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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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 5324b5c22c541454162dd284e283976c5ae8658c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:20:08 +0200 Subject: [PATCH 13/86] Add portable keyboard, gamepad and wheel input hooks --- .github/workflows/input-hook-contract.yml | 43 +++++++ docs/portable-input-hooks.md | 81 +++++++++++++ examples/portable_input/README.md | 17 +++ examples/portable_input/main.lua | 24 ++++ examples/portable_input/manifest.json | 18 +++ src/core/Game.lua | 81 +++++++++++-- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/portable_input_hooks.lua | 32 ++++++ tests/engine/portable_input_routing.lua | 125 +++++++++++++++++++++ tests/input_hooks/cases.lua | 131 ++++++++++++++++++++++ tests/input_hooks/fixture.lua | 74 ++++++++++++ tests/input_hooks/worker.lua | 7 ++ tools/input-hook-contract-check/README.md | 16 +++ tools/input-hook-contract-check/check.py | 58 ++++++++++ 15 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/input-hook-contract.yml create mode 100644 docs/portable-input-hooks.md create mode 100644 examples/portable_input/README.md create mode 100644 examples/portable_input/main.lua create mode 100644 examples/portable_input/manifest.json create mode 100644 tests/engine/portable_input_hooks.lua create mode 100644 tests/engine/portable_input_routing.lua create mode 100644 tests/input_hooks/cases.lua create mode 100644 tests/input_hooks/fixture.lua create mode 100644 tests/input_hooks/worker.lua create mode 100644 tools/input-hook-contract-check/README.md create mode 100644 tools/input-hook-contract-check/check.py diff --git a/.github/workflows/input-hook-contract.yml b/.github/workflows/input-hook-contract.yml new file mode 100644 index 000000000..fa6ad76cf --- /dev/null +++ b/.github/workflows/input-hook-contract.yml @@ -0,0 +1,43 @@ +name: portable input hook contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: input-hook-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native input and host routing + run: | + luajit tests/engine/portable_input_hooks.lua + luajit tests/engine/portable_input_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/input-hook-contract + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/input-hook-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/input-hook-contract/api2-unified.json + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/input-hook-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: input-hook-contract + path: /tmp/input-hook-contract + if-no-files-found: ignore diff --git a/docs/portable-input-hooks.md b/docs/portable-input-hooks.md new file mode 100644 index 000000000..25ebda673 --- /dev/null +++ b/docs/portable-input-hooks.md @@ -0,0 +1,81 @@ +# Portable raw input hooks + +Gen2Recomped dispatches `input.key`, `input.gamepad`, and `input.wheel` +through `mod.hooks:wrap`, matching the native Gen1Recomp callbacks at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. A mod overlay can observe or +capture input without replacing engine methods or importing private modules. + +| Hook | Callback arguments after `next` | Payload | +| --- | --- | --- | +| `input.key` | `game, event` | `{ phase = "pressed" or "released", key = key }` | +| `input.gamepad` | `game, event` | Button: `{ phase = "pressed" or "released", joystick = joystick, button = button }` | +| `input.gamepad` | `game, event` | Axis: `{ phase = "axis", joystick = joystick, axis = axis, value = value }` | +| `input.wheel` | `game, dy` | Original numeric vertical delta, including fractions and zero | + +`game` and `joystick` retain their native identities. The joystick is an +opaque host handle, not a portable serializable record. Button events do not +include axis fields, and axis events do not include `button`. Keyboard +scancodes/repeat flags and horizontal wheel deltas are not part of this +contract. + +```lua +local mod = ... +local captured = false +mod.hooks:wrap("input.key", function(next, game, event) + if captured then return end + return next(game, event) +end) +``` + +Omitting `next` suppresses the entire normal Game callback, including its +hotkeys, top-state capture, Input updates and axis transitions. It also +suppresses wheel zoom. A returned boolean has no independent consume meaning: +calling `next` and then returning `true` or `false` has already forwarded the +event. `next()` forwards the current hook arguments. As in Gen1Recomp, +`next(game, replacement)` changes what downstream wrappers see; the native +body still acts on the original callback arguments. This is not a synthetic +input API. + +Releases can be captured too. If a mod forwards a press, it must also let the +matching release through or deliberately manage its capture lifecycle; +otherwise the native held state remains set. Prefer capturing complete +press/release pairs. Activating an overlay between a forwarded press and its +release needs particular care. Unsubscribing removes callbacks, not held state. + +Hooks run at the live Game boundary. Launcher/importer/editor routing and +accelerometer filtering outside that boundary retain their existing behavior. +Within Gen2Recomped, existing native routing is preserved: emergency hotkeys +precede top-state key capture, releases update Input before capture observers, +and the free camera keeps its existing wheel behavior (including zero delta). +Gen1-specific gamepad polling/watchdog bookkeeping is still owned by Gen1; +this contract does not promise identical internal input algorithms. + +When no hook chain is installed, calls bypass the hook bus and allocate no +hook payload or continuation closure. Existing bus priority, error handling, unsubscribe and +owner cleanup apply. Wrappers that fail before `next` are skipped; a failure +after forwarding does not replay native side effects; native errors propagate. + +The hooks work with native API 1/2, unified API 2 and API 3. The central +callback and capability catalogs include them, so +`mod:supports("hooks", name)` reports availability before the mod entry runs. +This change builds on the common API-3 entry feature (PR #102). + +## Verification + +```sh +luajit tests/engine/portable_input_hooks.lua +luajit tests/engine/portable_input_routing.lua +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The ROM-free checker loads the same committed example entry chunk through +each engine's real mod loader and invokes its native Game callbacks with the +real Input singleton. It covers Red, Gold, FireRed and Emerald, checking raw +payloads/identity, forwarded and suppressed input, held releases, fractional +and zero wheel values, continuation rewrites, callback errors and unsubscribe. +It compares input-state journals across engines and refuses a dirty or +unexpected Gen1 reference. It does not replace Gen1 callback implementations. +The Gen2 routing suite additionally covers every supported host version, +menu capture, engine hotkeys, touch bookkeeping and free-camera zoom. diff --git a/examples/portable_input/README.md b/examples/portable_input/README.md new file mode 100644 index 000000000..06a051d02 --- /dev/null +++ b/examples/portable_input/README.md @@ -0,0 +1,17 @@ +# Portable input overlay example + +Copy this directory into `mods/`. The same `main.lua` runs on Gen1Recomp and +Gen2Recomped. `exports.events` journals the three raw input hooks; +`exports.capture(true)` captures complete input events and `capture(false)` +forwards them. `exports.stop()` removes the subscriptions. The exported `api` +lets the headless contract runner add further public wrappers for error and +priority checks. + +Keep capture paired across presses and releases. Forwarding a press and +swallowing its release leaves that native input held. The example starts +with capture disabled and leaves native input active. + +The manifest uses API 2 for compatibility with current standalone hosts. The +contract checker also loads this exact entry chunk with unified API-2 and +API-3 manifests when those host features are installed. See +[the hook contract](../../docs/portable-input-hooks.md). diff --git a/examples/portable_input/main.lua b/examples/portable_input/main.lua new file mode 100644 index 000000000..de8a7d2ca --- /dev/null +++ b/examples/portable_input/main.lua @@ -0,0 +1,24 @@ +-- The same entry chunk runs on both engines; no private engine imports. +local mod = ... +local events, captured = {}, false +mod.exports.events = events +mod.exports.api = mod +function mod.exports.capture(value) + captured = value +end +local off = {} +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + off[#off + 1] = mod.hooks:wrap(name, function(next, game, event) + events[#events + 1] = { name = name, game = game, event = event } + -- An overlay owns both press and release while capture is active. + if captured then + return "captured" + end + return next(game, event) + end) +end +function mod.exports.stop() + for _, unsubscribe in ipairs(off) do + unsubscribe() + end +end diff --git a/examples/portable_input/manifest.json b/examples/portable_input/manifest.json new file mode 100644 index 000000000..914b02d7c --- /dev/null +++ b/examples/portable_input/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "portable_input", + "name": "Portable Input Hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": [ + "all" + ], + "generations": [ + 1, + 2, + 3, + 4, + 5 + ], + "affects_link": false +} diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..54c8ed21c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1192,7 +1192,7 @@ function Game:zoomStep(delta) end end -function Game:wheelmoved(_, dy) +local function wheelmovedBody(self, dy) -- THE WHEEL ZOOMS THE FREE CAMERA when one owns the frame, and the -- window otherwise. -- @@ -1213,6 +1213,17 @@ function Game:wheelmoved(_, dy) end end +function Game:wheelmoved(_, dy) + if not ModRuntime.wantsHook("input.wheel") then + return wheelmovedBody(self, dy) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.wheel", function() + return wheelmovedBody(self, dy) + end, self, dy) +end + -- WHEN THE STACK IS EMPTIED AND NOBODY PUSHES ANYTHING BACK. -- -- Traced from play, straight out of the log: @@ -1344,7 +1355,7 @@ function Game:cycleGen4CameraTilt() return true end -function Game:keypressed(key) +local function keypressedBody(self, key) -- ...ahead of the delegation below, which is unconditional. if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end @@ -1477,6 +1488,17 @@ function Game:keypressed(key) Input:keypressed(key) end +function Game:keypressed(key) + if not ModRuntime.wantsHook("input.key") then + return keypressedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keypressedBody(self, key) + end, self, { phase = "pressed", key = key }) +end + function Game:textinput(text) local top = self.stack and self.stack:top() if top and top.onTextInput then @@ -1504,19 +1526,31 @@ function Game:returnToLauncher() require("src.core.HostShell").toLauncher() end --- Releases reach Input even while a top state captures raw input: a +-- Unless an input.key wrapper swallows the event, releases reach Input +-- even while a top state captures raw input: a -- swallowed key-up would strand a held-state flag for a key Input saw go -- down before the capture armed (the stuck-flag hazard Input:reset -- exists for). The top state only OBSERVES the release afterwards, -- unlike onKeyPressed above which owns the press, so BindingsMenu can -- commit a capture on the key-up (#589). -function Game:keyreleased(key) +local function keyreleasedBody(self, key) Input:keyreleased(key) local top = self.stack and self.stack:top() if top and top.onKeyReleased then top:onKeyReleased(key) end end -function Game:gamepadpressed(joystick, button) +function Game:keyreleased(key) + if not ModRuntime.wantsHook("input.key") then + return keyreleasedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keyreleasedBody(self, key) + end, self, { phase = "released", key = key }) +end + +local function gamepadpressedBody(self, joystick, button) -- a controller is being used: the touch overlay steps aside until the -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() @@ -1529,14 +1563,36 @@ function Game:gamepadpressed(joystick, button) Input:gamepadpressed(joystick, button) end -function Game:gamepadreleased(joystick, button) +function Game:gamepadpressed(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadpressedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadpressedBody(self, joystick, button) + end, self, { phase = "pressed", joystick = joystick, button = button }) +end + +local function gamepadreleasedBody(self, joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) local top = self.stack and self.stack:top() if top and top.onGamepadReleased then top:onGamepadReleased(button) end end -function Game:gamepadaxis(joystick, axis, value) +function Game:gamepadreleased(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadreleasedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadreleasedBody(self, joystick, button) + end, self, { phase = "released", joystick = joystick, button = button }) +end + +local function gamepadaxisBody(self, joystick, axis, value) -- past-deadzone only, so resting-stick drift can't hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end -- the triggers and the right stick as bindable pseudo-buttons (Input @@ -1559,6 +1615,17 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +function Game:gamepadaxis(joystick, axis, value) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadaxisBody(self, joystick, axis, value) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadaxisBody(self, joystick, axis, value) + end, self, { phase = "axis", joystick = joystick, axis = axis, value = value }) +end + -- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the -- generic joystick path anyway: any sensor-style device that still reaches us -- has gravity pinning an axis past the deadzone, which would hide the touch diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 32da142d7..cdadbbde8 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.key input.gamepad input.wheel 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 diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..5f54085eb 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -19,7 +19,7 @@ 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.key input.gamepad input.wheel 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 diff --git a/tests/engine/portable_input_hooks.lua b/tests/engine/portable_input_hooks.lua new file mode 100644 index 000000000..31c6113cb --- /dev/null +++ b/tests/engine/portable_input_hooks.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 2, "native") +dofile("tests/input_hooks/cases.lua").run(f) +f.close() +-- The no-mod path bypasses Runtime.call altogether for all three hooks. +local Runtime = require("src.mods.Runtime") +local none = Fixture.new(".", "gen2", "red", 2, "native", true) +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(Runtime.wantsHook(name), false, "no subscriber: " .. name) +end +local call = Runtime.call +Runtime.call = function() + error("unsubscribed input entered hook bus") +end +none.game:keypressed("up") +T.eq(none.Input:isDown("up"), true, "no-mod native key press") +none.game:keyreleased("up") +T.eq(none.Input:isDown("up"), false, "no-mod native key release") +none.game:gamepadpressed(none.pad, "a") +T.eq(none.Input:isDown("a"), true, "no-mod native pad press") +none.game:gamepadreleased(none.pad, "a") +T.eq(none.Input:isDown("a"), false, "no-mod native pad release") +none.game:gamepadaxis(none.pad, "leftx", 0.9) +T.eq(none.Input:isDown("right"), true, "no-mod native axis") +none.game:gamepadaxis(none.pad, "leftx", 0) +none.game:wheelmoved(0, 1) +T.same(none.zoom, { 1 }, "no-mod native wheel") +Runtime.call = call +none.close() +T.finish("portable input hooks") diff --git a/tests/engine/portable_input_routing.lua b/tests/engine/portable_input_routing.lua new file mode 100644 index 000000000..2db2b0183 --- /dev/null +++ b/tests/engine/portable_input_routing.lua @@ -0,0 +1,125 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local Touch = require("src.core.TouchControls") +local Version = require("src.core.GameVersion") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 2, "native") + local g, I, api, e = f.game, f.Input, f.public, f.exports + local calls, top = {}, {} + g.stack.top = function() + return top + end + top.onKeyPressed = function(_, key) + calls[#calls + 1] = "key:" .. key + end + top.onKeyReleased = function(_, key) + T.eq(I:isDown("up"), false, "release reaches Input before capture observer: " .. version) + calls[#calls + 1] = "release:" .. key + end + local noted, note = 0, Touch.noteGamepad + Touch.noteGamepad = function() + noted = noted + 1 + end + top.onGamepadPressed = function(_, button) + calls[#calls + 1] = "pad:" .. button + end + top.onGamepadReleased = function(_, button) + if button == "a" then + T.eq(I:isDown("a"), false, "pad release before observer") + end + calls[#calls + 1] = "pad-release:" .. button + end + local unstick = 0 + g.unstick = function() + unstick = unstick + 1 + return "unstuck" + end + local held = {} + for key, value in pairs(I.axisHeld or {}) do + held[key] = value + end + e.capture(true) + g:keypressed("f9") + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(unstick, 0, "hook precedes engine emergency hotkey: " .. version) + T.eq(#calls, 0, "hook precedes every capture callback") + T.eq(noted, 0, "suppression precedes touch overlay bookkeeping") + T.same(I.axisHeld or {}, held, "suppression does not create pseudo-button state") + T.eq(#I.pressQueue, 0, "suppressed input creates no queued press") + e.capture(false) + T.eq(g:keypressed("f9"), "unstuck", "native hotkey result survives hooks") + T.eq(unstick, 1, "hotkey runs exactly once") + T.eq(#calls, 0, "native hotkey retains priority over top capture") + g:keypressed("up") + T.eq(I:isDown("up"), false, "top key capture keeps native priority over Input") + T.same(calls, { "key:up" }, "exactly one captured key") + I:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "top pad capture does not fall through") + I:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(calls[#calls], "pad:triggerleft", "swallowed axis did not consume later capture edge") + g:gamepadaxis(f.pad, "triggerleft", 0) + T.eq(calls[#calls], "pad-release:triggerleft", "axis release still reaches capture observer") + Touch.noteGamepad = note + -- Standard hook ordering, next() argument forwarding, and return values. + local order = {} + local a = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "outer" + T.eq(game, g, "original Game in outer wrapper") + T.eq(dy, 2.5, "fractional raw wheel delta") + next(game, -100) + order[#order + 1] = "after" + return false + end, 30) + local b = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "inner" + T.eq(dy, -100, "rewritten delta visible downstream") + return next() + end, 20) + local n = #f.zoom + T.eq(g:wheelmoved(777, 2.5), false, "outer hook return value preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and continuation") + T.eq(f.zoom[n + 1], 1, "native wheel still uses original positive delta") + a() + b() + -- Free-camera behavior (including its existing zero-delta step) is native. + local free = {} + g.zoomView = function() + return { + zoomBy = function(_, delta) + free[#free + 1] = delta + end, + } + end + for _, dy in ipairs({ 2.5, -1, 0 }) do + e.capture(true) + g:wheelmoved(0, dy) + e.capture(false) + g:wheelmoved(0, dy) + end + T.same(free, { -1, 1, 1 }, "free camera preserves native sign and zero behavior") + -- Nil and false are ordinary wrapper results; only omitting next suppresses. + local off = api.hooks:wrap("input.key", function() + return nil + end, 100) + n = #calls + T.eq(g:keypressed("up"), nil, "nil-result suppression") + T.eq(#calls, n, "nil-result wrapper prevents native callback") + off() + f.result.loader.hooks:removeOwner("portable_input") + n = #e.events + g:keypressed("up") + T.eq(#e.events, n, "owner cleanup removes public registrations") + f.close() +end +Version.set(oldVersion) +T.finish("portable input routing") diff --git a/tests/input_hooks/cases.lua b/tests/input_hooks/cases.lua new file mode 100644 index 000000000..006b8694c --- /dev/null +++ b/tests/input_hooks/cases.lua @@ -0,0 +1,131 @@ +local M = {} +function M.run(f) + local T, g, I, e = f.T, f.game, f.Input, f.exports + local rows = {} + local function state(label) + rows[#rows + 1] = { + label = label, + up = I:isDown("up"), + a = I:isDown("a"), + right = I:isDown("right"), + zoom = #f.zoom, + events = #e.events, + } + end + local function last(name, expected) + local seen = e.events[#e.events] + T.eq(seen.name, name, "hook name") + T.eq(seen.game, g, "original Game identity") + T.same(seen.event, expected, "exact raw payload") + end + g:keypressed("up") + last("input.key", { phase = "pressed", key = "up" }) + T.eq(I:isDown("up"), true, "forwarded keyboard press") + state("key-down") + e.capture(true) + T.eq(g:keyreleased("up"), "captured", "release may be swallowed") + last("input.key", { phase = "released", key = "up" }) + T.eq(I:isDown("up"), true, "swallowed release does not clear held input") + state("key-release-captured") + e.capture(false) + g:keyreleased("up") + T.eq(I:isDown("up"), false, "forwarded release clears held input") + e.capture(true) + g:keypressed("up") + T.eq(I:isDown("up"), false, "swallowed press never reaches Input") + state("key-press-captured") + e.capture(false) + g:gamepadpressed(f.pad, "a") + last("input.gamepad", { phase = "pressed", joystick = f.pad, button = "a" }) + T.eq(e.events[#e.events].event.joystick, f.pad, "opaque joystick identity") + T.eq(I:isDown("a"), true, "forwarded pad press") + e.capture(true) + g:gamepadreleased(f.pad, "a") + last("input.gamepad", { phase = "released", joystick = f.pad, button = "a" }) + T.eq(I:isDown("a"), true, "swallowed pad release retains held input") + state("pad-release-captured") + e.capture(false) + g:gamepadreleased(f.pad, "a") + T.eq(I:isDown("a"), false, "forwarded pad release clears held input") + e.capture(true) + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "swallowed pad press") + g:gamepadaxis(f.pad, "leftx", 0.875) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0.875 }) + T.eq(I:isDown("right"), false, "swallowed axis does not create direction") + state("axis-captured") + e.capture(false) + g:gamepadaxis(f.pad, "leftx", 0.875) + T.eq(I:isDown("right"), true, "native axis produces direction") + state("axis-forwarded") + g:gamepadaxis(f.pad, "leftx", 0) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0 }) + T.eq(I:isDown("right"), false, "zero axis releases direction") + for _, dy in ipairs({ -1, 2.5, 0 }) do + local n = #f.zoom + e.capture(true) + T.eq(g:wheelmoved(99, dy), "captured", "wheel suppression result") + last("input.wheel", dy) + T.eq(#f.zoom, n, "captured wheel does not zoom") + e.capture(false) + g:wheelmoved(99, dy) + last("input.wheel", dy) + T.eq(#f.zoom, n + (dy == 0 and 0 or 1), "native wheel delta") + end + state("wheel-forwarded") + local api = f.public + if api.supports then + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(api:supports("hooks", name), true, "public capability " .. name) + end + T.eq(api:supports("hooks", "input.not_a_hook"), false, "unknown hook still declines") + end + -- Rewriting next's arguments changes downstream observations, not the + -- original event captured by the native Game body (the Gen1 contract). + local off = api.hooks:wrap("input.key", function(next, game, ev) + return next(game, { phase = ev.phase, key = "down" }) + end, 100) + g:keypressed("up") + T.eq(e.events[#e.events].event.key, "down", "downstream sees replacement payload") + T.eq(I:isDown("up"), true, "native callback retains original key") + T.eq(I:isDown("down"), false, "replacement does not synthesize another key") + off() + g:keyreleased("up") + -- Errors before/after next must not lose or replay a native event. + for _, after in ipairs({ false, true }) do + local offError = api.hooks:wrap("input.wheel", function(next) + if after then + next() + end + error("intentional input contract failure") + end, 100) + local n = #f.zoom + g:wheelmoved(0, 1) + T.eq(#f.zoom, n + 1, "wrapper error preserves exactly one native call") + offError() + end + local original = g.zoomStep + local calls = 0 + g.zoomStep = function() + calls = calls + 1 + error("native zoom failure") + end + T.raises(function() + g:wheelmoved(0, 1) + end, "native zoom failure", "native failure propagates") + T.eq(calls, 1, "native failure is never retried") + g.zoomStep = original + local n = #e.events + e.stop() + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "leftx", 0) + g:wheelmoved(0, 1) + T.eq(#e.events, n, "unsubscribe removes every fixture callback") + T.eq(I:isDown("up"), false, "empty chains preserve native release") + T.eq(I:isDown("a"), false, "empty chains preserve native pad release") + return rows +end +return M diff --git a/tests/input_hooks/fixture.lua b/tests/input_hooks/fixture.lua new file mode 100644 index 000000000..4f4f9a133 --- /dev/null +++ b/tests/input_hooks/fixture.lua @@ -0,0 +1,74 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Input = require("src.core.Input") + local name = backend == "gen1" + and (version == "gold" and "Game2" or (version == "firered" or version == "emerald") and "Game3" or "Game") + or "Game" + local Game = require("src.core." .. name) + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_input/" .. path, "rb")) + files["mods/portable_input/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_input/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_input/manifest.json"] = Json.encode(manifest) + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local f = { + T = T, + result = result, + Input = Input, + Game = Game, + version = version, + api = api, + public = result.loader.exports.portable_input and result.loader.exports.portable_input.api, + exports = result.loader.exports.portable_input, + } + local zoom = {} + local game = setmetatable({ + input = Input, + mods = result.loader, + data = result.data, + save = { options = {} }, + phase = "world", + stack = { + top = function() + return nil + end, + }, + zoomStep = function(_, delta) + zoom[#zoom + 1] = delta + end, + zoomView = function() + return nil + end, + }, { __index = Game }) + f.game, f.zoom = game, zoom + result.loader.game = game + f.pad = { + getName = function() + return "Contract Gamepad" + end, + isGamepadDown = function() + return false + end, + getGamepadAxis = function() + return 0 + end, + } + function f.close() + result.release() + end + return f +end +return M diff --git a/tests/input_hooks/worker.lua b/tests/input_hooks/worker.lua new file mode 100644 index 000000000..a892d9679 --- /dev/null +++ b/tests/input_hooks/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/input_hooks/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/input_hooks/cases.lua").run(f) +f.close() +print("INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable input contract") diff --git a/tools/input-hook-contract-check/README.md b/tools/input-hook-contract-check/README.md new file mode 100644 index 000000000..c8e0c2195 --- /dev/null +++ b/tools/input-hook-contract-check/README.md @@ -0,0 +1,16 @@ +# Cross-engine input hook contract + +Run `python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +--lua luajit --report /tmp/input-contract.json` from Gen2Recomped. + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native loaders execute +`examples/portable_input/main.lua` unchanged, against the real Input singleton +and native Game/Game2/Game3 callbacks. Four host versions run in separate +processes. Each run asserts the contract and the checker compares resulting +held-state journals. No ROM or window is needed, and Gen1 is never patched. + +Use `--format unified` for API-2 callback-format integration and +`--api 3 --format unified` for the common API-3 entry. These modes require the +corresponding host features; unsupported manifests fail rather than being +silently downgraded. `--gen2` selects a separate integration checkout. diff --git a/tools/input-hook-contract-check/check.py b/tools/input-hook-contract-check/check.py new file mode 100644 index 000000000..e5a6fa67c --- /dev/null +++ b/tools/input-hook-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged input mod against both engines' native Game callbacks.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/input_hooks/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From bbf00f2df2657da2d2b554a2b6cc40a5bc166339 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:47:07 +0200 Subject: [PATCH 14/86] Add portable mod.input tap, press and release controls --- .github/workflows/mod-input-contract.yml | 44 +++++ docs/portable-mod-input.md | 78 +++++++++ examples/portable_mod_input/README.md | 15 ++ examples/portable_mod_input/main.lua | 16 ++ examples/portable_mod_input/manifest.json | 10 ++ src/core/Input.lua | 12 ++ src/dev/HotReload.lua | 1 + src/mods/InputControl.lua | 140 ++++++++++++++++ src/mods/Loader.lua | 17 ++ tests/engine/mod_input_headless.lua | 64 ++++++++ tests/engine/mod_input_lifecycle.lua | 190 ++++++++++++++++++++++ tests/engine/portable_mod_input.lua | 6 + tests/mod_input/cases.lua | 130 +++++++++++++++ tests/mod_input/fixture.lua | 60 +++++++ tests/mod_input/worker.lua | 7 + tools/mod-input-contract-check/README.md | 19 +++ tools/mod-input-contract-check/check.py | 58 +++++++ 17 files changed, 867 insertions(+) create mode 100644 .github/workflows/mod-input-contract.yml create mode 100644 docs/portable-mod-input.md create mode 100644 examples/portable_mod_input/README.md create mode 100644 examples/portable_mod_input/main.lua create mode 100644 examples/portable_mod_input/manifest.json create mode 100644 src/mods/InputControl.lua create mode 100644 tests/engine/mod_input_headless.lua create mode 100644 tests/engine/mod_input_lifecycle.lua create mode 100644 tests/engine/portable_mod_input.lua create mode 100644 tests/mod_input/cases.lua create mode 100644 tests/mod_input/fixture.lua create mode 100644 tests/mod_input/worker.lua create mode 100644 tools/mod-input-contract-check/README.md create mode 100644 tools/mod-input-contract-check/check.py diff --git a/.github/workflows/mod-input-contract.yml b/.github/workflows/mod-input-contract.yml new file mode 100644 index 000000000..acbbad865 --- /dev/null +++ b/.github/workflows/mod-input-contract.yml @@ -0,0 +1,44 @@ +name: portable logical mod input contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: mod-input-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check logical input, headless loading and lifecycle + run: | + luajit tests/engine/portable_mod_input.lua + luajit tests/engine/mod_input_lifecycle.lua + luajit tests/engine/mod_input_headless.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/mod-input-contract + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/mod-input-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/mod-input-contract/api2-unified.json + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/mod-input-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: mod-input-contract + path: /tmp/mod-input-contract + if-no-files-found: ignore diff --git a/docs/portable-mod-input.md b/docs/portable-mod-input.md new file mode 100644 index 000000000..51c705fff --- /dev/null +++ b/docs/portable-mod-input.md @@ -0,0 +1,78 @@ +# Portable logical mod input + +`mod.input` implements Gen1Recomp's source-owned logical input contract in +Gen2Recomped, for native API 1/2, unified API 2 and API 3. The signatures are: + +| Method | Result | Effect | +| --- | --- | --- | +| `mod.input:tap(game, button)` | `nil` | Queue an edge for the next fixed step and immediately release this call's source | +| `mod.input:press(game, button)` | opaque token | Queue an edge and hold this call's source until released | +| `mod.input:release(token)` | boolean | Release this mod's token once; return `false` for missing, foreign, forged, retired or already released tokens | + +Buttons are exactly `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`, in lowercase. They are logical actions, independent of keyboard +aliases or user rebindings. Unknown buttons raise an error before input is +changed. The common contract does not include shoulder or DS face buttons. + +Wait for `game.ready` to obtain the live game; both `tap` and `press` take +that explicit handle. Constructing or reading `mod.input` does not create a +Game/Input instance, register callbacks, or require graphics. + +```lua +local mod = ... +mod.events:on("game.ready", function(event) + local game = event.game + mod.input:tap(game, "a") + local token = mod.input:press(game, "right") + -- Later, when this mod's action ends: + mod.input:release(token) +end) +``` + +Keep tokens in memory; they carry no editable fields and cannot be copied or +serialized. Each press owns a separate source. Releasing one token preserves +other tokens for the same button, other mods, keyboard/gamepad input and +virtual controls. Multiple taps before the same fixed step coalesce into that +step's single `wasPressed` edge. A tap's own source never remains held, but +another source can still hold the button. + +The normal Input queue determines edge lifetime. Releasing a token or +cleaning up a mod releases its held source; it does not undo an edge already +queued. A press followed by a release before the next step still yields one +edge and no held state. An Input reset also clears queued edges. + +Gen2Recomped retires tokens whenever their Input instance resets, including +focus/visibility changes, controller removal and input initialization. The +handle remains usable after recovery. Rollback retires the failing mod's +handle; hot reload, a new loader session and launcher teardown retire old +handles. A retired handle refuses new taps/presses. Tokens cannot release +sources belonging to a replacement session, even for the same mod ID. +Outstanding holds remain registered for teardown even if the caller loses +its token or loader references. + +Logical input goes directly to Input's per-source bookkeeping. It does not +synthesize OS events or dispatch `input.key`, `input.gamepad`, `input.wheel` +or engine display hotkeys. A raw-input hook can therefore request a logical +button without recursively entering itself. + +## Verification + +The reference is unmodified Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The checker loads the same example +entry chunk through both native mod loaders, emits `game.ready`, and checks +the real Input singleton's held/edge state on Red, Gold, FireRed and Emerald. +It tests the shared contract; the Gen2 lifecycle suite separately verifies +Gen2's reset and retired-handle behavior. + +```sh +luajit tests/engine/portable_mod_input.lua +luajit tests/engine/mod_input_lifecycle.lua +luajit tests/engine/mod_input_headless.lua +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The lifecycle tests cover all supported Gen2Recomped host versions under +API 1, native/unified API 2 and API 3. The checks are ROM-free and headless; +they do not measure OS event delivery or physical controller hardware. diff --git a/examples/portable_mod_input/README.md b/examples/portable_mod_input/README.md new file mode 100644 index 000000000..fe3001d71 --- /dev/null +++ b/examples/portable_mod_input/README.md @@ -0,0 +1,15 @@ +# Portable logical input example + +Copy this directory into `mods/`. The unchanged entry chunk runs on both +Gen1Recomp and Gen2Recomped. It obtains the Game through `game.ready` and +exports `tap(button)`, `press(button)` and `release(token)` helpers, together +with the underlying public `input` handle for contract tests. It does not +send input until a helper is called. + +`press` returns an opaque token which must be released by the same mod. +Available buttons are `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`. Do not store tokens in saves. See +[the contract and lifecycle rules](../../docs/portable-mod-input.md). + +The manifest uses API 2. The contract runner also exercises the exact same +entry chunk with unified API-2 and API-3 manifests. diff --git a/examples/portable_mod_input/main.lua b/examples/portable_mod_input/main.lua new file mode 100644 index 000000000..44119e6cb --- /dev/null +++ b/examples/portable_mod_input/main.lua @@ -0,0 +1,16 @@ +-- Logical buttons, not keyboard aliases; unchanged across both engines. +local mod = ... +local game +mod.exports.input = mod.input +mod.events:on("game.ready", function(event) + game = event.game +end) +function mod.exports.tap(button) + return mod.input:tap(game, button) +end +function mod.exports.press(button) + return mod.input:press(game, button) +end +function mod.exports.release(token) + return mod.input:release(token) +end diff --git a/examples/portable_mod_input/manifest.json b/examples/portable_mod_input/manifest.json new file mode 100644 index 000000000..69112779f --- /dev/null +++ b/examples/portable_mod_input/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_mod_input", + "name": "Portable Logical Input", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/Input.lua b/src/core/Input.lua index e55bf0cbf..f808bbdd8 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -8,6 +8,7 @@ -- table at call time keeps that entirely inside GamepadMap. local GamepadMap = require("src.core.GamepadMap") local GameVersion = require("src.core.GameVersion") +local InputControl = require("src.mods.InputControl") local Input = {} @@ -144,6 +145,7 @@ end -- that would have cleared a held direction. Called from Game on those -- transitions so a stuck flag can't outlive them. function Input:reset() + InputControl.forget(self) self.state = {} self.pressQueue = {} self.pressed = {} @@ -237,6 +239,16 @@ function Input:overlayReleased(btn) release(self, btn, "touch:" .. btn) end +-- Internal source-aware entry points for mod.input. Each token owns a +-- separate source, so releasing it cannot release physical/other mod input. +function Input:sourcePress(button, source) + press(self, button, source) +end + +function Input:sourceRelease(button, source) + release(self, button, source) +end + function Input:gamepadpressed(joystick, button) local btn = self.padBindings[button] if btn then diff --git a/src/dev/HotReload.lua b/src/dev/HotReload.lua index 0a36309fb..37aee8ea7 100644 --- a/src/dev/HotReload.lua +++ b/src/dev/HotReload.lua @@ -29,6 +29,7 @@ function HotReload.run(game, opts) local Assets = require("src.render.Assets") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") + if game.mods and game.mods._closeModInput then game.mods:_closeModInput() end local data = game.data if data and data.reloadGenerated then data:reloadGenerated() end local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil) diff --git a/src/mods/InputControl.lua b/src/mods/InputControl.lua new file mode 100644 index 000000000..03eea1a79 --- /dev/null +++ b/src/mods/InputControl.lua @@ -0,0 +1,140 @@ +-- Per-loader, per-mod ownership for logical GB input. No Game/Input import: +-- handles may be constructed at the title screen or in a headless loader. +local Control = {} +Control.__index = Control +local sessions = setmetatable({}, { __mode = "k" }) +-- Keep sessions with outstanding holds alive until release/reset/teardown. +local holding = {} +local serial = 0 +local BUTTONS = { + up = true, + down = true, + left = true, + right = true, + a = true, + b = true, + start = true, + select = true, +} + +local function idle(session) + for _, bucket in pairs(session.owners) do + if next(bucket.tokens) then + return + end + end + holding[session] = nil +end + +function Control.new() + serial = serial + 1 + local self = setmetatable({ serial = serial, owners = {} }, Control) + sessions[self] = true + return self +end + +function Control:release(owner) + if owner == nil then + for id in pairs(self.owners) do + self:release(id) + end + return + end + local bucket = self.owners[owner] + if not bucket then + return + end + for token, record in pairs(bucket.tokens) do + bucket.tokens[token] = nil + record.input:sourceRelease(record.button, record.source) + end + idle(self) +end + +function Control:retire(owner) + local bucket = self.owners[owner] + if not bucket then + return + end + self:release(owner) + bucket.retired = true + self.owners[owner] = nil +end + +function Control:close() + for owner in pairs(self.owners) do + self:retire(owner) + end + self.closed = true + sessions[self] = nil + holding[self] = nil +end + +function Control:api(owner) + assert(not self.closed, "mod.input is no longer active") + self:retire(owner) + local bucket = { tokens = {}, seq = 0 } + self.owners[owner] = bucket + local function sourceFor(game, button) + assert(not self.closed and not bucket.retired, "mod.input is no longer active") + local input = type(game) == "table" and game.input + assert( + type(input) == "table" + and type(input.sourcePress) == "function" + and type(input.sourceRelease) == "function", + "mod.input needs the live game (see game.ready)" + ) + assert(BUTTONS[button], "unknown GB button: " .. tostring(button)) + bucket.seq = bucket.seq + 1 + -- A retired loader's sources cannot alias those of its replacement. + return input, ("mod:%s:%d:%d"):format(owner, self.serial, bucket.seq) + end + return { + tap = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + input:sourceRelease(button, source) + end, + press = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + local token = {} + bucket.tokens[token] = { input = input, button = button, source = source } + holding[self] = true + return token + end, + release = function(_, token) + local record = bucket.tokens[token] + if not record then + return false + end + bucket.tokens[token] = nil + idle(self) + record.input:sourceRelease(record.button, record.source) + return true + end, + } +end + +-- Input:reset already discards every physical and synthetic source. Retire +-- only tokens bound to that Input, without touching another game's holds. +function Control.forget(input) + for session in pairs(sessions) do + for _, bucket in pairs(session.owners) do + for token, record in pairs(bucket.tokens) do + if record.input == input then + bucket.tokens[token] = nil + end + end + end + idle(session) + end +end + +function Control.closeAll() + for session in pairs(sessions) do + session:close() + end +end + +return Control diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..4802d84aa 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -24,6 +24,7 @@ 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 InputControl = require("src.mods.InputControl") local BattleSnapshots = require("src.mods.BattleSnapshots") local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") @@ -158,6 +159,7 @@ function Loader.new(opts) mods = {}, loaded = {}, errors = {}, initialized = false, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, apis = {}, migrations = {}, order = {}, + inputControl = InputControl.new(), modSave = {}, modOptions = {}, optionSchemas = {}, optionStatus = {}, imageCache = {}, fs = (opts and opts.fs) or (love and love.filesystem), @@ -810,6 +812,16 @@ function Loader.freshness(fs, root) return (" (newest file %s, %s)"):format(tostring(newestName), when) end +-- Recovery releases holds while keeping the current API usable. Retirement +-- (rollback/reload/teardown) additionally disables the old API's presses. +function Loader:releaseModInput(owner) + self.inputControl:release(owner) +end + +function Loader:_closeModInput() + self.inputControl:close() +end + function Loader:_api(mod) local loader = self local modId = mod.manifest.id @@ -922,6 +934,7 @@ function Loader:_api(mod) return loader.events:emit(name, payload) end, }, + input = loader.inputControl:api(modId), hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, @@ -1262,6 +1275,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) + self.inputControl:retire(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 +1391,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + InputControl.closeAll() + self.inputControl = InputControl.new() 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 +1737,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() + InputControl.closeAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() diff --git a/tests/engine/mod_input_headless.lua b/tests/engine/mod_input_headless.lua new file mode 100644 index 000000000..15f816759 --- /dev/null +++ b/tests/engine/mod_input_headless.lua @@ -0,0 +1,64 @@ +-- Construct/query the input facade without a live Game, Input or LOVE runtime. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +love = nil +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local manifest = Json.encode({ + id = "headless_input", + name = "Headless Input", + version = "1.0.0", + api = 2, + entry = "main.lua", +}) +local files = { + ["mods/headless_input/manifest.json"] = manifest, + ["mods/headless_input/main.lua"] = [[ + local mod = ... + mod.exports.input = mod.input + mod.exports.hasHandle = type(mod.input.tap) == "function" + and type(mod.input.press) == "function" and type(mod.input.release) == "function" + ]], +} +local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(files[path], path) + end, + getInfo = function(path) + if path == "mods" or path == "mods/headless_input" then + return { type = "directory" } + end + if files[path] then + return { type = "file" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "headless_input" } or {} + end, +} +local oldGame, oldInput = package.loaded["src.core.Game"], package.loaded["src.core.Input"] +local loader = Loader.new({ fs = fs, dev = true }) +loader._game = function() + error("mod.input must not bootstrap Game") +end +T.check(loader:load({ pokemon = {} }), "headless native API loads") +T.eq(#loader.errors, 0, "no load-time error") +local e = assert(loader.exports.headless_input) +T.eq(e.hasHandle, true, "complete facade available inside entry chunk") +for _, method in ipairs({ "tap", "press" }) do + for _, game in ipairs({ false, 1, "game", {}, { input = {} } }) do + T.raises(function() + e.input[method](e.input, game, "a") + end, "mod.input needs the live game", "invalid game rejected before Input work") + end +end +T.eq(e.input:release({}), false, "unknown token is safe before Game exists") +T.eq(e.input:release(nil), false, "missing token is safe before Game exists") +T.eq(package.loaded["src.core.Game"], oldGame, "does not import Game") +T.eq(package.loaded["src.core.Input"], oldInput, "does not import Input") +T.eq(love, nil, "does not install LOVE globals") +loader:_closeModInput() +T.finish("headless mod.input") diff --git a/tests/engine/mod_input_lifecycle.lua b/tests/engine/mod_input_lifecycle.lua new file mode 100644 index 000000000..32267d04f --- /dev/null +++ b/tests/engine/mod_input_lifecycle.lua @@ -0,0 +1,190 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/mod_input/fixture.lua") +local Control = require("src.mods.InputControl") +local Input = require("src.core.Input") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local savedVersion = Version.get() +Touch:init() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + f.ready() + local own, peer, g = f.primary, f.peer, f.game + setmetatable(g, { __index = Game }) + for _, recover in ipairs({ + function() + g:focus(false) + end, + function() + g:focus(true) + end, + function() + g:visible(false) + end, + function() + g:visible(true) + end, + function() + g:joystickremoved(nil) + end, + function() + Input:init() + end, + }) do + local token = own.press("up") + Input:step() + recover() + T.eq(Input:isDown("up"), false, label .. "native recovery clears hold") + T.eq(own.release(token), false, label .. "native recovery retires token") + local fresh = own.press("up") + T.eq(own.release(token), false, label .. "old token cannot affect resumed input") + T.eq(Input:isDown("up"), true, label .. "new input remains held") + T.eq(own.release(fresh), true, label .. "API remains usable after recovery") + end + local api = f.result.loader.apis.portable_mod_input + local seen = 0 + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + api.hooks:wrap(name, function(next, ...) + seen = seen + 1 + return next(...) + end) + end + own.tap("a") + local token = own.press("b") + own.release(token) + T.eq(seen, 0, label .. "logical input bypasses raw hooks/hotkeys") + g:keypressed("up") + g:keyreleased("up") + T.eq(seen, 2, label .. "physical input still dispatches raw hooks") + -- Logical input can be used within a raw hook without recursion. + local held + local off = api.hooks:wrap("input.key", function(_, game, event) + if event.phase == "pressed" then + held = own.input:press(game, "a") + end + end, 100) + g:keypressed("up") + T.eq(Input:isDown("up"), false, label .. "raw hook can suppress original action") + T.eq(Input:isDown("a"), true, label .. "raw hook can request logical action") + T.eq(own.release(held), true, label .. "logical hold from hook releases") + off() + -- Replacing a mod's handle retires the old one without touching peers. + token = own.press("select") + local peerToken = peer.press("select") + local fresh = f.result.loader.inputControl:api("portable_mod_input") + T.eq(own.release(token), false, label .. "replaced API retires old token") + T.eq(Input:isDown("select"), true, label .. "replacement preserves peer") + T.raises(function() + own.press("a") + end, "no longer active", label .. "old API cannot press again") + token = fresh:press(g, "select") + peer.release(peerToken) + T.eq(Input:isDown("select"), true, label .. "new handle owns independent source") + T.eq(fresh:release(token), true, label .. "new handle releases normally") + f.close() + f.result.loader:_closeModInput() + end +end +-- A reset retires tokens for that Input instance only. +Input:init() +local other = setmetatable({}, { __index = Input }) +other:init() +local control = Control.new() +local api = control:api("isolation") +local a = api:press({ input = Input }, "a") +local b = api:press({ input = other }, "a") +Input:reset() +T.eq(api:release(a), false, "reset retires matching Input token") +T.eq(other:isDown("a"), true, "another Input instance remains held") +T.eq(api:release(b), true, "another Input instance's token remains valid") +control:close() +-- Two sessions with the same mod ID still own different sources. +local one, two = Control.new(), Control.new() +local x, y = one:api("same_id"), two:api("same_id") +a = x:press({ input = Input }, "b") +b = y:press({ input = Input }, "b") +one:close() +T.eq(x:release(a), false, "closed session token retired") +T.eq(Input:isDown("b"), true, "closing old session preserves replacement source") +T.eq(y:release(b), true, "replacement session releases normally") +two:close() +-- A failed entry is cleaned through the real loader's rollback path. +Input:init() +local capture = { game = { input = Input } } +_G.MOD_INPUT_ROLLBACK_FIXTURE = capture +local files = {} +local Json = require("src.link.Json") +for _, id in ipairs({ "aaa_input_peer", "broken_input" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 3, + games = { "all" }, + generations = { 1, 2, 3, 4, 5 }, + entry = "main.lua", + }) +end +files["mods/aaa_input_peer/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.peer, f.peerToken = mod.input, mod.input:press(f.game, "b") +]] +files["mods/broken_input/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.broken, f.brokenToken = mod.input, mod.input:press(f.game, "b") + f.brokenSelect = mod.input:press(f.game, "select") + error("intentional mod.input rollback") +]] +local failed = T.sdk.loadMods({ "mods/aaa_input_peer", "mods/broken_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(files), + dev = false, +}) +_G.MOD_INPUT_ROLLBACK_FIXTURE = nil +T.check(#failed.errors > 0, "failed input entry reported") +T.eq(Input:isDown("select"), false, "failed entry releases its exclusive hold") +T.eq(Input:isDown("b"), true, "failed entry preserves successful peer hold") +T.eq(capture.broken:release(capture.brokenToken), false, "rollback retires failed token") +T.eq(capture.peer:release(capture.peerToken), true, "peer still owns its token") +T.raises(function() + capture.broken:press(capture.game, "a") +end, "no longer active", "rolled-back API retired") +failed.loader:_closeModInput() +failed.release() +-- Native hot reload closes the old service before building replacement mods. +local f = Fixture.new(".", "gen2", "red", 3, "unified") +f.ready() +a = f.primary.press("a") +f.game.data = T.fixtures.fresh() +rawset(f.game.data, "reloadGenerated", function() end) +local replacement = require("src.dev.HotReload").run(f.game, { fs = T.sdk.memfs({}), dev = false }) +T.eq(#replacement.errors, 0, "ROM-free hot reload succeeds") +T.eq(Input:isDown("a"), false, "hot reload releases old holds") +T.eq(f.primary.release(a), false, "hot reload retires old token") +T.raises(function() + f.primary.press("a") +end, "no longer active", "old service cannot press after reload") +f.close() +-- Native teardown retires all handles, including holds whose caller lost +-- its token/loader references. Outstanding sources must survive GC to cleanup. +Input:init() +Input:keypressed("z") +do + local orphan = Control.new():api("orphan") + orphan:press({ input = Input }, "b") +end +collectgarbage("collect") +Loader.teardown() +T.eq(Input:isDown("b"), false, "teardown releases orphaned mod hold after GC") +T.eq(Input:isDown("a"), true, "teardown preserves keyboard source") +Input:keyreleased("z") +Input:reset() +Version.set(savedVersion) +T.finish("mod.input lifecycle") diff --git a/tests/engine/portable_mod_input.lua b/tests/engine/portable_mod_input.lua new file mode 100644 index 000000000..2d1b7f3f4 --- /dev/null +++ b/tests/engine/portable_mod_input.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/mod_input/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/mod_input/cases.lua").run(f) +f.close() +f.T.finish("portable mod.input") diff --git a/tests/mod_input/cases.lua b/tests/mod_input/cases.lua new file mode 100644 index 000000000..ae4944eb0 --- /dev/null +++ b/tests/mod_input/cases.lua @@ -0,0 +1,130 @@ +local M = {} +function M.run(f) + local T, I, own, peer = f.T, f.Input, f.primary, f.peer + local buttons = { "up", "down", "left", "right", "a", "b", "start", "select" } + local rows = {} + local function state(label) + local held, edge = {}, {} + for _, button in ipairs(buttons) do + held[button], edge[button] = I:isDown(button), I:wasPressed(button) + end + rows[#rows + 1] = { label = label, held = held, edge = edge } + end + T.raises(function() + own.tap("a") + end, "mod.input needs the live game", "requires game.ready") + T.eq(#I.pressQueue, 0, "early call has no queued input") + f.ready() + for _, button in ipairs(buttons) do + I:reset() + T.eq(own.tap(button), nil, "tap has no success return") + T.eq(I:isDown(button), false, "tap has no held state before step") + T.eq(I:wasPressed(button), false, "tap edge waits for fixed step") + I:step() + T.eq(I:wasPressed(button), true, "tap produces one edge") + T.eq(I:isDown(button), false, "tap does not resurrect held state") + state(button .. "-tap") + I:step() + T.eq(I:wasPressed(button), false, "tap edge expires after one step") + local a, b = own.press(button), own.press(button) + T.eq(type(a), "table", "opaque press token") + T.neq(a, b, "independent tokens for same button") + T.eq(next(a), nil, "token contains no mutable input details") + I:step() + T.eq(I:wasPressed(button), true, "press queues native edge") + T.eq(I:isDown(button), true, "press holds immediately and across steps") + I:step() + T.eq(I:wasPressed(button), false, "holding does not repeat edges") + T.eq(I:isDown(button), true, "holding survives further steps") + T.eq(peer.release(a), false, "other mod cannot release token") + T.eq(own.release({}), false, "copied/forged token is refused") + T.eq(own.release(a), true, "own token released") + T.eq(I:isDown(button), true, "second token still holds") + T.eq(own.release(a), false, "release is idempotent") + T.eq(own.release(b), true, "second own token released") + T.eq(I:isDown(button), false, "last source releases held state") + state(button .. "-released") + end + I:reset() + own.tap("a") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "multiple taps before step coalesce into one edge") + I:step() + T.eq(I:wasPressed("a"), false, "coalesced taps do not spill into another step") + -- Physical sources and other mods keep independent ownership. + local a, b = own.press("b"), peer.press("b") + I:keypressed("x") + I:gamepadpressed(nil, "b") + I:overlayPressed("b") + I:step() + T.eq(own.release(a), true, "primary release") + T.eq(I:isDown("b"), true, "other sources keep button down") + T.eq(peer.release(b), true, "peer releases only its own source") + I:keyreleased("x") + T.eq(I:isDown("b"), true, "pad and overlay still hold") + I:gamepadreleased(nil, "b") + T.eq(I:isDown("b"), true, "overlay still holds") + I:overlayReleased("b") + T.eq(I:isDown("b"), false, "all physical sources released") + I:keypressed("z") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap coexists with physical hold") + T.eq(I:isDown("a"), true, "tap release preserves physical hold") + I:keyreleased("z") + -- Logical input ignores user keyboard remapping. + I:applyBindings({ a = { key = "v" } }) + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap uses logical action after rebind") + T.eq(I:isDown("a"), false, "rebound tap has no held state") + I:applyBindings(nil) + state("sources-and-rebind") + I:reset() + a = own.press("select") + T.eq(own.release(a), true, "release before first step") + I:step() + T.eq(I:wasPressed("select"), true, "press-release still has its queued edge") + T.eq(I:isDown("select"), false, "press-release does not become a hold") + I:step() + state("released-before-step") + for _, value in ipairs({ "", "A", "quit", "l", "r", "x", "y", false, 1, {}, function() end }) do + for _, method in ipairs({ "tap", "press" }) do + T.raises(function() + own[method](value) + end, "unknown GB button", "reject invalid logical button") + end + end + T.raises(function() + own.tap(nil) + end, "unknown GB button", "missing logical button") + T.eq(#I.pressQueue, 0, "validation does not queue input") + for _, value in ipairs({ "token", false, 1, {}, function() end }) do + T.eq(own.release(value), false, "malformed token declines") + end + T.eq(own.release(nil), false, "missing token declines") + -- Recovery invalidates only this loader's tokens and preserves keyboard. + a, b = own.press("a"), peer.press("a") + I:keypressed("z") + f.result.loader:releaseModInput("portable_mod_input") + T.eq(own.release(a), false, "owner recovery retires token") + T.eq(I:isDown("a"), true, "owner recovery preserves peer/keyboard") + T.eq(peer.release(b), true, "peer token still valid") + T.eq(I:isDown("a"), true, "keyboard remains after both mods release") + f.result.loader:releaseModInput() + T.eq(I:isDown("a"), true, "global mod recovery leaves keyboard alone") + I:keyreleased("z") + -- Recovery permits new presses, without reviving old tokens. + a = own.press("a") + f.result.loader:releaseModInput() + b = own.press("a") + T.eq(own.release(a), false, "stale token cannot release new hold") + T.eq(I:isDown("a"), true, "new hold is live") + T.eq(own.release(b), true, "replacement hold releases normally") + I:step() + I:step() + state("recovery") + return rows +end +return M diff --git a/tests/mod_input/fixture.lua b/tests/mod_input/fixture.lua new file mode 100644 index 000000000..9c4fe5b0f --- /dev/null +++ b/tests/mod_input/fixture.lua @@ -0,0 +1,60 @@ +local M = {} +function M.new(root, backend, version, api, format) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local Input = require("src.core.Input") + local files, paths = {}, {} + local Json = require("src.link.Json") + local function read(path) + local f = assert(io.open(root .. "/examples/portable_mod_input/" .. path, "rb")) + local bytes = f:read("*a") + f:close() + return bytes + end + local manifest = assert(Json.decode(read("manifest.json"))) + for _, id in ipairs({ "portable_mod_input", "portable_mod_input_peer" }) do + paths[#paths + 1] = "mods/" .. id + manifest.id, manifest.api, manifest.callback_format = id, api, format + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = + T.sdk.loadMods(paths, { generation = generation, data = data, fs = T.sdk.memfs(files), dev = false }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local game = { + input = Input, + mods = result.loader, + data = data, + save = { options = {} }, + stack = { + states = {}, + top = function() + return nil + end, + }, + } + result.loader.game = game + local f = { + T = T, + result = result, + Input = Input, + game = game, + primary = assert(result.loader.exports.portable_mod_input), + peer = assert(result.loader.exports.portable_mod_input_peer), + files = files, + paths = paths, + } + function f.ready() + result.loader.events:emit("game.ready", { game = game }) + end + function f.close() + result.loader:releaseModInput() + result.release() + end + return f +end +return M diff --git a/tests/mod_input/worker.lua b/tests/mod_input/worker.lua new file mode 100644 index 000000000..bbabd4b0a --- /dev/null +++ b/tests/mod_input/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/mod_input/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/mod_input/cases.lua").run(f) +f.close() +print("MOD_INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable mod.input contract") diff --git a/tools/mod-input-contract-check/README.md b/tools/mod-input-contract-check/README.md new file mode 100644 index 000000000..b6ac022a6 --- /dev/null +++ b/tools/mod-input-contract-check/README.md @@ -0,0 +1,19 @@ +# Cross-engine mod.input contract + +Run from Gen2Recomped: + +```sh +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/mod-input.json +``` + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. No source is patched. Each native +loader executes `examples/portable_mod_input/main.lua` unchanged for two +independent mods, on Red, Gold, FireRed and Emerald. The checker verifies +the eight logical buttons, fixed-step edges/holds, independent sources, +foreign/idempotent token release, rebinding, invalid arguments and recovery, +then compares held/edge journals across the engines. + +Use `--format unified` for unified API 2, `--api 3 --format unified` for API 3, +and `--gen2` to select an integration checkout. Unsupported manifests fail; +there is no silent downgrade. No ROM or native window is required. diff --git a/tools/mod-input-contract-check/check.py b/tools/mod-input-contract-check/check.py new file mode 100644 index 000000000..7981cdd22 --- /dev/null +++ b/tools/mod-input-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged logical input mod against both engines' native Input state.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/mod_input/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("MOD_INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("MOD_INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logical input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_mod_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 34d5c10ff5cf5c8c65170a339a0ba9504972bf40 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:16:08 +0200 Subject: [PATCH 15/86] Add portable core.update dispatch and native cross-engine contracts --- .github/workflows/core-update-contract.yml | 43 +++++ docs/portable-core-update.md | 84 +++++++++ examples/portable_core_update/README.md | 13 ++ examples/portable_core_update/main.lua | 18 ++ examples/portable_core_update/manifest.json | 10 ++ main.lua | 5 +- src/core/PlatformHooks.lua | 16 ++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/core_update/cases.lua | 180 ++++++++++++++++++++ tests/core_update/fixture.lua | 104 +++++++++++ tests/core_update/worker.lua | 7 + tests/engine/core_update.lua | 6 + tests/engine/core_update_routing.lua | 102 +++++++++++ tools/core-update-contract-check/README.md | 20 +++ tools/core-update-contract-check/check.py | 58 +++++++ 16 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/core-update-contract.yml create mode 100644 docs/portable-core-update.md create mode 100644 examples/portable_core_update/README.md create mode 100644 examples/portable_core_update/main.lua create mode 100644 examples/portable_core_update/manifest.json create mode 100644 src/core/PlatformHooks.lua create mode 100644 tests/core_update/cases.lua create mode 100644 tests/core_update/fixture.lua create mode 100644 tests/core_update/worker.lua create mode 100644 tests/engine/core_update.lua create mode 100644 tests/engine/core_update_routing.lua create mode 100644 tools/core-update-contract-check/README.md create mode 100644 tools/core-update-contract-check/check.py diff --git a/.github/workflows/core-update-contract.yml b/.github/workflows/core-update-contract.yml new file mode 100644 index 000000000..3f5fd0b26 --- /dev/null +++ b/.github/workflows/core-update-contract.yml @@ -0,0 +1,43 @@ +name: portable core.update contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: core-update-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check frame dispatch and native host routing + run: | + luajit tests/engine/core_update.lua + luajit tests/engine/core_update_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/core-update-contract + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/core-update-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/core-update-contract/api2-unified.json + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/core-update-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: core-update-contract + path: /tmp/core-update-contract + if-no-files-found: ignore diff --git a/docs/portable-core-update.md b/docs/portable-core-update.md new file mode 100644 index 000000000..74ed54262 --- /dev/null +++ b/docs/portable-core-update.md @@ -0,0 +1,84 @@ +# Portable interactive frame hook + +Gen2Recomped dispatches `core.update` at the same host boundary as Gen1Recomp: +`main.lua` calls `PlatformHooks.update(game, dt)` for each normal interactive +Game frame. Native API 1/2, unified API 2 and API 3 use the same signature: + +```lua +local mod = ... +local paused = false +mod.hooks:wrap("core.update", function(next, game, dt) + -- Poll/update the mod's own overlay even while Game simulation is paused. + if paused then return end + return next(game, dt) +end) +``` + +The arguments are the original opaque Game handle and frame delta. The hook +runs once at the interactive frame boundary, outside the fixed-step loop; +fast-forward does not multiply callback frequency. Vanilla calls +`game:update(dt)` and discards its return values, matching Gen1's helper. + +Omitting `next` pauses that frame's Game update. The pausing wrapper still +runs on subsequent frames, so its overlay can observe its own state and resume. Calling +`next()` forwards the current arguments; explicit `next(otherGame, otherDt)` +changes both downstream wrapper arguments and the actual native update target. +This differs from raw input hooks, whose native bodies retain the original +input event. Returned booleans have no independent consume meaning: whether +the callback calls `next` determines whether Game updates. + +The helper returns hook-chain results, including nil holes. LÖVE's interactive +host callback ignores those results, just as Gen1 does. Existing priority, +unsubscribe, owner cleanup and error rules apply. A wrapper that fails before +forwarding is skipped; a failure after forwarding does not replay the native +update. Native update errors propagate without being retried. Unified +callbacks retain their existing cached-continuation behavior; this feature +does not change the hook bus's rules for repeated explicit `next` calls. + +`mod:supports("hooks", "core.update")` reports availability before the mod entry +runs, and the unified callback catalog accepts the hook without record +conversion. The Game identity remains unchanged unless a wrapper deliberately +passes a replacement to `next`. + +## Host routing + +The hook pauses Game simulation only. Work outside Game:update, including +normal host housekeeping and drawing, retains its native routing. A boot +report, launcher/importer, editor or a frame with no Game does not dispatch +the hook. + +Autopilot and coroutine-driver runs keep their existing direct deterministic +`Game:update(1/60)` calls and bypass `core.update`, matching Gen1. Each scripted +observation still has its own step, including multiple iterations per rendered +frame. A failed or completed driver returns through its existing exit path. +Do not install an additional wrapper on Game:update to implement this seam; +that would also affect scripted loops and could double dispatch it. + +With no subscriber, the existing Runtime/bus fallback calls the native update. +Its default function is hoisted, and dispatch allocates no payload table or +per-frame continuation closure on that unsubscribed path. + +## Verification + +```sh +luajit tests/engine/core_update.lua +luajit tests/engine/core_update_routing.lua +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The reference is clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed mod entry chunk +runs through both engines' native loaders on Red, Gold, FireRed and Emerald. +Tests load the real main.lua and invoke the actual love.update callback, +including Gen1's profiler wrapper. Test-only upvalue injection supplies a +Game:update spy and host-owner fixtures without importing a cartridge; it does +not replace the native routing or PlatformHooks implementations. Unrelated +host housekeeping is isolated behind a counter. The checker compares frame +journals and rejects dirty or unexpected Gen1 sources. + +The Gen2 routing matrix covers every supported host version in API 1, +native/unified API 2 and API 3. These checks are ROM-free and headless; they +verify dispatch and lifecycle behavior rather than full-world simulation, +GPU rendering or native OS event delivery. diff --git a/examples/portable_core_update/README.md b/examples/portable_core_update/README.md new file mode 100644 index 000000000..766ec3342 --- /dev/null +++ b/examples/portable_core_update/README.md @@ -0,0 +1,13 @@ +# Portable interactive frame example + +Copy into `mods/`. This unchanged entry chunk works on Gen1Recomp and +Gen2Recomped. `exports.pause(true)` pauses Game updates while the mod continues +to observe interactive frames; `pause(false)` resumes. `exports.stop()` removes +the hook. The example starts unpaused and issues no synthetic input. + +The public API, poll count and latest Game/delta observation are exported for +the headless contract tests. It retains only the latest observation, without +allocating an unbounded frame journal. The API-2 manifest is also exercised as +unified API 2 and API 3 by the checker. See +[the frame contract](../../docs/portable-core-update.md), particularly the +separate deterministic-driver routing. diff --git a/examples/portable_core_update/main.lua b/examples/portable_core_update/main.lua new file mode 100644 index 000000000..74b6c9901 --- /dev/null +++ b/examples/portable_core_update/main.lua @@ -0,0 +1,18 @@ +-- Observe every interactive frame, including frames whose simulation is paused. +local mod = ... +local paused, polls, seen = false, 0, {} +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.pause(value) + paused = value +end +function mod.exports.polls() + return polls +end +mod.exports.stop = mod.hooks:wrap("core.update", function(next, game, dt) + polls = polls + 1 + seen.game, seen.dt = game, dt + if paused then + return "paused" + end + return next(game, dt) +end) diff --git a/examples/portable_core_update/manifest.json b/examples/portable_core_update/manifest.json new file mode 100644 index 000000000..4ebf27460 --- /dev/null +++ b/examples/portable_core_update/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_core_update", + "name": "Portable Frame Update", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/main.lua b/main.lua index 18325ffdf..df242c60c 100644 --- a/main.lua +++ b/main.lua @@ -241,6 +241,7 @@ local lastLogFlush = 0 -- unrelated push overflows it and ends the process with a traceback pointing at -- innocent code. See src/render/GraphicsStack.lua. local GraphicsStack = require("src.render.GraphicsStack") +local PlatformHooks = require("src.core.PlatformHooks") -- Declared HERE rather than further down beside its first mark: bootGame below -- calls BootTrace.booted(), and a `local` declared after a function's body is -- not in scope inside it -- the name resolved to the nil GLOBAL instead and @@ -688,7 +689,9 @@ function love.update(dt) -- unguarded dereference here is not a theoretical race -- it is -- "attempt to index upvalue 'Game' (a nil value)" a second after launch. if not Game then return end - Game:update(dt) + -- Only the interactive game frame is a core.update seam, as in Gen1. + -- Scripted act+step loops above retain their deterministic direct updates. + PlatformHooks.update(Game, dt) end -- THE LAUNCHER'S OWN BOTTOM SCREEN. diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 000000000..42a9018b3 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,16 @@ +-- Shared frame boundary for mods that poll host state or pause simulation. +-- Match Gen1Recomp's main.lua -> PlatformHooks.update contract. +local Runtime = require("src.mods.Runtime") +local PlatformHooks = {} + +-- Hoisted: forwarding needs no per-frame closure or payload allocation. +-- Native Game:update results are deliberately discarded, as in Gen1. +local function vanillaUpdate(game, dt) + game:update(dt) +end + +function PlatformHooks.update(game, dt) + return Runtime.call("core.update", vanillaUpdate, game, dt) +end + +return PlatformHooks diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index cdadbbde8..7c7a344f5 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- 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 + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5f54085eb..02b5d214e 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- 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 + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel 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 diff --git a/tests/core_update/cases.lua b/tests/core_update/cases.lua new file mode 100644 index 000000000..2630ed60e --- /dev/null +++ b/tests/core_update/cases.lua @@ -0,0 +1,180 @@ +local M = {} +local function pack(...) + return { n = select("#", ...), ... } +end +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function state(label) + rows[#rows + 1] = { label = label, updates = g.calls, polls = e.polls(), hostPolls = f.hostPolls() } + end + for _, dt in ipairs({ 0, 1 / 60, 0.25, 2.5 }) do + local n, polls = g.calls, e.polls() + f.update(dt) + T.eq(g.calls, n + 1, "interactive frame forwards exactly one native update") + T.eq(e.polls(), polls + 1, "interactive frame invokes wrapper once") + T.eq(e.seen.game, g, "original Game identity") + T.eq(e.seen.dt, dt, "unmodified frame delta") + T.eq(g.dts[#g.dts], dt, "native update receives frame delta") + end + state("forwarded") + e.pause(true) + local n, polls, host = g.calls, e.polls(), f.hostPolls() + f.update(1 / 60) + f.update(1 / 30) + T.eq(g.calls, n, "pause suppresses Game:update") + T.eq(e.polls(), polls + 2, "mod continues polling while simulation paused") + T.eq(f.hostPolls(), host + 2, "host housekeeping continues while paused") + state("paused") + e.pause(false) + f.update(1 / 60) + T.eq(g.calls, n + 1, "resume forwards exactly one update") + state("resumed") + if api.supports then + T.eq(api:supports("hooks", "core.update"), true, "native capability advertised") + end + -- next() forwards current args; explicit args route to another Game/delta. + local alternate = { calls = 0 } + function alternate:update(dt) + self.calls = self.calls + 1 + self.dt = dt + end + local order = {} + local outer = api.hooks:wrap("core.update", function(next, game, dt) + T.eq(game, g, "outer sees original Game") + order[#order + 1] = "outer" + next(alternate, dt / 2) + order[#order + 1] = "after" + return nil, "wrapped", nil + end, 20) + local inner = api.hooks:wrap("core.update", function(next, game, dt) + order[#order + 1] = "inner" + T.eq(game, alternate, "rewritten Game reaches downstream wrapper") + T.eq(dt, 0.125, "rewritten delta reaches downstream wrapper") + return next() + end, 10) + n = g.calls + T.same(pack(f.bridge.update(g, 0.25)), { n = 3, [2] = "wrapped" }, "wrapper nil-hole results preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and postprocessing") + T.eq(alternate.calls, 1, "native fallback uses replacement Game") + T.eq(alternate.dt, 0.125, "native fallback uses replacement delta") + T.eq(g.calls, n, "original Game not updated after explicit replacement") + outer() + inner() + T.same(pack(f.bridge.update(g, 1 / 60)), { n = 0 }, "native Game:update results deliberately discarded") + for _, after in ipairs({ false, true }) do + local off = api.hooks:wrap("core.update", function(next) + if after then + next() + end + error("intentional core.update wrapper failure") + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "wrapper error preserves one native update") + off() + end + local original = g.update + local attempts = 0 + g.update = function() + attempts = attempts + 1 + error("native update failure") + end + T.raises(function() + f.update(1 / 60) + end, "native update failure", "native failure escapes") + T.eq(attempts, 1, "native failure is not retried") + g.update = original + if f.api >= 3 then + local off = api.hooks:wrap("core.update", function(next) + next() + return next() + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "unified API-3 continuation caches downstream result") + off() + end + -- Real love.update routing: non-game owners bypass the seam. + for _, mode in ipairs({ "Importer", "TouchEditor", "editorMode", "noGame" }) do + local owner = { calls = 0 } + if mode == "Importer" then + function owner:update() + self.calls = self.calls + 1 + end + f.set("Importer", owner) + elseif mode == "TouchEditor" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("TouchEditor", owner) + elseif mode == "editorMode" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("EditorApp", owner) + f.set("editorMode", true) + else + f.set("Game", nil) + end + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, mode .. " does not fire mod frame hook") + T.eq(g.calls, n, mode .. " does not update game") + if mode ~= "noGame" then + T.eq(owner.calls, 1, mode .. " native owner still updates") + end + if mode == "editorMode" then + f.set("editorMode", false) + f.set("EditorApp", nil) + elseif mode == "noGame" then + f.set("Game", g) + else + f.set(mode, nil) + end + end + state("non-game-routing") + -- Scripted act+step loops remain deterministic and bypass core.update. + f.set("speedOverride", 2) + local acts = 0 + f.set("autopilot", { + update = function() + acts = acts + 1 + end, + }) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "autopilot observes each deterministic step") + T.eq(g.calls, n + 2, "autopilot runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "autopilot retains fixed delta") + T.eq(e.polls(), polls, "autopilot bypasses frame hook") + f.set("autopilot", nil) + acts = 0 + f.set( + "driverCo", + coroutine.create(function() + while true do + acts = acts + 1 + coroutine.yield() + end + end) + ) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "driver observes each deterministic step") + T.eq(g.calls, n + 2, "driver runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "driver retains fixed delta") + T.eq(e.polls(), polls, "driver bypasses frame hook") + f.set("driverCo", nil) + f.set("speedOverride", nil) + state("scripted-routing") + e.stop() + e.stop() + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, "unsubscribe is idempotent") + T.eq(g.calls, n + 1, "empty chain still forwards native update") + state("unsubscribed") + return rows +end +return M diff --git a/tests/core_update/fixture.lua b/tests/core_update/fixture.lua new file mode 100644 index 000000000..1a0d63c9f --- /dev/null +++ b/tests/core_update/fixture.lua @@ -0,0 +1,104 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + love.event = love.event or {} + local Version = require("src.core.GameVersion") + Version.set(version) + -- Load the real host callbacks without booting/importing a cartridge. + dofile("main.lua") + local entryUpdate = love.update + local function upvalue(fn, name) + for i = 1, 100 do + local n, value = debug.getupvalue(fn, i) + if not n then + break + end + if n == name then + return i, value + end + end + end + -- Gen1 decorates love.update with its native frame profiler. + local _, original = upvalue(entryUpdate, "gameUpdate") + local update = original or entryUpdate + local function set(name, value) + local owner, index = update, upvalue(update, name) + if not index then + owner, index = entryUpdate, upvalue(entryUpdate, name) + end + if not index then + local _, iterations = upvalue(update, "scriptedIterations") + if iterations then + owner, index = iterations, upvalue(iterations, name) + end + end + assert(index, "missing native update upvalue: " .. name) + debug.setupvalue(owner, index, value) + end + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_core_update/" .. path, "rb")) + files["mods/portable_core_update/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_core_update/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_core_update/manifest.json"] = Json.encode(manifest) + local generation = Version.generation() + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_core_update" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { calls = 0, dts = {}, mods = result.loader, data = data } + function game:update(dt) + self.calls = self.calls + 1 + self.dts[#self.dts + 1] = dt + return "ignored native result", nil, "tail" + end + set("Game", game) + set("speedOverride", nil) + local hostPolls = 0 + if backend == "gen1" then + require("src.core.HostDisplay").setBackend({ + update = function() + hostPolls = hostPolls + 1 + end, + }) + else + -- Isolate the unrelated watts service; retain love.update's real routing. + set("walkerTick", function() + hostPolls = hostPolls + 1 + end) + end + local exports = result.loader.exports.portable_core_update + return { + T = T, + result = result, + game = game, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + set = set, + update = entryUpdate, + bridge = require("src.core.PlatformHooks"), + hostPolls = function() + return hostPolls + end, + close = function() + if backend == "gen1" then + require("src.core.HostDisplay").setBackend(nil) + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/core_update/worker.lua b/tests/core_update/worker.lua new file mode 100644 index 000000000..eb7714361 --- /dev/null +++ b/tests/core_update/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/core_update/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/core_update/cases.lua").run(f) +f.close() +print("CORE_UPDATE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.update contract") diff --git a/tests/engine/core_update.lua b/tests/engine/core_update.lua new file mode 100644 index 000000000..fcac5b9a9 --- /dev/null +++ b/tests/engine/core_update.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/core_update/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/core_update/cases.lua").run(f) +f.close() +f.T.finish("portable core.update") diff --git a/tests/engine/core_update_routing.lua b/tests/engine/core_update_routing.lua new file mode 100644 index 000000000..515f520d5 --- /dev/null +++ b/tests/engine/core_update_routing.lua @@ -0,0 +1,102 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/core_update/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local savedVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.update"), true, label .. "native hook capability") + f.update(0.125) + T.eq(g.calls, 1, label .. "native update runs once") + T.eq(e.polls(), 1, label .. "hook runs once") + T.eq(e.seen.game, g, label .. "Game stays opaque under every callback format") + T.eq(e.seen.dt, 0.125, label .. "delta stays scalar") + e.pause(true) + T.eq(f.bridge.update(g, 0.5), "paused", label .. "hook result returned by bridge") + T.eq(g.calls, 1, label .. "bridge respects pause") + e.pause(false) + -- Boot reports own their frame before any Game/mod processing. + f.set("bootReport", {}) + local polls, n, host = e.polls(), g.calls, f.hostPolls() + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "boot report bypasses hook") + T.eq(g.calls, n, label .. "boot report bypasses game") + T.eq(f.hostPolls(), host, label .. "boot report retains native housekeeping boundary") + f.set("bootReport", nil) + -- Driver failure/completion must not accidentally fall into the hook. + local quit, oldQuit = {}, love.event.quit + love.event.quit = function(code) + quit[#quit + 1] = code or "normal" + end + f.set( + "driverCo", + coroutine.create(function() + error("intentional core.update driver failure") + end) + ) + f.update(1 / 60) + T.eq(quit[1], 1, label .. "driver error requests failure exit") + T.eq(e.polls(), polls, label .. "failed driver bypasses hook") + T.eq(g.calls, n, label .. "failed driver does not update game") + f.set("driverCo", coroutine.create(function() end)) + f.update(1 / 60) + T.eq(quit[2], "normal", label .. "completed driver requests native exit") + T.eq(e.polls(), polls, label .. "completed driver bypasses hook") + T.eq(g.calls, n, label .. "completed driver does not update game") + f.set("driverCo", nil) + love.event.quit = oldQuit + -- Owner cleanup removes only that contributor's frame wrapper. + local peerCalls = 0 + local off = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 5, "other_owner") + f.result.loader.hooks:removeOwner("portable_core_update") + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "owner removal retires mod wrapper") + T.eq(peerCalls, 1, label .. "owner removal preserves another wrapper") + T.eq(g.calls, n + 1, label .. "owner removal preserves native update") + off() + f.close() + end +end +-- With no mods, the actual love.update and direct bridge keep native behavior. +local bare = Fixture.new(".", "gen2", "red", 2, "native", true) +T.eq(Runtime.wantsHook("core.update"), false, "no-mod Runtime has no frame subscriber") +for _, dt in ipairs({ 0, 0.125, 2 }) do + bare.update(dt) +end +T.eq(bare.game.calls, 3, "no-mod interactive update runs once per call") +T.same(bare.game.dts, { 0, 0.125, 2 }, "no-mod delta forwarding unchanged") +T.eq(select("#", bare.bridge.update(bare.game, 0)), 0, "native return values discarded without subscribers") +bare.close() +-- A failed entry's registered update cannot survive its public-loader rollback. +local files = { + ["mods/broken_frame/manifest.json"] = [[{"id":"broken_frame","name":"Broken Frame", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_frame/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.update", function() error("rolled-back wrapper survived") end) + error("intentional frame entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_frame" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed frame entry is reported") +T.eq(Runtime.wantsHook("core.update"), false, "rollback removes registered frame wrapper") +local count = 0 +require("src.core.PlatformHooks").update({ + update = function() + count = count + 1 + end, +}, 1 / 60) +T.eq(count, 1, "rollback preserves native frame fallback") +Loader.teardown() +T.eq(Runtime.wantsHook("core.update"), false, "teardown leaves no frame wrapper") +failed.release() +Version.set(savedVersion) +T.finish("core.update routing") diff --git a/tools/core-update-contract-check/README.md b/tools/core-update-contract-check/README.md new file mode 100644 index 000000000..4dc330350 --- /dev/null +++ b/tools/core-update-contract-check/README.md @@ -0,0 +1,20 @@ +# Cross-engine core.update contract + +```sh +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/core-update.json +``` + +Gen1 must be clean at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native +loaders execute `examples/portable_core_update/main.lua` unchanged. The tests +invoke each engine's actual love.update callback loaded from main.lua, with a +Game:update spy and fixtures for non-game owners supplied through test-only +upvalue injection. Neither native main.lua nor PlatformHooks is replaced. +Gen2's unrelated walker bookkeeping and Gen1's HostDisplay backend use a +counter so the test does not depend on external services. + +The checker covers Red, Gold, FireRed and Emerald, asserts forwarding, pause, +priority, argument replacement, errors, unsubscribe and host/driver routing, +then compares frame journals. `--format unified` selects unified API 2; +`--api 3 --format unified` selects API 3. `--gen2` selects an integration +checkout. No ROM, native window or renderer is needed, and no Gen1 source is +written. diff --git a/tools/core-update-contract-check/check.py b/tools/core-update-contract-check/check.py new file mode 100644 index 000000000..dec9d2568 --- /dev/null +++ b/tools/core-update-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged frame-update mod against both engines' native love.update routes.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/core_update/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("CORE_UPDATE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("CORE_UPDATE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"frame routing mismatch: {version}" + print(f"PASS {version}: matching native frame routing across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_core_update/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 7801f9d715c8c1f9910a7f982ef89a6396e4137e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:19 +0200 Subject: [PATCH 16/86] Add portable core.logic_speed dispatch and cross-engine contracts --- .github/workflows/logic-speed-contract.yml | 46 +++++ docs/modding.md | 9 +- docs/portable-logic-speed.md | 92 +++++++++ examples/portable_logic_speed/main.lua | 17 ++ examples/portable_logic_speed/manifest.json | 10 + src/core/FixedStep.lua | 6 + src/core/Game.lua | 61 ++++-- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/logic_speed.lua | 6 + tests/engine/logic_speed_clock.lua | 161 ++++++++++++++++ tests/engine/logic_speed_routing.lua | 76 ++++++++ tests/engine/mod_supports.lua | 5 +- tests/logic_speed/cases.lua | 196 ++++++++++++++++++++ tests/logic_speed/fixture.lua | 95 ++++++++++ tests/logic_speed/worker.lua | 11 ++ tools/logic-speed-contract-check/check.py | 58 ++++++ 17 files changed, 832 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/logic-speed-contract.yml create mode 100644 docs/portable-logic-speed.md create mode 100644 examples/portable_logic_speed/main.lua create mode 100644 examples/portable_logic_speed/manifest.json create mode 100644 tests/engine/logic_speed.lua create mode 100644 tests/engine/logic_speed_clock.lua create mode 100644 tests/engine/logic_speed_routing.lua create mode 100644 tests/logic_speed/cases.lua create mode 100644 tests/logic_speed/fixture.lua create mode 100644 tests/logic_speed/worker.lua create mode 100644 tools/logic-speed-contract-check/check.py diff --git a/.github/workflows/logic-speed-contract.yml b/.github/workflows/logic-speed-contract.yml new file mode 100644 index 000000000..502f0fc04 --- /dev/null +++ b/.github/workflows/logic-speed-contract.yml @@ -0,0 +1,46 @@ +name: portable core.logic_speed contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: logic-speed-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native speed dispatch, ownership and clocks + run: | + luajit tests/engine/logic_speed.lua + luajit tests/engine/logic_speed_routing.lua + luajit tests/engine/logic_speed_clock.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/logic-speed-contract + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/logic-speed-contract/api1-native.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/logic-speed-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/logic-speed-contract/api2-unified.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/logic-speed-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: logic-speed-contract + path: /tmp/logic-speed-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index a48460651..2cc59fa3f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -59,8 +59,7 @@ them. Availability does not imply the current game state permits the action: 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 +than Gen1Recomp's newer catalog. For example, `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. @@ -77,6 +76,12 @@ 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). +## Logic speed hook + +The [portable logic speed contract](portable-logic-speed.md) documents +`core.logic_speed(next, game)`, its native speed resolution, hard locks and +explicit-override precedence for native API 1/2, unified API 2 and API 3. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/docs/portable-logic-speed.md b/docs/portable-logic-speed.md new file mode 100644 index 000000000..03f8502b1 --- /dev/null +++ b/docs/portable-logic-speed.md @@ -0,0 +1,92 @@ +# Portable logic speed hook + +`core.logic_speed(next, game)` wraps native speed resolution on Gen2Recomped, +matching Gen1Recomp. Native API 1/2, unified API 2 and API 3 all receive the +original opaque Game handle, with no record/ID conversion or extra arguments. +`mod:supports("hooks", "core.logic_speed")` reports the native producer on every +Gen2Recomped host, including before a Game/save exists. + +```lua +local mod = ... +local fastForward = false +mod.hooks:wrap("core.logic_speed", function(next, game) + if fastForward then return 20 end + return next(game) +end) +``` + +A call without `next` supplies the multiplier. `next()` retains the current +Game; `next(otherGame)` forwards that replacement to downstream wrappers and +the native `_resolveLogicSpeed` method. The native resolver reads live options. +These are engine handles: a replacement must implement that native method. +`next` resolves options; it does not re-enter the replacement's `logicSpeed` +policy checks. The original caller's hard locks and launch override have +already been checked before the chain runs. + +The precedence is: + +1. A link session or open link transport forces literal 1X and skips the hook. +2. Any stacked state with `isFixedSpeed` or `isMinigame` forces literal 1X and + skips the hook, including when a menu covers that state. These markers are + opt-in metadata, not a claim that every existing minigame declares them. +3. An explicit `--speed` / `POKEPORT_SPEED` override skips the hook and uses the + existing native clamp. +4. The hook chain wraps the saved-option resolver. Without a subscriber, the + resolver runs directly without a per-call closure or payload allocation. + +Gen2Recomped keeps its global `save.options.speed` and existing ladder +`1, 2, 4, 10, 20, 30, 50, 75, 100, 200`. Gen1 has a 3X rung and may use +category-specific options or cartridge ladders. A portable mod should choose +common supported levels or accept the host's nearest native level. This hook +does not migrate settings or promise identical ladders. + +The first hook result is clamped at the outer producer boundary. Numeric +strings are accepted; invalid/nil/false/table, NaN and infinite results become +1X; finite values choose the nearest native level. Hooks cannot introduce a +zero/negative timestep or poison the accumulator. Existing priority, +unsubscribe, owner cleanup and error rules apply: failures before forwarding +fall through; failures after forwarding preserve the downstream result without +replaying resolution. Native resolver failures propagate without retry. +Unified continuations retain their existing caching rules. + +The hook runs per unlocked `Game:logicSpeed()` call. Normal `Game:update` +samples once per frame, outside the fixed-step loop. Native title/intro code +may also query the multiplier; this is not a once-per-frame notification. +Only simulation time is multiplied. Audio, camera and presentation updates +retain their real-time delta. If a hard lock starts during an accelerated +step, the installed fixed-step callback drops the remaining frame budget; +it does not arm the separate hitch-suppression path or slow the next frame. + +Gen1's GBA boot/cinematic restrictions remain native to that host (and explicit +overrides keep their existing boot precedence). Gen2's own title/intro code +retains its timing. This feature does not transplant Gen1-only boot states. + +## Verification + +```sh +luajit tests/engine/logic_speed.lua +luajit tests/engine/logic_speed_routing.lua +luajit tests/engine/logic_speed_clock.lua +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker requires clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and rejects a dirty/unexpected +reference. The same committed mod source runs through both native loaders on +Red, Gold, FireRed and Emerald, calling the real Game/Game2/Game3 speed and +lock methods on ROM-free owner fixtures. Journals compare the common contract; +separate assertions preserve native ladder and Gen1 GBA boot differences. +Gen1 does not opt into unified callbacks for API 2; that run verifies the same +consumer against native Gen1 API 2 and unified Gen2 API 2. API 3 is unified +on both engines. + +The Gen2 routing matrix covers all supported versions in native API 1/2, +unified API 2 and API 3, plus rollback, owner cleanup and no-mod behavior. +The clock suite captures the real callback installed by `Game:load` (isolating +unrelated import/render/save setup), then runs real `Game:update` and +`FixedStep` to check acceleration, mid-frame lock transitions and real-time +audio/presentation. These tests require no ROM or GPU and do not claim a +full imported-game playthrough or live network session. diff --git a/examples/portable_logic_speed/main.lua b/examples/portable_logic_speed/main.lua new file mode 100644 index 000000000..ed0e63757 --- /dev/null +++ b/examples/portable_logic_speed/main.lua @@ -0,0 +1,17 @@ +-- A speed overlay can choose a multiplier or restore the host's saved option. +-- Link/fixed-speed locks and explicit launch overrides remain the host's choice. +local mod = ... +local override, seen = nil, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.setSpeed(value) + override = value +end +mod.exports.stop = mod.hooks:wrap("core.logic_speed", function(next, game, ...) + seen.calls = seen.calls + 1 + seen.game, seen.extra = game, select("#", ...) + if override ~= nil then + return override + end + seen.vanilla = next(game) + return seen.vanilla +end) diff --git a/examples/portable_logic_speed/manifest.json b/examples/portable_logic_speed/manifest.json new file mode 100644 index 000000000..93d141c6f --- /dev/null +++ b/examples/portable_logic_speed/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_logic_speed", + "name": "Portable Logic Speed", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a5..cc283db20 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -50,6 +50,12 @@ function FixedStep:update(dt) end end +-- End only the current accelerated frame. Unlike discardCatchup, this must +-- not suppress the next real-time frame after a speed lock becomes active. +function FixedStep:endFrame() + self.accum = 0 +end + -- Drop any pending catch-up steps and arm the one-frame clamp above. A -- hitch inside one logic step (map seam setMap / song start) makes the -- next real-time dt huge; without this the while-loop above would advance diff --git a/src/core/Game.lua b/src/core/Game.lua index 54c8ed21c..0c9fb1506 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -17,6 +17,11 @@ local Screens = require("src.ui.Screens") local Game = {} +-- The default must follow an explicit next(otherGame), not capture the caller. +local function resolveLogicSpeedVanilla(game) + return game:_resolveLogicSpeed() +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -107,7 +112,10 @@ function Game:load() -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) - FixedStep:init(function(step) self:step(step) end) + FixedStep:init(function(step) + self:step(step) + self:_speedLockEdge() + end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") @@ -377,24 +385,46 @@ function Game:_step(dt) -- it on its own real-time 60Hz accumulator instead. end --- The logic multiplier for this frame. Read live rather than cached so the --- 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:logicSpeed() +-- Keep the native global option; the portable hook wraps resolution, not +-- engine-specific category settings or the speed ladder. +function Game:_resolveLogicSpeed() local GameSpeed = require("src.core.GameSpeed") - -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- 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. + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) +end + +function Game:speedLocked() if self.linkSession or (self.linkNet and not self.linkNet.closed) then - return 1 + return true, "link" + end + -- A menu on top must not hide a fixed-speed state underneath it. + 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 + +-- A link/fixed-speed state may start inside an accelerated logic step. Drop +-- the remaining budget now instead of running that state at the old speed. +function Game:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + +-- Read live on every resolution. Hard locks win over --speed/POKEPORT_SPEED, +-- and those explicit overrides win over mods. Clamp the final hook result as +-- well as the native default so invalid output cannot poison the accumulator. +function Game:logicSpeed() + if self:speedLocked() then return 1 end + local GameSpeed = require("src.core.GameSpeed") if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + if not ModRuntime.wantsHook("core.logic_speed") then return self:_resolveLogicSpeed() end + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", resolveLogicSpeedVanilla, self)) end -- THE FREE CAMERA, when one owns the frame -- otherwise nil. @@ -597,6 +627,7 @@ function Game:update(dt) -- or the anti-spiral clamp quietly caps every level above ~15X. self:updateCameraStick(dt) local speed = self:logicSpeed() + self._frameSpeed = speed FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) do local close = FrameProfile.section(" update: fixed steps") diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 7c7a344f5..16bfd31bc 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- 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 core.update + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 02b5d214e..6f0d48233 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- 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 core.update + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel 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 diff --git a/tests/engine/logic_speed.lua b/tests/engine/logic_speed.lua new file mode 100644 index 000000000..cee2e37f0 --- /dev/null +++ b/tests/engine/logic_speed.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/logic_speed/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/logic_speed/cases.lua").run(f) +f.close() +f.T.finish("portable core.logic_speed") diff --git a/tests/engine/logic_speed_clock.lua b/tests/engine/logic_speed_clock.lua new file mode 100644 index 000000000..1ed5faff4 --- /dev/null +++ b/tests/engine/logic_speed_clock.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Game = require("src.core.Game") +local FixedStep = require("src.core.FixedStep") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local noop = function() end +local g = setmetatable({ + bootConfig = noop, + adoptSave = noop, + applyOptions = noop, + updateCameraStick = noop, +}, { __index = Game }) +local savedModules, savedUpvalues = {}, {} +local function module(name, value) + savedModules[name] = { package.loaded[name] } + package.loaded[name] = value +end +local function upvalue(name, value) + for i = 1, 100 do + local key, original = debug.getupvalue(Game.load, i) + if not key then + break + end + if key == name then + savedUpvalues[i] = original + debug.setupvalue(Game.load, i, value) + return + end + end + error("missing load upvalue: " .. name) +end +-- Stop real Game:load immediately after it installs its real FixedStep +-- callback. Unrelated ROM loading, rendering and save services are isolated. +upvalue("Data", { load = noop, applyMapEditorOverlay = noop, publishGen2Palettes = noop }) +upvalue("ModLoader", { + new = function() + return { load = noop, status = noop } + end, +}) +for _, name in ipairs({ "Input", "TouchControls", "Renderer" }) do + upvalue(name, { init = noop }) +end +upvalue("StateStack", { init = noop }) +upvalue("SaveData", { + loadOptions = noop, + newGame = function() + return { options = { speed = 4 } } + end, +}) +module("src.render.Pipelines", { install = noop }) +module("src.translation.Service", { install = noop }) +for _, name in ipairs({ "src.render.Font", "src.ui.Theme", "src.core.Strings" }) do + module(name, { load = noop }) +end +local init = FixedStep.init +local stop = {} +FixedStep.init = function(self, callback) + init(self, callback) + error(stop) +end +local ok, err = pcall(Game.load, g) +FixedStep.init = init +for index, value in pairs(savedUpvalues) do + debug.setupvalue(Game.load, index, value) +end +T.eq(ok, false, "test stops at the intended boot boundary") +T.eq(err, stop, "real Game:load reached FixedStep installation") +T.eq(type(FixedStep.callback), "function", "native step callback captured") + +local logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 +module("src.pokemon.Gen4PoketchState", { tick = noop }) +module("src.core.Music", { + update = function() + audio = audio + 1 + end, +}) +module("src.render.Tilt", { + update = function(dt) + tilt = tilt + dt + end, +}) +package.loaded["src.render.Pipelines"] = { + update = function(dt) + pipeline = pipeline + dt + end, +} +module("src.core.DiscordPresence", { update = noop }) +g.updateCameraStick = function(_, dt) + camera = camera + dt +end +local hooks, savedHooks = Hooks.new(), Runtime.hooks +Runtime.hooks = hooks +local polls, value = 0, 4 +local off = hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "native update passes its Game to speed hook") + polls = polls + 1 + return value +end) +g.stack = { states = {} } +local lockAt, lockKind +function g:step(dt) + logic = logic + 1 + T.eq(dt, FixedStep.STEP, "simulation retains fixed delta") + if logic == lockAt then + if lockKind == "link" then + self.linkSession = true + else + self.stack.states = { { isMinigame = true }, { isMenu = true } } + end + end +end +local function reset() + logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 + FixedStep.accum, FixedStep.suppressCatchup = 0, false + g.audioAccum, g.linkSession, g.stack.states = 0, nil, {} +end +for _, speed in ipairs({ 1, 4, 20, 100, 200 }) do + reset() + value = speed + local before = polls + g:update(FixedStep.STEP) + -- Floating point subtraction can leave the final step just below STEP. + T.check(logic >= speed - 1 and logic <= speed, "speed controls native logic budget") + T.eq(polls, before + 1, "one speed dispatch per Game:update, not per simulation step") + T.eq(audio, 1, "audio stays at real-time 60Hz") + T.eq(tilt, FixedStep.STEP, "tilt uses real delta") + T.eq(pipeline, FixedStep.STEP, "render pipelines use real delta") + T.eq(camera, FixedStep.STEP, "camera uses real delta") + T.eq(g._frameSpeed, speed, "native update records frame speed for lock edges") +end +for _, kind in ipairs({ "link", "fixed" }) do + reset() + value, lockAt, lockKind = 200, 3, kind + local before = polls + g:update(FixedStep.STEP) + T.eq(logic, 3, kind .. " starts mid-frame and cancels remaining accelerated ticks") + T.eq(FixedStep.accum, 0, kind .. " drains only the current budget") + T.eq(FixedStep.suppressCatchup, false, kind .. " does not arm hitch suppression") + T.eq(audio, 1, kind .. " leaves audio real-time") + g:update(FixedStep.STEP) + T.eq(logic, 4, kind .. " next frame runs at literal 1X") + T.eq(polls, before + 1, kind .. " locked next frame bypasses hook") + g.linkSession, g.stack.states = nil, {} + lockAt = nil + value = 4 + g:update(FixedStep.STEP) + T.check(logic >= 7 and logic <= 8, kind .. " unlock restores accelerated simulation") +end +reset() +value = 0 / 0 +g:update(FixedStep.STEP) +T.eq(logic, 1, "invalid mod speed cannot poison simulation clock") +T.eq(g._frameSpeed, 1, "invalid mod speed normalized before accumulation") +T.eq(audio, 1, "invalid mod speed cannot poison audio clock") +off() +Runtime.hooks = savedHooks +for name, original in pairs(savedModules) do + package.loaded[name] = original[1] +end +T.finish("core.logic_speed native clocks") diff --git a/tests/engine/logic_speed_routing.lua b/tests/engine/logic_speed_routing.lua new file mode 100644 index 000000000..c73cd17a3 --- /dev/null +++ b/tests/engine/logic_speed_routing.lua @@ -0,0 +1,76 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/logic_speed/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.logic_speed"), true, label .. "native capability") + T.eq(g:logicSpeed(), 4, label .. "native option through hook") + T.eq(e.seen.game, g, label .. "opaque owner identity") + T.eq(e.seen.extra, 0, label .. "scalar signature") + T.eq(e.seen.calls, 1, label .. "one dispatch") + e.setSpeed(20) + T.eq(g:logicSpeed(), 20, label .. "override") + local calls = e.seen.calls + g.linkNet = { closed = false } + g.speedOverride = 100 + T.eq(g:logicSpeed(), 1, label .. "open link beats override") + T.eq(e.seen.calls, calls, label .. "link bypasses hook") + g.linkNet.closed = true + T.eq(g:logicSpeed(), 100, label .. "closed link restores explicit override") + T.eq(e.seen.calls, calls, label .. "explicit override bypasses hook") + g.speedOverride = nil + T.eq(g:logicSpeed(), 20, label .. "closed link permits hook") + for _, marker in ipairs({ "isFixedSpeed", "isMinigame" }) do + g.stack.states = { { [marker] = true }, { isMenu = true } } + calls = e.seen.calls + T.eq(g:logicSpeed(), 1, label .. marker .. " stays locked under a menu") + T.eq(select(2, g:speedLocked()), "minigame", label .. "lock reason") + T.eq(e.seen.calls, calls, label .. "fixed-speed bypasses hook") + end + g.stack = nil + T.eq(g:logicSpeed(), 20, label .. "missing stack is unlocked") + -- Loader ownership removes only the affected mod's contribution. + local peers = 0 + local off = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + calls = e.seen.calls + T.eq(g:logicSpeed(), 4, label .. "owner cleanup restores native option") + T.eq(e.seen.calls, calls, label .. "owner cleanup retires consumer") + T.eq(peers, 1, label .. "other owner still runs") + off() + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +T.eq(Runtime.wantsHook("core.logic_speed"), false, "no-mod fast path") +T.eq(bare.game:logicSpeed(), 4, "no-mod native option") +bare.game.save = nil +T.eq(bare.game:logicSpeed(), 1, "no save uses native default") +bare.close() +local files = { + ["mods/broken_speed/manifest.json"] = [[{"id":"broken_speed","name":"Broken Speed", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_speed/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.logic_speed", function() return 200 end) + error("intentional speed entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_speed" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("core.logic_speed"), false, "entry rollback removes speed hook") +Loader.teardown() +T.eq(Runtime.wantsHook("core.logic_speed"), false, "teardown removes speed hook") +failed.release() +Version.set(originalVersion) +T.finish("core.logic_speed routing") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 5d4562cd8..7ad2d3214 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -75,8 +75,9 @@ for _, version in ipairs(GameVersion.ORDER) do T.check(registries, version .. ': all API-2 registration surfaces available') 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'), + T.check(yes(api, 'hooks', 'core.logic_speed'), + version .. ': native speed hook is advertised') + T.check(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') diff --git a/tests/logic_speed/cases.lua b/tests/logic_speed/cases.lua new file mode 100644 index 000000000..0ed362199 --- /dev/null +++ b/tests/logic_speed/cases.lua @@ -0,0 +1,196 @@ +local M = {} +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function observe(label, expected) + local value = g:logicSpeed() + T.eq(value, expected, label) + rows[#rows + 1] = { label = label, speed = value } + end + if api.supports then + T.eq(api:supports("hooks", "core.logic_speed"), true, "native speed capability") + end + for _ = 1, 3 do + observe("native saved option", 4) + end + T.eq(e.seen.calls, 3, "one hook call per unlocked resolution") + T.eq(g.resolves, 3, "one native resolver per forwarded resolution") + T.eq(e.seen.game, g, "original opaque Game identity") + T.eq(e.seen.extra, 0, "no invented arguments") + T.eq(e.seen.vanilla, 4, "next returns native resolution") + for key in pairs(g.save.options) do + g.save.options[key] = 10 + end + observe("live saved option", 10) + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + e.setSpeed(20) + local resolves = g.resolves + observe("mod override", 20) + T.eq(g.resolves, resolves, "omitting next skips native resolver") + e.setSpeed(nil) + + -- Clamp at the producer boundary, including nil/false and non-finite values. + for _, case in ipairs({ + { name = "nil", expected = 1 }, + { name = "false", value = false, expected = 1 }, + { name = "NaN", value = 0 / 0, expected = 1 }, + { name = "infinity", value = math.huge, expected = 1 }, + { name = "negative infinity", value = -math.huge, expected = 1 }, + { name = "zero", value = 0, expected = 1 }, + { name = "negative", value = -20, expected = 1 }, + { name = "table", value = {}, expected = 1 }, + { name = "string", value = "20", expected = 20 }, + { name = "large", value = 1e9, expected = 200 }, + }) do + local off = api.hooks:wrap("core.logic_speed", function() + return case.value + end, 100) + observe(case.name .. " hook output", case.expected) + off() + end + -- Each host retains its own ladder (Gen1 includes 3X; Gen2 does not). + local off = api.hooks:wrap("core.logic_speed", function() + return 3 + end, 100) + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "native ladder preserved") + off() + local other = f.owner(10) + off = api.hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "outer wrapper sees original Game") + return next(other) + end, 100) + resolves = g.resolves + observe("replacement next target", 10) + T.eq(other.resolves, 1, "native resolver uses replacement Game") + T.eq(g.resolves, resolves, "original Game is not resolved after replacement") + T.eq(e.seen.game, other, "replacement reaches downstream wrapper") + off() + local order = {} + local outer = api.hooks:wrap("core.logic_speed", function(next) + order[#order + 1] = "outer" + local result = next() + order[#order + 1] = "after" + return result * 5 + end, 20) + local inner = api.hooks:wrap("core.logic_speed", function(next, game) + order[#order + 1] = "inner" + T.eq(game, g, "next() retains current owner") + return next() + end, 10) + observe("priority and postprocessing", 20) + T.same(order, { "outer", "inner", "after" }, "descending wrapper priority") + outer() + inner() + + e.setSpeed(200) + g.speedOverride = 20 + local calls = e.seen.calls + resolves = g.resolves + observe("explicit override wins", 20) + T.eq(e.seen.calls, calls, "explicit override bypasses hook") + T.eq(g.resolves, resolves, "explicit override bypasses saved option") + for _, kind in ipairs({ "link", "fixed" }) do + f.lock(kind) + T.eq(g:speedLocked(), true, kind .. " native lock detected") + observe(kind .. " wins over explicit override", 1) + g.speedOverride = nil + observe(kind .. " wins over hook", 1) + T.eq(e.seen.calls, calls, kind .. " hook is never called") + T.eq(g.resolves, resolves, kind .. " saved option is not resolved") + f.lock(nil) + observe(kind .. " unlock restores mod", 200) + calls = e.seen.calls + g.speedOverride = 20 + end + g.speedOverride = nil + f.lock(nil) + e.setSpeed(nil) + + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("core.logic_speed", function(next) + if after then + next() + end + error("intentional speed wrapper failure") + end, 100) + resolves, calls = g.resolves, e.seen.calls + observe(after and "error after next" or "error before next", 4) + T.eq(g.resolves, resolves + 1, "wrapper failure resolves native exactly once") + T.eq(e.seen.calls, calls + 1, "wrapper failure traverses consumer once") + off() + end + local original = g._resolveLogicSpeed + local attempts = 0 + g._resolveLogicSpeed = function() + attempts = attempts + 1 + error("native speed resolver failure") + end + T.raises(function() + g:logicSpeed() + end, "native speed resolver failure", "native errors escape") + T.eq(attempts, 1, "native error is not retried") + g._resolveLogicSpeed = original + if f.api >= 3 then + off = api.hooks:wrap("core.logic_speed", function(next) + next() + return next() + end, 100) + resolves = g.resolves + observe("API-3 cached continuation", 4) + T.eq(g.resolves, resolves + 1, "API-3 repeated next caches native result") + off() + end + -- Gen1's GBA boot phases are a separate native restriction. Gen2's + -- titles/attract screens retain their own timing; do not invent that host. + if f.backend == "gen1" and f.generation == 3 then + local Boot = require("src.ui.game3.boot") + g.phase = "boot" + calls = e.seen.calls + for _, phase in ipairs({ + Boot.PHASE.INTRO, + Boot.PHASE.TITLE, + Boot.PHASE.TITLE_CRY, + Boot.PHASE.TITLE_RESTART, + }) do + g.boot = { phase = phase } + T.eq(g:logicSpeed(), 1, "Gen1 GBA boot cinematic lock") + T.eq(e.seen.calls, calls, "Gen1 GBA boot bypasses hook") + end + g.speedOverride = 20 + T.eq(g:logicSpeed(), 20, "Gen1 GBA explicit override retains boot precedence") + g.speedOverride, g.phase = nil, "field" + end + e.stop() + e.stop() + calls = e.seen.calls + observe("unsubscribed native fallback", 4) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + -- The actual unwrapped resolver and launch override keep host-native + -- levels too. The shared contract does not add a 3X rung to Gen2. + for key in pairs(g.save.options) do + g.save.options[key] = 3 + end + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "unsubscribed native ladder") + g.speedOverride = 3 + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "explicit native ladder") + g.speedOverride = nil + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + local peerCalls = 0 + api.hooks:wrap("core.logic_speed", function() + return 200 + end, 100) + local peer = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + observe("owner cleanup restores native option", 4) + T.eq(peerCalls, 1, "owner cleanup preserves peer wrapper") + peer() + return rows +end +return M diff --git a/tests/logic_speed/fixture.lua b/tests/logic_speed/fixture.lua new file mode 100644 index 000000000..66c7b4732 --- /dev/null +++ b/tests/logic_speed/fixture.lua @@ -0,0 +1,95 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_logic_speed/" .. path, "rb")) + files["mods/portable_logic_speed/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_logic_speed/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_logic_speed/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_logic_speed" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local module = "src.core.Game" + if backend == "gen1" and generation > 1 then + module = module .. generation + end + local Game = require(module) + local function owner(speed) + local opts = { speed = speed, speedOverworld = speed, speedBattle = speed, speedMenu = speed } + local game = setmetatable({ + generation = generation, + phase = "field", + data = data, + mods = result.loader, + save = { options = opts }, + options = opts, + stack = { states = { { isOverworld = true } } }, + resolves = 0, + }, { __index = Game }) + function game:_resolveLogicSpeed() + self.resolves = self.resolves + 1 + return Game._resolveLogicSpeed(self) + end + return game + end + local g = owner(4) + -- Host-specific state fixtures exercise the actual native speedLocked method. + local keys = { "src.core.game3.link", "src.core.game3.minigames.common" } + local saved = {} + for _, key in ipairs(keys) do + saved[key] = package.loaded[key] + end + local function lock(kind) + g.linkSession, g.linkNet = nil, nil + g.stack.states = { { isOverworld = true } } + if backend == "gen1" and generation == 3 then + package.loaded[keys[1]] = { link = kind == "link" and true or nil } + package.loaded[keys[2]] = { + isActive = function() + return kind == "fixed" + end, + } + elseif kind == "link" then + g.linkSession = true + elseif kind == "fixed" then + g.stack.states = { { isFixedSpeed = true }, { isMenu = true } } + end + end + local exports = result.loader.exports.portable_logic_speed + return { + T = T, + result = result, + game = g, + owner = owner, + lock = lock, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + for _, key in ipairs(keys) do + package.loaded[key] = saved[key] + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/logic_speed/worker.lua b/tests/logic_speed/worker.lua new file mode 100644 index 000000000..1b1d47663 --- /dev/null +++ b/tests/logic_speed/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/logic_speed/cases.lua").run(f) +f.close() +local bare = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("core.logic_speed"), false, "no-mod runtime") +bare.T.eq(bare.game:logicSpeed(), 4, "no-mod native resolver") +bare.close() +print("LOGIC_SPEED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.logic_speed contract") diff --git a/tools/logic-speed-contract-check/check.py b/tools/logic-speed-contract-check/check.py new file mode 100644 index 000000000..73482836e --- /dev/null +++ b/tools/logic-speed-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged speed mod against both engines' native logicSpeed methods.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/logic_speed/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LOGIC_SPEED_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LOGIC_SPEED_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logic speed mismatch: {version}" + print(f"PASS {version}: matching native logic speed across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_logic_speed/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From af4571fa9037273879229c42c9f0219f016ddffa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:53:05 +0200 Subject: [PATCH 17/86] Add portable screen.render_visible dispatch and cross-engine contracts --- .../workflows/screen-visibility-contract.yml | 45 ++++++ docs/modding.md | 6 + docs/portable-screen-visibility.md | 98 ++++++++++++ examples/portable_screen_visibility/main.lua | 16 ++ .../portable_screen_visibility/manifest.json | 10 ++ src/core/Game.lua | 15 +- src/core/StateStack.lua | 30 +++- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/screen_visibility.lua | 5 + tests/engine/screen_visibility_routing.lua | 113 +++++++++++++ tests/screen_visibility/cases.lua | 150 ++++++++++++++++++ tests/screen_visibility/fixture.lua | 148 +++++++++++++++++ tests/screen_visibility/worker.lua | 13 ++ .../screen-visibility-contract-check/check.py | 58 +++++++ 15 files changed, 701 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/screen-visibility-contract.yml create mode 100644 docs/portable-screen-visibility.md create mode 100644 examples/portable_screen_visibility/main.lua create mode 100644 examples/portable_screen_visibility/manifest.json create mode 100644 tests/engine/screen_visibility.lua create mode 100644 tests/engine/screen_visibility_routing.lua create mode 100644 tests/screen_visibility/cases.lua create mode 100644 tests/screen_visibility/fixture.lua create mode 100644 tests/screen_visibility/worker.lua create mode 100644 tools/screen-visibility-contract-check/check.py diff --git a/.github/workflows/screen-visibility-contract.yml b/.github/workflows/screen-visibility-contract.yml new file mode 100644 index 000000000..f616e6f26 --- /dev/null +++ b/.github/workflows/screen-visibility-contract.yml @@ -0,0 +1,45 @@ +name: portable screen.render_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: screen-visibility-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native composition and input/update ownership + run: | + luajit tests/engine/screen_visibility.lua + luajit tests/engine/screen_visibility_routing.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/screen-visibility-contract + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/screen-visibility-contract/api1-native.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/screen-visibility-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/screen-visibility-contract/api2-unified.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/screen-visibility-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: screen-visibility-contract + path: /tmp/screen-visibility-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 2cc59fa3f..6a3fa5f76 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ 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). +## Per-state main-screen visibility + +The [portable screen visibility contract](portable-screen-visibility.md) +describes `screen.render_visible(next, state)`: hide a native state's main +render, opacity and palette ownership while keeping its update/input owner. + ## Logic speed hook The [portable logic speed contract](portable-logic-speed.md) documents diff --git a/docs/portable-screen-visibility.md b/docs/portable-screen-visibility.md new file mode 100644 index 000000000..50f6672d3 --- /dev/null +++ b/docs/portable-screen-visibility.md @@ -0,0 +1,98 @@ +# Portable state visibility predicate + +Gen2Recomped dispatches `screen.render_visible(next, state)` through native +API 1/2, unified API 2 and API 3 on every supported host. A companion or custom +UI can mirror a native state elsewhere while omitting its main-screen render: + +```lua +local mod = ... +local mirrored = false +mod.hooks:wrap("screen.render_visible", function(next, state) + if mirrored and state.screenId == "BagMenu" then return false end + return next(state) +end) +``` + +The default is `true`. Only a literal first result of `false` hides a state; +nil, zero, strings and tables do not. `next()` retains current arguments; +`next(state)` forwards the supplied state to downstream predicates. The native +default ignores that argument and returns true. These returns do not consume +input, pop a screen, pause simulation or replace the state that is drawn. + +Treat this as a pure predicate. Composition may ask about the same state +several times, including opacity selection, the main draw loop and palette +selection. Do not mutate states, open/close screens or depend on a specific +number of calls. Use descriptive fields such as `screenId` or `kind`, checking +which identities the host's actual screen exposes. This hook does not establish +a new cross-generation catalog of screen IDs. + +A hidden state: + +- Does not draw through `StateStack:draw` or the main Game composition loop. +- Cannot be the topmost opaque render base, a world-backdrop opaque fallback, + or the owner of main-screen UI/world palette zones. +- Stays in the same stack position and keeps normal update/input ownership. + Native covered-state animation still uses the original opacity rules; + simulation and keyboard handling never evaluate this render predicate. + +The hook controls individual main-screen states, not the whole output window +or a separate device display. It does not change whole-stack surface sizing, +fill/anchor preferences or native lifecycle semantics. Hiding an empty/sole +state draws no state; it does not promise a particular clear color. Separate +external world passes remain subject to their native host routing. + +`mod:supports("hooks", "screen.render_visible")` reports the real Gen2 producer, +including during mod entry before a Game is created. Unsubscribed calls return +true directly, without allocating a predicate closure or payload. Existing +priority, cleanup, rollback and error rules apply. A wrapper failure before +forwarding falls through; one after forwarding preserves the downstream +result. Native draw failures propagate and are not retried. Unified +continuations retain their existing caching rules. + +## Reference boundaries + +The verified Gen1Recomp reference is clean commit +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Its Gen1 and Gold-family games +use the common StateStack producer. Its separate GBA UI stack does **not** +dispatch this predicate, despite listing the name in its callback catalog. +Gen2Recomped uses its common native stack for all five generations, so this +addition is also available on its GBA/DS hosts. The shared tests compare Red, +Yellow, Gold and Crystal; the Gen2-only matrix covers every supported version. +No Gen1 sources are modified to make the contract pass. + +Native Gen1 API 1/2 passes the original state; Gen1 API 3 currently supplies a +shallow context view and can restore edits through its generic adapter. +Gen2 passes the original opaque state in all four tested modes. Therefore +table identity, arbitrary state edits, metatable access and replacement-state +mutation are **not** shared guarantees. The same pure, property-based consumer +runs on both engines unchanged. Tests assert each host's existing identity +behavior separately. They do not retrofit Gen1's adapter into Gen2 or mask +that remaining distinction. Gen1 API 2 ignores the unified opt-in, so that +comparison uses native Gen1 API 2 against unified Gen2 API 2. + +Gen1 Gold draws its overworld outside the state stack and has its own native +widescreen/palette routing. Common journals compare state draw order and render +base, not external-world draw counts or palette implementations that differ +between hosts. The Gen1/Gen2 palette paths that use the shared Renderer are +asserted separately. + +## Verification + +```sh +luajit tests/engine/screen_visibility.lua +luajit tests/engine/screen_visibility_routing.lua +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects a dirty/unexpected reference and runs the same committed +mod through both native loaders. It invokes the real StateStack and Game draw +methods (including native Gold `drawScene`) with ROM-free states and isolated +Renderer presentation methods. The Gen2 matrix also checks animation/input +ownership, world palettes/clear ownership, hidden backdrop states, no-mod fast +paths, owner removal and failed-entry rollback. The CI workflow pins the same +reference and publishes machine-readable contract reports. These headless +checks do not claim a cartridge playthrough, actual mirrored-device output +or GPU pixel equivalence. diff --git a/examples/portable_screen_visibility/main.lua b/examples/portable_screen_visibility/main.lua new file mode 100644 index 000000000..63ab03435 --- /dev/null +++ b/examples/portable_screen_visibility/main.lua @@ -0,0 +1,16 @@ +-- A companion can mirror a native menu while its original state keeps input. +-- Keep the predicate pure: composition may ask about a state repeatedly. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(screenId, value) + hidden[screenId] = value == true +end +mod.exports.stop = mod.hooks:wrap("screen.render_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden[state.screenId] then + return false + end + return next(state) +end) diff --git a/examples/portable_screen_visibility/manifest.json b/examples/portable_screen_visibility/manifest.json new file mode 100644 index 000000000..ec5483f0e --- /dev/null +++ b/examples/portable_screen_visibility/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_screen_visibility", + "name": "Portable Screen Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/Game.lua b/src/core/Game.lua index 0c9fb1506..3b18cf1aa 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -17,6 +17,11 @@ local Screens = require("src.ui.Screens") local Game = {} +-- Legacy/tool stack doubles may not expose the optional visibility method. +local function renderVisible(stack, state) + return state ~= nil and (not stack.renderVisible or stack:renderVisible(state)) +end + -- The default must follow an explicit next(otherGame), not capture the caller. local function resolveLogicSpeedVanilla(game) return game:_resolveLogicSpeed() @@ -856,11 +861,11 @@ function Game.drawBaseInStack(stack, visibleBase) local states = stack and stack.states or {} for i = visibleBase - 1, 1, -1 do local state = states[i] - if state and state.bgMode and state:bgMode() == "world" then + if renderVisible(stack, state) and state.bgMode and state:bgMode() == "world" then -- restart the search from under the battle: the highest opaque state at -- or below it (the overworld), not the menu sitting over it for j = i, 1, -1 do - if states[j].isOpaque then return j end + if renderVisible(stack, states[j]) and states[j].isOpaque then return j end end return 1 end @@ -1041,10 +1046,12 @@ function Game:_draw() -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld + and renderVisible(self.stack, self.overworld) -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) local worldDrawn = self.stack.states[drawFrom] == self.overworld + and renderVisible(self.stack, self.overworld) -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay @@ -1120,7 +1127,7 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if renderVisible(self.stack, state) and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1145,7 +1152,7 @@ function Game:_draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..00e8f8390 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -172,26 +172,48 @@ end -- update opens the menu on A, and running that under the menu would open a -- second one on the same press) and it must not push, pop or finish. A state -- that wants both puts the motion in animate and calls it from update. +-- Update/covered animation follows native opacity, regardless of rendering. +-- Hiding a mirrored menu must not animate newly exposed states during logic. +local function opaqueBase(states) + for i = #states, 1, -1 do + if states[i].isOpaque then return i end + end + return 1 +end + function StateStack:update(dt) local top = self:top() if top and top.update then top:update(dt) end - for i = self:visibleBase(), #self.states - 1 do + for i = opaqueBase(self.states), #self.states - 1 do local state = self.states[i] if state.animate then state:animate(dt) end end end --- index of the lowest state drawn this frame (highest opaque, else 1) +local function visibleByDefault() return true end + +-- A pure main-screen predicate: only literal false hides the native state. +-- Update/input ownership and the stored stack remain unchanged. +function StateStack:renderVisible(state) + if not state then return false end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false +end + +-- index of the lowest state drawn this frame (highest visible opaque, else 1) function StateStack:visibleBase() + if not Runtime.wantsHook("screen.render_visible") then return opaqueBase(self.states) end for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and state.isOpaque then return i end end return 1 end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local state = self.states[i] + if self:renderVisible(state) and state.draw then state:draw() end end end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 16bfd31bc..45c919cb2 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.key input.gamepad input.wheel 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.zones save.new_game save.write screen.render_visible 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 6f0d48233..c31c7be4e 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.key input.gamepad input.wheel 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.zones save.new_game save.write screen.render_visible 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/tests/engine/screen_visibility.lua b/tests/engine/screen_visibility.lua new file mode 100644 index 000000000..aa8858b16 --- /dev/null +++ b/tests/engine/screen_visibility.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/screen_visibility/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/screen_visibility/cases.lua").run(f) +f.close() +f.T.finish("portable screen.render_visible") diff --git a/tests/engine/screen_visibility_routing.lua b/tests/engine/screen_visibility_routing.lua new file mode 100644 index 000000000..f3e84272d --- /dev/null +++ b/tests/engine/screen_visibility_routing.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/screen_visibility/fixture.lua") +local Cases = dofile("tests/screen_visibility/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Game = require("src.core.Game") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, stack, base, menu, overlay = f.exports, f.stack, f.base, f.menu, f.overlay + e.hide("BagMenu", true) + local baseAnimations, menuAnimations = 0, 0 + base.animate = function() + baseAnimations = baseAnimations + 1 + end + menu.animate = function() + menuAnimations = menuAnimations + 1 + end + stack.states = { base, menu, overlay } + local polls = e.seen.calls + stack:update(1 / 60) + T.eq(baseAnimations, 0, "hidden opaque menu still covers lower simulation animation") + T.eq(menuAnimations, 1, "hidden covered menu retains native animation") + T.eq(overlay.updates, 1, "visible overlay retains update ownership") + T.eq(e.seen.calls, polls, "animation does not invoke render predicate") + base.animate, menu.animate = nil, nil + stack.states = { base, menu } + local key + menu.onKeyPressed = function(_, value) + key = value + end + f.game:keypressed("screen-contract-key") + T.eq(key, "screen-contract-key", "hidden menu retains native keyboard dispatch") + T.eq(e.seen.calls, polls, "keyboard does not invoke render predicate") + menu.onKeyPressed = nil + local worldPalettes = 0 + base.sgbWorldZones = function() + worldPalettes = worldPalettes + 1 + return "world zones" + end + local trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, { "Base" }, "world below hidden opaque menu is drawn") + T.eq(zones, "Base zones", "hidden menu cannot claim UI palettes") + T.eq(worldZones, "world zones", "visible world retains separate palettes") + T.eq(clearWorld, true, "visible world retains transparent UI clear") + e.hide("Base", true) + trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, {}, "all hidden states draw nothing") + T.eq(zones, nil, "all hidden states claim no UI palette") + T.eq(worldZones, nil, "hidden world cannot claim world palette") + T.eq(worldPalettes, 1, "hidden world palette provider is never invoked") + T.eq(clearWorld, false, "hidden world cannot claim clear ownership") + e.hide("Base", false) + -- World-backed battles can extend drawing below the opaque base. Their + -- fallback search must not select a hidden opaque state as that base. + local middle = f.state("BagMenu", true) + local battle = f.state("Battle", false) + battle.bgMode = function() + return "world" + end + overlay.isOpaque = true + stack.states = { base, middle, battle, overlay } + T.eq(Game.drawBaseInStack(stack, 4), 1, "backdrop search skips hidden opaque states") + e.hide("Battle", true) + T.eq(Game.drawBaseInStack(stack, 4), 4, "hidden battle does not extend native backdrop") + e.hide("Battle", false) + overlay.isOpaque = false + base.sgbWorldZones = nil + e.hide("BagMenu", false) + base.updates, menu.updates, menu.presses, overlay.updates = 0, 0, 0, 0 + Cases.run(f) + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +local originalCall, calls = Runtime.call, 0 +Runtime.call = function(name, ...) + if name == "screen.render_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("screen.render_visible"), false, "no-mod runtime") +T.eq(bare.stack:renderVisible(nil), false, "nil remains hidden without mods") +T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod default true") +T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native main render") +bare.stack:draw() +T.eq(calls, 0, "unsubscribed predicate never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +local files = { + ["mods/broken_screen/manifest.json"] = [[{"id":"broken_screen","name":"Broken Screen", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_screen/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function() return false end) + error("intentional visibility entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_screen" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry reported") +T.eq(Runtime.wantsHook("screen.render_visible"), false, "rollback removes hidden-state predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("screen.render_visible"), false, "teardown removes hidden-state predicate") +failed.release() +Version.set(originalVersion) +T.finish("screen.render_visible native routing") diff --git a/tests/screen_visibility/cases.lua b/tests/screen_visibility/cases.lua new file mode 100644 index 000000000..c169023b6 --- /dev/null +++ b/tests/screen_visibility/cases.lua @@ -0,0 +1,150 @@ +local M = {} +function M.run(f) + local T, stack, e, api = f.T, f.stack, f.exports, f.public + local base, menu, overlay = f.base, f.menu, f.overlay + local rows = {} + local function draw(label, states, expected, palette) + local trace, zones = f.frame(states) + T.same(trace, expected, label .. " native draw order") + if f.palettePath then + T.eq(zones, palette, label .. " visible palette owner") + end + T.same(stack.states, states, label .. " rendering does not rewrite stack") + rows[#rows + 1] = { label = label, drawn = trace, base = stack:visibleBase() } + end + if api.supports then + T.eq(api:supports("hooks", "screen.render_visible"), true, "native capability") + end + T.eq(stack:renderVisible(nil), false, "nil is not a renderable state") + draw("native opaque menu", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.extra, 0, "opaque state signature has no invented args") + T.eq(e.seen.state.screenId, menu.screenId, "native screen identifier reaches public hook") + T.eq(e.seen.state == menu, f.stateIdentity, "host-native state identity semantics") + e.hide("BagMenu", true) + draw("mirrored menu", { base, menu }, { "Base" }, "Base zones") + T.same(f.stackFrame({ base, menu }), { "Base" }, "native StateStack:draw hides mirrored menu") + T.eq(stack:top(), menu, "hidden menu remains the native top") + local before, baseUpdates, presses = e.seen.calls, base.updates, menu.presses + f.input:keypressed("z") + f.input:step() + stack:update(1 / 60) + T.eq(menu.updates, 1, "hidden menu keeps update ownership") + T.eq(base.updates, baseUpdates, "world below does not steal update ownership") + T.eq(menu.presses, presses + 1, "hidden menu receives native Input press") + T.eq(e.seen.calls, before, "simulation never queries the render predicate") + f.input:keyreleased("z") + f.input:step() + draw("overlay above mirrored menu", { base, menu, overlay }, { "Base", "TextBox" }, "TextBox zones") + local hiddenChild = f.state("BagMenu", true) + draw("multiple mirrored menus", { base, menu, hiddenChild }, { "Base" }, "Base zones") + local replacement = {} + for key, value in pairs(base) do + replacement[key] = value + end + replacement.contractMarker = "replacement" + + local off = api.hooks:wrap("screen.render_visible", function(next, state) + T.eq(state.screenId, base.screenId, "outer predicate sees actual state fields") + return next(replacement) + end, 100) + T.eq(stack:renderVisible(base), true, "next(replacement) reaches downstream predicate") + T.eq(e.seen.state.contractMarker, "replacement", "explicit next forwards replacement fields") + off() + base.contractMarker = nil + -- Only literal false hides. No boolean coercion or special consume value. + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("screen.render_visible", function() + return case.value + end, 100) + T.eq(stack:renderVisible(menu), true, case.name .. " remains visible") + off() + end + off = api.hooks:wrap("screen.render_visible", function() + return false, true + end, 100) + T.eq(stack:renderVisible(base), false, "only first result controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("screen.render_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("screen.render_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state.screenId, base.screenId, "next() keeps current state fields") + return next() + end, 10) + T.eq(stack:renderVisible(base), true, "default true passes through public wrappers") + T.same(order, { "outer", "inner", "after" }, "priority and next ordering") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("screen.render_visible", function(next) + if after then + next() + end + error("intentional screen predicate failure") + end, 100) + T.eq(stack:renderVisible(menu), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("screen.render_visible", function(next) + next() + return next() + end, 100) + before = e.seen.calls + T.eq(stack:renderVisible(base), true, "API-3 repeated next result") + T.eq(e.seen.calls, before + 1, "API-3 next caches downstream predicate") + off() + end + draw("hidden sole menu", { menu }, {}, nil) + draw("empty stack", {}, {}, nil) + local nativeDraw, attempts = menu.draw, 0 + menu.draw = function() + attempts = attempts + 1 + error("native screen draw failure") + end + f.frame({ base, menu }) + T.eq(attempts, 0, "hidden native drawer is never invoked") + e.hide("BagMenu", false) + T.raises(function() + f.frame({ base, menu }) + end, "native screen draw failure", "native draw errors escape") + T.eq(attempts, 1, "native draw failure is not retried") + menu.draw = nativeDraw + e.hide("BagMenu", true) + + e.hide("Base", true) + e.hide("TextBox", true) + draw("all hidden", { base, menu, overlay }, {}, nil) + e.hide("Base", false) + e.hide("TextBox", false) + e.stop() + e.stop() + before = e.seen.calls + draw("unsubscribed", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.calls, before, "unsubscribe is idempotent") + api.hooks:wrap("screen.render_visible", function() + return false + end, 100) + local peers = 0 + local peer = f.result.loader.hooks:wrap("screen.render_visible", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_screen_owner") + f.result.loader.hooks:removeOwner("portable_screen_visibility") + T.eq(stack:renderVisible(menu), true, "owner removal restores default") + T.eq(peers, 1, "owner removal preserves another mod") + peer() + return rows +end +return M diff --git a/tests/screen_visibility/fixture.lua b/tests/screen_visibility/fixture.lua new file mode 100644 index 000000000..4ea71a75a --- /dev/null +++ b/tests/screen_visibility/fixture.lua @@ -0,0 +1,148 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_screen_visibility/" .. path, "rb")) + files["mods/portable_screen_visibility/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_screen_visibility/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_screen_visibility/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_screen_visibility" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local StateStack = require("src.core.StateStack") + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local module = backend == "gen1" and generation == 2 and "src.core.Game2" or "src.core.Game" + local Game = require(module) + local g = setmetatable({ + stack = stack, + mods = result.loader, + data = data, + save = { options = {} }, + inFillBoot = function() + return false + end, + letterbox = function() end, + paintBattleSurround = function() end, + }, { __index = Game }) + local trace, zones, worldZones, clearWorld = {}, nil, nil, nil + local externalDraws = 0 + local Renderer = require("src.render.Renderer") + local Touch = require("src.core.TouchControls") + local saved = { Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw } + local width, height = Renderer.WIDTH, Renderer.HEIGHT + Renderer.setUISize = function(_, w, h) + width, height = w, h + end + Renderer.uiSize = function() + return width, height + end + Renderer.beginFrame = function(_, world) + clearWorld = world + end + Renderer.endFrame = function(_, z, wz) + zones, worldZones = z, wz + return {} + end + Touch.draw = function() end + local Input = require("src.core.Input") + Input:init() + local function state(id, opaque) + return { + screenId = id, + isOpaque = opaque, + updates = 0, + presses = 0, + palettes = 0, + draw = function() + trace[#trace + 1] = id + end, + update = function(self) + self.updates = self.updates + 1 + if Input:wasPressed("a") then + self.presses = self.presses + 1 + end + end, + sgbPalettes = function(self) + self.palettes = self.palettes + 1 + return id .. " zones" + end, + } + end + local base, menu, overlay = state("Base", true), state("BagMenu", true), state("TextBox", false) + g.overworld = base + g.world = { + map = {}, + fitScale = function() + return 4 + end, + draw = function() + externalDraws = externalDraws + 1 + end, + } + local function frame(states) + trace, zones, worldZones, clearWorld = {}, nil, nil, nil + externalDraws = 0 + stack.states = states + if backend == "gen1" and generation == 2 then + g:drawScene(640, 576) + elseif backend == "gen1" then + g:draw() + else + g:_draw() + end + return trace, zones, worldZones, clearWorld + end + local exports = result.loader.exports.portable_screen_visibility + return { + T = T, + result = result, + game = g, + stack = stack, + base = base, + menu = menu, + overlay = overlay, + stackFrame = function(states) + trace = {} + stack.states = states + stack:draw() + return trace + end, + frame = frame, + state = state, + input = Input, + backend = backend, + generation = generation, + palettePath = backend ~= "gen1" or generation == 1, + stateIdentity = backend ~= "gen1" or api < 3, + externalDraws = function() + return externalDraws + end, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw = + saved[1], saved[2], saved[3], saved[4], saved[5] + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/screen_visibility/worker.lua b/tests/screen_visibility/worker.lua new file mode 100644 index 000000000..77c34ef3d --- /dev/null +++ b/tests/screen_visibility/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/screen_visibility/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/screen_visibility/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("screen.render_visible"), false, "no-mod Runtime") +bare.T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod visibility default") +bare.T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native composition") +bare.close() +print("SCREEN_VISIBILITY_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable screen.render_visible contract") diff --git a/tools/screen-visibility-contract-check/check.py b/tools/screen-visibility-contract-check/check.py new file mode 100644 index 000000000..d364f92b1 --- /dev/null +++ b/tools/screen-visibility-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged visibility mod through both engines' native screen composition.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "crystal"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/screen_visibility/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("SCREEN_VISIBILITY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("SCREEN_VISIBILITY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"screen visibility mismatch: {version}" + print(f"PASS {version}: matching native screen composition across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_screen_visibility/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5764ec80f5d4f0070d1069ea6ae4bd30fa3b45c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:46:17 +0200 Subject: [PATCH 18/86] Add portable battle.bottom_ui_visible dispatch and cross-engine contracts --- .../workflows/battle-bottom-ui-contract.yml | 49 +++++ docs/modding.md | 6 + docs/portable-battle-bottom-ui.md | 100 +++++++++ examples/portable_battle_bottom_ui/main.lua | 16 ++ .../portable_battle_bottom_ui/manifest.json | 10 + src/battle/BattleState.lua | 8 +- src/battle/UIVisibility.lua | 71 +++++- src/battle/WideBattle.lua | 2 + src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- src/render/TextBox.lua | 2 + src/ui/ChoiceBox.lua | 2 + tests/battle_bottom_ui/cases.lua | 129 +++++++++++ tests/battle_bottom_ui/fixture.lua | 161 ++++++++++++++ tests/battle_bottom_ui/worker.lua | 13 ++ tests/engine/battle_bottom_ui.lua | 5 + tests/engine/battle_bottom_ui_composition.lua | 132 ++++++++++++ tests/engine/battle_bottom_ui_routing.lua | 203 ++++++++++++++++++ .../battle-bottom-ui-contract-check/check.py | 58 +++++ 19 files changed, 960 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/battle-bottom-ui-contract.yml create mode 100644 docs/portable-battle-bottom-ui.md create mode 100644 examples/portable_battle_bottom_ui/main.lua create mode 100644 examples/portable_battle_bottom_ui/manifest.json create mode 100644 tests/battle_bottom_ui/cases.lua create mode 100644 tests/battle_bottom_ui/fixture.lua create mode 100644 tests/battle_bottom_ui/worker.lua create mode 100644 tests/engine/battle_bottom_ui.lua create mode 100644 tests/engine/battle_bottom_ui_composition.lua create mode 100644 tests/engine/battle_bottom_ui_routing.lua create mode 100644 tools/battle-bottom-ui-contract-check/check.py diff --git a/.github/workflows/battle-bottom-ui-contract.yml b/.github/workflows/battle-bottom-ui-contract.yml new file mode 100644 index 000000000..38754d2d6 --- /dev/null +++ b/.github/workflows/battle-bottom-ui-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.bottom_ui_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-bottom-ui-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check bottom UI, inherited overlays and native battle composition + run: | + luajit tests/engine/battle_bottom_ui.lua + luajit tests/engine/battle_bottom_ui_routing.lua + luajit tests/engine/battle_bottom_ui_composition.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/battle-bottom-ui-contract + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-bottom-ui-contract/api1-native.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-bottom-ui-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-bottom-ui-contract/api2-unified.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-bottom-ui-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-bottom-ui-contract + path: /tmp/battle-bottom-ui-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 6a3fa5f76..3cb3f8ba3 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ 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). +## Battle bottom UI visibility + +The [portable battle bottom UI contract](portable-battle-bottom-ui.md) documents +`battle.bottom_ui_visible(next, state)` for battle text/menu drawing and +inherited text/YES-NO overlays, without changing native input/update ownership. + ## Per-state main-screen visibility The [portable screen visibility contract](portable-screen-visibility.md) diff --git a/docs/portable-battle-bottom-ui.md b/docs/portable-battle-bottom-ui.md new file mode 100644 index 000000000..59d89326b --- /dev/null +++ b/docs/portable-battle-bottom-ui.md @@ -0,0 +1,100 @@ +# Portable battle bottom UI visibility + +`battle.bottom_ui_visible(next, state)` controls native battle text and menu +presentation on every Gen2Recomped host, through native API 1/2, unified API 2 +and API 3. It complements `screen.render_visible`: a mod can hide the bottom +UI without hiding the battle scene, sprites, health/status panels or effects. + +```lua +local mod = ... +local replacingBattleUI = false +mod.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if replacingBattleUI then return false end + return next(state) +end) +``` + +Keep the flag active only while the mod owns the intended presentation. +Native TextBox also uses this hook outside battles, so a global false can hide +field dialogue too. Mods may instead select specific handles or state +properties. Battle metadata/layout internals differ between engines: Gen2's +native class uses `isBattleState`, while Gen1's classic classes use `isBattle`. +The Gen2 inheritance helper recognizes both without altering those classes. + +The callback receives the original opaque state, with no extra arguments or +record/ID conversion. The default is true; only a literal first result of +false hides UI. Nil, zero, strings and tables remain visible. `next()` keeps +the current arguments; explicit `next(otherState)` changes downstream +predicate arguments, not the actual state or drawing target. The default +ignores the argument. Treat the callback as a pure predicate: rendering and +menu clipping may query it repeatedly during one frame. + +## Native routing + +The primary gate runs before BattleState dispatches its text area to classic, +GBA or DS renderers. WideBattle's separate bottom draw also honors it. On DS, +this gates the text/menu path that owns both compact and device-screen action, +move and target controls; it does not hide independent device content or +change input ownership. The Game Boy move/Mimic row clip is removed while +that menu is hidden, retaining existing unrelated field/side clips. + +TextBox and ChoiceBox inherit a false decision from the nearest enclosing +battle below them on the native stack. A hidden battle short-circuits before +text frames, choice backings, glyphs and UI anchors are written. When the +battle is visible, TextBox also has its own individual predicate. ChoiceBox +only inherits: a field YES/NO prompt never becomes a battle-hook state. +A battle above an overlay is not its owner. + +The predicate does not remove states, consume input or pause logic. Typewriter +updates and delayed YES/NO confirmation remain native. It does not modify +existing rendering-driven presentation counters or their timing when a drawer +is skipped. `battle.status_hud_visible` remains a separate surface with its +existing generation coverage; this change does not add missing GB/GBA HUD +producers. `screen.render_visible` may independently hide an entire state. + +`mod:supports("hooks", "battle.bottom_ui_visible")` reports the native producer +before Game creation. With no subscriber the helper returns true directly; +the default function is hoisted and the unhooked path allocates no predicate +closure/payload. Existing priorities, unsubscribe, owner cleanup and failed- +entry rollback apply. Gen2's existing `claimBottom`/`releaseBottom`, claimant +introspection, reset and failing-claim removal remain intact. A legacy claim +that denies drawing wins before the public hook; a hook returning true cannot +force another owner's hidden strip visible. A wrapper error before forwarding falls through; an +error after forwarding preserves downstream results without replay. Unified +callbacks retain existing cached-continuation rules. Native renderer error +handling is unchanged, including DS's existing guarded text-area drawing. + +## Verification + +```sh +luajit tests/engine/battle_bottom_ui.lua +luajit tests/engine/battle_bottom_ui_routing.lua +luajit tests/engine/battle_bottom_ui_composition.lua +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects dirty/unexpected Gen1 sources and pins +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed example runs +through both native loaders on Red, Gold, FireRed and Emerald. It calls the +real native predicates and bottom render routes: Gen1 BattleState, Gold's +`drawBottom`, and GBA battle Ui.draw's caught-Pokemon scene, compared with +Gen2's native BattleState dispatch. GBA inheritance is exercised through its +own Ui helper; the shared classic TextBox helper is not presented as Gen1 +GBA's native message implementation. Gen1 API 2 ignores the unified opt-in; +that run compares native Gen1 API 2 against unified Gen2 API 2. API 3 is +unified on both hosts, with opaque state identity preserved for this hook. + +The Gen2 routing matrix covers every supported version in all four modes, +actual inherited TextBox/ChoiceBox drawers and input/update behavior, native +move clipping, cleanup and the no-mod fast path. The composition suite builds +real native wild battles across every version in classic/wide options. It +runs their compositors with asset-layer spies, retaining the actual bottom +render path, and checks field/actor/HUD/animation dispatch plus identical +native menu input and logic ticks. It also covers the GB palette-canvas path. +The CI workflow also runs the reference engine's unchanged native GBA battle +UI contract suite, covering its regular menus, HUD and native confirmations. +These are ROM-free/headless dispatch tests, not GPU pixel comparisons, live +mirrored-device output or cartridge playthroughs. diff --git a/examples/portable_battle_bottom_ui/main.lua b/examples/portable_battle_bottom_ui/main.lua new file mode 100644 index 000000000..d8a64c6a4 --- /dev/null +++ b/examples/portable_battle_bottom_ui/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native bottom layer without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.bottom_ui_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_battle_bottom_ui/manifest.json b/examples/portable_battle_bottom_ui/manifest.json new file mode 100644 index 000000000..f674b2d36 --- /dev/null +++ b/examples/portable_battle_bottom_ui/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_battle_bottom_ui", + "name": "Portable Battle Bottom UI", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c2f51594a..3d2dc912d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -24,6 +24,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -577,6 +578,10 @@ function BattleState:uiSize() return 160, 144 end +function BattleState:bottomUIVisible() + return UIVisibility.bottomVisible(self, true) +end + -- Battle colors itself per-pixel (species pics + HP bar tints), so the -- SGB whole-screen remap must not run over it. The wide layout still -- needs a zone list of its own: the invented 160x144 one would leave its @@ -11927,7 +11932,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- .mimicmenu) wipes rows 7+. The port draws pics above the menu -- layer in the colorized pipeline, so clip them to the visible rows. local g = love.graphics - local clipY = not skipMenuClip + local clipY = not skipMenuClip and self:bottomUIVisible() and (self.phase == "mimicSelect" and 56 or self.phase == "moveSelect" and 64) or nil @@ -12376,6 +12381,7 @@ function BattleState:drawHUDs(slide) end function BattleState:drawTextArea() + if not self:bottomUIVisible() then return end -- EMERALD'S OWN, and reached through this method rather than around it. -- Gen3Battle.draw used to call its layer functions directly, which meant a -- mod wrapping this name never saw a Gen 3 battle at all. diff --git a/src/battle/UIVisibility.lua b/src/battle/UIVisibility.lua index 3165af0ca..a79808544 100644 --- a/src/battle/UIVisibility.lua +++ b/src/battle/UIVisibility.lua @@ -23,7 +23,8 @@ -- -- `box` is the TextBox / ChoiceBox instance about to draw and may be nil; -- `isTextBox` is true for the text box and false for the choice box. Both are --- passed to every predicate; neither is interpreted here. +-- passed unchanged to every legacy claim. The public hook below uses +-- isTextBox to decide whether to query that individual state as well. -- -- A predicate that throws is treated as "no opinion" and its claim is dropped, -- because this is called from inside a draw and a broken claim must not be @@ -31,6 +32,7 @@ -- session, which would leave the game unplayable with no error on screen. local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local UIVisibility = {} @@ -42,39 +44,92 @@ local bottomClaims = {} -- twice with the same owner replaces the predicate rather than stacking, so -- an install that runs again after a reload cannot leak a claim. function UIVisibility.claimBottom(owner, predicate) - if owner == nil or type(predicate) ~= "function" then return false end + if owner == nil or type(predicate) ~= "function" then + return false + end bottomClaims[owner] = predicate return true end function UIVisibility.releaseBottom(owner) - if owner == nil then return false end + if owner == nil then + return false + end local had = bottomClaims[owner] ~= nil bottomClaims[owner] = nil return had end +local function visibleByDefault() + return true +end + +local function enclosingBattle(state) + local stack = state and state.game and state.game.stack + local states = stack and stack.states + if not states then + return nil + end + local found = false + for i = #states, 1, -1 do + local candidate = states[i] + if candidate == state then + found = true + end + if found and candidate and (candidate.isBattle or candidate.isBattleState) then + return candidate + end + end +end + +-- TextBox is queried individually too. ChoiceBox only inherits an enclosing +-- battle, so a field YES/NO never becomes a battle-hook state. +local function hookBottomVisible(state, queryState) + if not Runtime.wantsHook("battle.bottom_ui_visible") then + return true + end + local battle = enclosingBattle(state) + if + battle + and battle ~= state + and Runtime.call("battle.bottom_ui_visible", visibleByDefault, battle) == false + then + return false + end + if queryState or battle == state then + return Runtime.call("battle.bottom_ui_visible", visibleByDefault, state) ~= false + end + return true +end + -- Whoever is asking is allowed to draw the bottom box, unless a claim says no. function UIVisibility.bottomVisible(box, isTextBox) for owner, predicate in pairs(bottomClaims) do local ok, visible = pcall(predicate, box, isTextBox) if not ok then bottomClaims[owner] = nil - Logger.warn("UIVisibility: bottom claim %s failed and was dropped: %s", - tostring(owner), tostring(visible)) + Logger.warn( + "UIVisibility: bottom claim %s failed and was dropped: %s", + tostring(owner), + tostring(visible) + ) elseif visible == false then return false end end - return true + return hookBottomVisible(box, isTextBox) end -- Introspection for the mod manager / a diagnostic overlay: who is currently -- holding the strip. Returns a fresh array of owner ids. function UIVisibility.bottomClaimants() local out = {} - for owner in pairs(bottomClaims) do out[#out + 1] = owner end - table.sort(out, function(a, b) return tostring(a) < tostring(b) end) + for owner in pairs(bottomClaims) do + out[#out + 1] = owner + end + table.sort(out, function(a, b) + return tostring(a) < tostring(b) + end) return out end diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..eb8e263cb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -15,6 +15,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") @@ -359,6 +360,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) + if not UIVisibility.bottomVisible(battle, true) then return end if battle.phase == "messages" and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 45c919cb2..716524749 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -15,7 +15,7 @@ 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.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c31c7be4e..3ba666cd1 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 359964018..7e4e2f7ac 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -9,6 +9,7 @@ local Logger = require("src.core.Logger") local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -636,6 +637,7 @@ function TextBox:uiSize() end function TextBox:draw() + if not UIVisibility.bottomVisible(self, true) then return end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index ba7edbdd0..6261b64ec 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,6 +1,7 @@ -- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") @@ -67,6 +68,7 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() + if not UIVisibility.bottomVisible(self, false) then return end local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th -- rides the same bottom anchor as the dialogue box it sits above, so the -- pair travels together (the anchor keeps each element's gap from the edge) diff --git a/tests/battle_bottom_ui/cases.lua b/tests/battle_bottom_ui/cases.lua new file mode 100644 index 000000000..827922827 --- /dev/null +++ b/tests/battle_bottom_ui/cases.lua @@ -0,0 +1,129 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native bottom draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.bottom_ui_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden bottom leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local text, choice = f.textBox(), f.choiceBox() + f.stack.states = { b, text, choice } + T.eq(f.inherited(text, true), false, "text inherits enclosing battle false") + T.eq(e.seen.state, b, "inherited decision receives enclosing battle") + T.eq(f.inherited(choice, false), false, "choice inherits enclosing battle false") + e.hide(false) + T.eq(f.inherited(text, true), true, "text returns when enclosing battle visible") + T.eq(e.seen.state, text, "visible battle permits individual text predicate") + local off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == text then + return false + end + return next(state) + end, 100) + T.eq(f.inherited(text, true), false, "individual text can hide without hiding battle") + T.eq(f.inherited(choice, false), true, "individual text decision does not hide choice") + off() + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("battle.bottom_ui_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.bottom_ui_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.bottom_ui_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + if after then + next() + end + error("intentional bottom UI predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.bottom_ui_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.bottom_ui_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_battle_bottom_ui") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/battle_bottom_ui/fixture.lua b/tests/battle_bottom_ui/fixture.lua new file mode 100644 index 000000000..f83de5d31 --- /dev/null +++ b/tests/battle_bottom_ui/fixture.lua @@ -0,0 +1,161 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_battle_bottom_ui/" .. path, "rb")) + files["mods/portable_battle_bottom_ui/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_battle_bottom_ui/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_bottom_ui/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_battle_bottom_ui" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local counters = { ink = 0, field = 0, actors = 0, hud = 0 } + local function ink() + counters.ink = counters.ink + 1 + end + local Font = require("src.render.Font") + for _, key in ipairs({ "drawBox", "drawDialogueBox", "draw", "drawCode", "drawHPBar" }) do + patch(Font, key, ink) + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local battle = setmetatable({ + game = game, + data = data, + save = game.save, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + battle = { player = {}, enemy = {} }, + printMessage = function() end, + }, { __index = class }) + local Ui + local visibility = require("src.battle.UIVisibility") + local drawBottom + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "box", ink) + patch(Chrome, "printThrough", ink) + drawBottom = function() + battle:drawBottom(0) + end + elseif backend == "gen1" and generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(battle, { options = {} }) + local Chrome = require("src.ui.game3.battle_chrome") + patch(Chrome, "drawPanel", ink) + patch(Chrome, "drawPostDexBg", function() + counters.field = counters.field + 1 + end) + local image = love.graphics.newImage("bottom-contract-sprite.png") + Ui._caughtDexScene = { + pal = { + fx = function() + return nil + end, + }, + sprite = { img = image, x = 0, y = 0, scaleY = 1 }, + } + local draw = love.graphics.draw + patch(love.graphics, "draw", function(img, ...) + if img == image then + counters.actors = counters.actors + 1 + end + return draw(img, ...) + end) + drawBottom = function() + Ui.draw(240, 160, true) + end + else + drawBottom = function() + battle:drawTextArea() + end + end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local function textBox() + return TextBox.new(game, "PORTABLE", nil, { drawFrame = true }) + end + local function choiceBox(callback) + return ChoiceBox.new(game, callback or function() end, { noSound = true }) + end + local exports = result.loader.exports.portable_battle_bottom_ui + return { + T = T, + result = result, + game = game, + battle = battle, + stack = stack, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + patch = patch, + input = Input, + counters = counters, + textBox = textBox, + choiceBox = choiceBox, + visible = function() + if Ui then + return Ui.bottomUIVisible() + end + return battle:bottomUIVisible() + end, + inherited = function(state, query) + if Ui then + return Ui.bottomUIVisible(state, query) + end + return visibility.bottomVisible(state, query) + end, + draw = function() + for key in pairs(counters) do + counters[key] = 0 + end + drawBottom() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/battle_bottom_ui/worker.lua b/tests/battle_bottom_ui/worker.lua new file mode 100644 index 000000000..844290d40 --- /dev/null +++ b/tests/battle_bottom_ui/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/battle_bottom_ui/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_bottom_ui/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.bottom_ui_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native bottom draw") +bare.close() +print("BATTLE_BOTTOM_UI_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.bottom_ui_visible contract") diff --git a/tests/engine/battle_bottom_ui.lua b/tests/engine/battle_bottom_ui.lua new file mode 100644 index 000000000..ab44a6a05 --- /dev/null +++ b/tests/engine/battle_bottom_ui.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/battle_bottom_ui/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/battle_bottom_ui/cases.lua").run(f) +f.close() +f.T.finish("portable battle.bottom_ui_visible") diff --git a/tests/engine/battle_bottom_ui_composition.lua b/tests/engine/battle_bottom_ui_composition.lua new file mode 100644 index 000000000..b539665b1 --- /dev/null +++ b/tests/engine/battle_bottom_ui_composition.lua @@ -0,0 +1,132 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawHUDs", function() + mark("hud") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function() + mark("hud") + end) + f.patch(Hud, "drawHPBar", function() + mark("hud") + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawHealthboxes", function() + mark("hud") + return true + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom or 0, 0, version .. "/" .. layout .. " actual compositor omits bottom backing") + for _, part in ipairs({ "field", "actors", "hud" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding bottom") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same bottom gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom or 0, 0, "palette compositor hides bottom") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden native menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native composition") diff --git a/tests/engine/battle_bottom_ui_routing.lua b/tests/engine/battle_bottom_ui_routing.lua new file mode 100644 index 000000000..aa7594d48 --- /dev/null +++ b/tests/engine/battle_bottom_ui_routing.lua @@ -0,0 +1,203 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Cases = dofile("tests/battle_bottom_ui/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local TextBox = require("src.render.TextBox") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, b, g, stack = f.exports, f.battle, f.game, f.stack + T.eq(b.isBattleState, true, "real Gen2 prototype supplies battle classification") + T.eq(rawget(b, "isBattle"), nil, "inheritance does not rely on a fabricated isBattle marker") + local anchors = 0 + g.renderer = { + setUIAnchor = function() + anchors = anchors + 1 + end, + } + local text, choice = f.textBox(), f.choiceBox() + stack.states = { b, text, choice } + e.hide(true) + f.counters.ink = 0 + text:draw() + choice:draw() + T.eq(f.counters.ink, 0, "hidden battle suppresses actual text/choice backings and glyphs") + T.eq(anchors, 0, "hidden overlays do not leave a render anchor") + T.eq(e.seen.state, b, "inherited overlay decision queries native enclosing battle") + e.hide(false) + text:draw() + choice:draw() + T.check(f.counters.ink > 0, "unhidden actual text/choice draws again") + T.check(anchors > 0, "unhidden text resumes native anchoring") + -- A field choice has no battle owner and must never become a hook state. + stack.states = { text, choice } + local polls = e.seen.calls + f.counters.ink = 0 + e.hide(true) + choice:draw() + T.check(f.counters.ink > 0, "field choice remains visible under an active bottom predicate") + T.eq(e.seen.calls, polls, "field choice does not query battle hook") + text:draw() + T.eq(e.seen.state, text, "field text retains its individual predicate contract") + -- A battle above a text state is not its owner. + stack.states = { text, b, choice } + e.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == b then + return false + end + return next(state) + end, 100) + T.eq( + require("src.battle.UIVisibility").bottomVisible(text, true), + true, + "battle above a text state is not inherited" + ) + off() + -- Native hidden YES/NO input still completes its own delayed callback. + local answer + choice = f.choiceBox(function(value) + answer = value + end) + stack.states = { b, text, choice } + e.hide(true) + f.input:keypressed("z") + f.input:step() + polls = e.seen.calls + stack:update(1 / 60) + f.input:keyreleased("z") + f.input:step() + for _ = 1, 40 do + if stack:top() == choice then + stack:update(1 / 60) + end + end + T.eq(answer, true, "hidden native YES/NO confirms through real input and hold frames") + T.eq(stack:top(), text, "hidden choice pops exactly itself") + T.eq(e.seen.calls, polls, "native choice update never queries rendering predicate") + -- Hidden typewriter text remains a live native foreground state. + text = f.textBox() + stack.states = { b, text } + local chars = text.charIndex or 0 + polls = e.seen.calls + for _ = 1, 60 do + stack:update(1 / 60) + end + T.check((text.charIndex or 0) > chars or text.done, "hidden native text continues typing") + T.eq(e.seen.calls, polls, "native typewriter update never queries draw predicate") + -- No menu-row scissor may remain after the corresponding UI is hidden. + local clips = 0 + f.patch(love.graphics, "getScissor", function() + return nil + end) + f.patch(love.graphics, "intersectScissor", function() + clips = clips + 1 + end) + for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do + b.phase = phase + local before = clips + e.hide(false) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before + 1, "visible native move menu clips its rows") + before = clips + e.hide(true) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before, "hidden native move menu removes its own clip") + e.hide(false) + b:drawPicsLayer(0, 0, 0, nil, true) + T.eq(clips, before, "explicit skipMenuClip retains native behavior") + end + b.phase = "contract" + e.hide(false) + Cases.run(f) + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +local originalCall, calls = Runtime.call, 0 +Runtime.call = function(name, ...) + if name == "battle.bottom_ui_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "no-mod runtime") +T.eq(bare.visible(), true, "no-mod default") +T.eq(bare.draw(), true, "no-mod actual bottom draw") +T.eq(calls, 0, "unsubscribed path never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +-- Preserve the pre-existing private claim API while adding the public hook. +local legacy = Fixture.new(".", "gen2", "red", 3, "unified") +local Visibility = require("src.battle.UIVisibility") +Visibility.reset() +T.eq(Visibility.claimBottom(nil, function() end), false, "nil legacy owner rejected") +T.eq(Visibility.claimBottom("bad", false), false, "non-callable legacy claim rejected") +local queried, queryKind +T.eq( + Visibility.claimBottom("legacy_owner", function(state, isTextBox) + queried, queryKind = state, isTextBox + return false + end), + true, + "legacy claim accepted" +) +local polls = legacy.exports.seen.calls +T.eq(legacy.visible(), false, "legacy denial wins over public default true") +T.eq(legacy.exports.seen.calls, polls, "legacy denial bypasses hook") +T.eq(queried, legacy.battle, "legacy claim receives original state") +T.eq(queryKind, true, "legacy predicate receives original kind flag") +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant introspection preserved") +local owners = Visibility.bottomClaimants() +owners[1] = "changed" +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant result is detached") +T.eq( + Visibility.claimBottom("legacy_owner", function() + return true + end), + true, + "legacy owner replacement" +) +legacy.exports.hide(true) +T.eq(legacy.visible(), false, "public hook may still hide after legacy approval") +legacy.exports.hide(false) +T.eq(legacy.visible(), true, "both layers must allow drawing") +T.eq(Visibility.releaseBottom("legacy_owner"), true, "legacy release reports removed owner") +T.eq(Visibility.releaseBottom("legacy_owner"), false, "legacy release is idempotent") +Visibility.claimBottom("throwing", function() + error("intentional legacy claim failure") +end) +T.eq(legacy.visible(), true, "throwing legacy claim fails open") +T.same(Visibility.bottomClaimants(), {}, "throwing legacy claim is retired") +Visibility.claimBottom("reset_me", function() + return false +end) +Visibility.reset() +T.eq(legacy.visible(), true, "legacy reset restores ordinary visibility") +legacy.close() +local files = { + ["mods/broken_bottom/manifest.json"] = [[{"id":"broken_bottom","name":"Broken Bottom", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_bottom/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.bottom_ui_visible", function() return false end) + error("intentional bottom entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_bottom" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "entry rollback removes predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "teardown removes predicate") +failed.release() +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native routing") diff --git a/tools/battle-bottom-ui-contract-check/check.py b/tools/battle-bottom-ui-contract-check/check.py new file mode 100644 index 000000000..cac046c63 --- /dev/null +++ b/tools/battle-bottom-ui-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged bottom UI mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_bottom_ui/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_BOTTOM_UI_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_BOTTOM_UI_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle bottom UI mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_bottom_ui/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 442e7167f9afae6ec0fc74ad867a64245d7cfa4b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:06:31 +0200 Subject: [PATCH 19/86] Add portable battle.status_hud_visible dispatch for GB and GBA --- .github/workflows/status-hud-contract.yml | 49 +++++ docs/modding.md | 8 +- docs/portable-battle-bottom-ui.md | 5 +- docs/portable-status-hud.md | 76 ++++++++ examples/portable_status_hud/main.lua | 16 ++ examples/portable_status_hud/manifest.json | 10 + src/battle/BattleState.lua | 8 +- src/battle/Gen3Battle.lua | 8 +- src/battle/Gen4Battle.lua | 21 +-- src/battle/StatusHUDVisibility.lua | 25 +++ src/battle/WideBattle.lua | 6 +- src/mods/Capabilities.lua | 2 +- tests/engine/mod_supports.lua | 4 +- tests/engine/status_hud.lua | 5 + tests/engine/status_hud_composition.lua | 138 ++++++++++++++ tests/engine/status_hud_renderers.lua | 137 ++++++++++++++ tests/status_hud/cases.lua | 114 ++++++++++++ tests/status_hud/fixture.lua | 201 +++++++++++++++++++++ tests/status_hud/worker.lua | 13 ++ tools/status-hud-contract-check/check.py | 58 ++++++ 20 files changed, 871 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/status-hud-contract.yml create mode 100644 docs/portable-status-hud.md create mode 100644 examples/portable_status_hud/main.lua create mode 100644 examples/portable_status_hud/manifest.json create mode 100644 src/battle/StatusHUDVisibility.lua create mode 100644 tests/engine/status_hud.lua create mode 100644 tests/engine/status_hud_composition.lua create mode 100644 tests/engine/status_hud_renderers.lua create mode 100644 tests/status_hud/cases.lua create mode 100644 tests/status_hud/fixture.lua create mode 100644 tests/status_hud/worker.lua create mode 100644 tools/status-hud-contract-check/check.py diff --git a/.github/workflows/status-hud-contract.yml b/.github/workflows/status-hud-contract.yml new file mode 100644 index 000000000..d6c8f6c31 --- /dev/null +++ b/.github/workflows/status-hud-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.status_hud_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: status-hud-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check status HUD and native battle composition + run: | + luajit tests/engine/status_hud.lua + luajit tests/engine/status_hud_composition.lua + luajit tests/engine/status_hud_renderers.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/status-hud-contract + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/status-hud-contract/api1-native.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/status-hud-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/status-hud-contract/api2-unified.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/status-hud-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: status-hud-contract + path: /tmp/status-hud-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 3cb3f8ba3..181abb341 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -61,7 +61,7 @@ return `nil, "map carries no elevation"`. Callback availability follows Gen2Recomped's actual implementation, rather than Gen1Recomp's newer catalog. For example, `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 +Gen 1–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 @@ -76,6 +76,12 @@ 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). +## Battle status HUD visibility + +The [portable battle status HUD contract](portable-status-hud.md) documents +`battle.status_hud_visible(next, state)` for GB/GBA status panels and DS +healthboxes, independently of battle text/menu drawing and native logic. + ## Battle bottom UI visibility The [portable battle bottom UI contract](portable-battle-bottom-ui.md) documents diff --git a/docs/portable-battle-bottom-ui.md b/docs/portable-battle-bottom-ui.md index 59d89326b..004aa9de3 100644 --- a/docs/portable-battle-bottom-ui.md +++ b/docs/portable-battle-bottom-ui.md @@ -48,9 +48,8 @@ A battle above an overlay is not its owner. The predicate does not remove states, consume input or pause logic. Typewriter updates and delayed YES/NO confirmation remain native. It does not modify existing rendering-driven presentation counters or their timing when a drawer -is skipped. `battle.status_hud_visible` remains a separate surface with its -existing generation coverage; this change does not add missing GB/GBA HUD -producers. `screen.render_visible` may independently hide an entire state. +is skipped. `battle.status_hud_visible` is a [separate status-panel surface](portable-status-hud.md) +with GB/GBA and DS producers. `screen.render_visible` may independently hide an entire state. `mod:supports("hooks", "battle.bottom_ui_visible")` reports the native producer before Game creation. With no subscriber the helper returns true directly; diff --git a/docs/portable-status-hud.md b/docs/portable-status-hud.md new file mode 100644 index 000000000..8312c45d1 --- /dev/null +++ b/docs/portable-status-hud.md @@ -0,0 +1,76 @@ +# Portable battle status HUD visibility + +`battle.status_hud_visible(next, state)` is a synchronous render predicate. +Only the first result being literal `false` hides the native status panels: +name, level, status, HP/EXP bars, numbers and their panel art. `nil`, `true`, +zero, strings and tables leave the panels visible. The default is true. + +```lua +local mod = ... +mod.hooks:wrap("battle.status_hud_visible", function(next, state) + if customBattleUIOwnsStatus(state) then return false end + return next(state) +end) +``` + +The argument is the original opaque native battle owner, with no extra +arguments. API 1/2 retain their native callback format; unified API 2 and +API 3 deliberately keep this hook's owner opaque, too. This hook does not +provide a portable battle record: use the public battle snapshot for that. +Predicates should read state and return a decision; renderers can query them +repeatedly, so the callback must not advance simulation or consume input. + +Gen2Recomped dispatches it from classic GB/GBC status rows, the wide GB/GBC +panels, GBA singles and all four doubles panels, and the existing DS healthbox +route. Native GB palette rendering and GBA widened surfaces use the same gate. +The standalone renderers accept partial battle objects. Existing DS custom +`statusHUDVisible` overrides retain their veto and error fallback; the shared +native method invokes the public hook once per query, rather than once in +the method and again in the DS helper. Hidden DS boxes count as handled and +cannot trigger a fallback GB panel. `mod:supports("hooks", +"battle.status_hud_visible")` reports the producer on all supported hosts. + +This does not hide the battlefield, battlers, effects, intro party summaries, +replacement party rows or bottom text/menu layer in Gen2Recomped. The latter +has its own [bottom UI predicate](portable-battle-bottom-ui.md). Input, +updates, HP/EXP presentation state and battle ownership remain native. +Legacy overrides, hook priorities, unsubscribe, owner cleanup and failure +fallback still apply. With no subscriber the default path returns true +without allocating a per-query closure or payload. + +## Reproducible contracts + +`examples/portable_status_hud` is the unchanged consumer loaded by both +engines. The worker uses each engine's real Loader, hook bus and native +predicate/renderer, from that engine's working directory. The GB/GBC test +spies on HUD leaf drawing; the Gen1 GBA worker uses its real Ui compositor +with a healthbox leaf spy. The pinned reference's unmodified native GBA suite +also tests actual healthbox art, text, singles/doubles and Oak relighting. +The reference tree must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/status_hud.lua +luajit tests/engine/status_hud_renderers.lua +luajit tests/engine/status_hud_composition.lua +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The cross-engine matrix covers Red, Gold, FireRed and Emerald with API 1, +native/unified API 2 and unified API 3. Gen1 ignores API-2 `callback_format`; +that row compares its native API-2 callback to Gen2's unified callback, using +the same consumer. Observations check native/hidden/restored/unsubscribed +status drawing, false-only semantics, identity, forwarding, priorities, +failure recovery, API-3 cached continuation and owner cleanup. + +Gen2-only tests cover all 15 hosts, both layout options, palette composition, +GBA native panel art and doubles, independent bottom UI, unchanged menu input, +logic clocks, DS delegation and legacy overrides. These are ROM-free render +routing/composition checks, not pixel goldens or imported-ROM playthroughs. +Gen1 Gold's native HUD also owns its party-ball rows and hides those with +its status predicate; Gen2 keeps its existing separate party-row ownership. +The shared observations intentionally compare status panels, not identical +pixels or generation-specific intro presentation. diff --git a/examples/portable_status_hud/main.lua b/examples/portable_status_hud/main.lua new file mode 100644 index 000000000..d7dc8ea97 --- /dev/null +++ b/examples/portable_status_hud/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native status panels without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.status_hud_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_status_hud/manifest.json b/examples/portable_status_hud/manifest.json new file mode 100644 index 000000000..be7bdee7b --- /dev/null +++ b/examples/portable_status_hud/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_status_hud", + "name": "Portable Status HUD", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3d2dc912d..f3a59f20c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -25,6 +25,7 @@ local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") local UIVisibility = require("src.battle.UIVisibility") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -578,6 +579,8 @@ function BattleState:uiSize() return 160, 144 end +BattleState.statusHUDVisible = StatusHUDVisibility.visible + function BattleState:bottomUIVisible() return UIVisibility.bottomVisible(self, true) end @@ -12240,6 +12243,7 @@ function BattleState:drawHUDs(slide) -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill -- double-applies and the zone shade shader maps the whole bar to black (#229). local grayFill = self:colorMode() + local showStatus = self:statusHUDVisible() local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 @@ -12249,7 +12253,7 @@ function BattleState:drawHUDs(slide) -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) - if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + if showStatus and self.enemy and not self.showEnemyTrainer and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), @@ -12336,7 +12340,7 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() - if self.player and not hidePlayer and not self.showPlayerBack + if showStatus and self.player and not hidePlayer and not self.showPlayerBack and slide == 0 then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..d7d19462b 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -42,6 +42,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") local GameVersion = require("src.core.GameVersion") @@ -1440,17 +1441,18 @@ local function healthboxAt(battle, key, panel, fx, fy) end local function drawHUDs(battle, slide) + local showStatus = StatusHUDVisibility.forBattle(battle) local record = hudRecord(battle) local images = record and record.images or {} local double = battle.isDouble and battle:isDouble() - if not double and battle.enemy and not battle.showEnemyTrainer + if showStatus and not double and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then local x, y = healthboxAt(battle, "singlesOpponent", hudImage(images.opponent), 8, 8) drawStatusPanel(battle, battle.enemy, x, y, false) end - if not double and not battle.safari and battle.player and not battle.demo + if showStatus and not double and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then local x, y = healthboxAt(battle, "singlesPlayer", hudImage(images.player), 112, 64) @@ -1467,7 +1469,7 @@ local function drawHUDs(battle, slide) -- The player's doubles panel is its own art too -- shorter, and with no -- EXP bar, which is why the cartridge ships five healthbox blobs and not -- two. - if double and slide == 0 then + if showStatus and double and slide == 0 then local pairs_ = { { pos = 0, key = "playerLeft", img = images.player, player = true }, { pos = 2, key = "playerRight", img = images.playerDoubles diff --git a/src/battle/Gen4Battle.lua b/src/battle/Gen4Battle.lua index 196cee404..4ba9bac09 100644 --- a/src/battle/Gen4Battle.lua +++ b/src/battle/Gen4Battle.lua @@ -1827,25 +1827,8 @@ function Gen4Battle.drawHealthboxIcons(battle, key, battler, boxX, boxY, side) return true end --- A mod that owns the battle HUD (Colosseum UI, realtime host) already --- answers `battle.status_hud_visible`. Gen 1/2 HP rows go through --- BattleState:drawHUDs, which those mods wrap. Sinnoh paints Platinum's --- healthboxes HERE instead, so that wrap never ran and the native bars --- stayed on screen over the Colosseum HUD. Returning true (boxes "drawn") --- is what keeps Gen4Battle.draw from falling through to the Game Boy panels. -local function statusHudVisible(battle) - if type(battle) == "table" and type(battle.statusHUDVisible) == "function" then - local ok, vis = pcall(battle.statusHUDVisible, battle) - if ok and vis == false then return false end - end - local okR, Runtime = pcall(require, "src.mods.Runtime") - if okR and Runtime and Runtime.wantsHook and Runtime.wantsHook("battle.status_hud_visible") then - local vis = Runtime.call("battle.status_hud_visible", - function() return true end, battle) - if vis == false then return false end - end - return true -end +-- A hidden DS healthbox counts as handled: do not fall back to GB panels. +local statusHudVisible = require("src.battle.StatusHUDVisibility").forBattle -- drawHealthboxes(battle) -> true when BOTH were drawn -- diff --git a/src/battle/StatusHUDVisibility.lua b/src/battle/StatusHUDVisibility.lua new file mode 100644 index 000000000..37c64aec0 --- /dev/null +++ b/src/battle/StatusHUDVisibility.lua @@ -0,0 +1,25 @@ +-- One native predicate for GB/GBA and DS healthboxes. Only literal false hides. +local Runtime = require("src.mods.Runtime") +local M = {} +local function defaultVisible() + return true +end +function M.visible(battle) + if not Runtime.wantsHook("battle.status_hud_visible") then + return true + end + return Runtime.call("battle.status_hud_visible", defaultVisible, battle) ~= false +end +-- Standalone renderers also accept partial battle fixtures and legacy overrides. +-- The native method is M.visible itself, so it must not dispatch the hook twice. +function M.forBattle(battle) + local method = type(battle) == "table" and battle.statusHUDVisible + if type(method) == "function" and method ~= M.visible then + local ok, visible = pcall(method, battle) + if ok and visible == false then + return false + end + end + return M.visible(battle) +end +return M diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index eb8e263cb..e280d8f1f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -15,6 +15,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local UIVisibility = require("src.battle.UIVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") @@ -225,7 +226,8 @@ local function drawIntroBalls(battle) end local function drawHUDs(battle, slide) - if battle.enemy and not battle.showEnemyTrainer + local showStatus = StatusHUDVisibility.forBattle(battle) + if showStatus and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then drawStatusPanel(battle, battle.enemy, 0, 0, false) @@ -236,7 +238,7 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). - if not battle.safari and battle.player and not battle.demo + if showStatus and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then -- WHERE THE PLAYER'S BLOCK SITS. The framed panel was fifteen tiles -- wide and filled the corner; the Game Boy block is only four tiles plus diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 716524749..31211e4ce 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -31,7 +31,7 @@ add("hooks", "12345", [[ -- 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", "12345", "battle.status_hud_visible") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 7ad2d3214..edb226283 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,8 +170,8 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo 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.status_hud_visible'), true, + version .. ': native GB/GBA/DS 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, diff --git a/tests/engine/status_hud.lua b/tests/engine/status_hud.lua new file mode 100644 index 000000000..be15c53c8 --- /dev/null +++ b/tests/engine/status_hud.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/status_hud/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/status_hud/cases.lua").run(f) +f.close() +f.T.finish("portable battle.status_hud_visible") diff --git a/tests/engine/status_hud_composition.lua b/tests/engine/status_hud_composition.lua new file mode 100644 index 000000000..874d3d2e2 --- /dev/null +++ b/tests/engine/status_hud_composition.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + f.patch(Font, "draw", function(_, _, y) + if type(y) == "number" and y < 104 then + mark("hud") + end + return 16 + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom, native.bottom, "status predicate preserves native bottom backing") + T.check((native.hud or 0) > 0, "native status text exists") + T.eq(hidden.hud or 0, 0, "actual compositor omits status text") + for _, part in ipairs({ "field", "actors" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding status") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same status gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom, visibleColor.bottom, "palette compositor preserves bottom") + T.check((visibleColor.hud or 0) > 0, "palette compositor has status text") + T.eq(hiddenColor.hud or 0, 0, "palette compositor hides status text") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Bottom UI and status predicates remain independent in both directions. + f.exports.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function() + return false + end) + local bottomHidden = frame(false) + T.eq(bottomHidden.bottom or 0, 0, "bottom predicate independently hides menu") + T.eq(bottomHidden.hud, native.hud, "bottom predicate preserves native status") + off() + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden HUD menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native composition") diff --git a/tests/engine/status_hud_renderers.lua b/tests/engine/status_hud_renderers.lua new file mode 100644 index 000000000..3f450e709 --- /dev/null +++ b/tests/engine/status_hud_renderers.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Visibility = require("src.battle.StatusHUDVisibility") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +local G4 = require("src.battle.Gen4Battle") +local G5 = require("src.battle.Gen5BattleHud") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local b, e = f.battle, f.exports + T.eq(f.public:supports("hooks", "battle.status_hud_visible"), true, version .. " capability") + for _, owner in ipairs({ b, {} }) do + local calls = e.seen.calls + T.eq(Visibility.forBattle(owner), true, "native or partial owner visible") + T.eq(e.seen.calls, calls + 1, "one hook dispatch, including DS native method") + T.eq(e.seen.state, owner, "same opaque owner") + e.hide(true) + T.eq(Visibility.forBattle(owner), false, "native or partial owner hidden") + e.hide(false) + end + b.statusHUDVisible = function() + return false + end + local calls = e.seen.calls + T.eq(Visibility.forBattle(b), false, "legacy override veto") + T.eq(e.seen.calls, calls, "legacy veto precedes hook") + b.statusHUDVisible = function() + return true + end + e.hide(true) + T.eq(Visibility.forBattle(b), false, "hook veto survives legacy true") + b.statusHUDVisible = function() + error("legacy failure") + end + T.eq(Visibility.forBattle(b), false, "legacy failure retains public predicate") + b.statusHUDVisible = Battle.statusHUDVisible + e.hide(false) + if Version.isGen3() then + local Assets = require("src.render.Assets") + local image = love.graphics.newImage("contract-healthbox.png") + local summaryImage = love.graphics.newImage("contract-summary.png") + f.patch(Assets, "image", function(path) + return path == "summary" and summaryImage or image + end) + b.data.constants.gen3BattleHud = { + images = { + player = "panel", + opponent = "panel", + playerDoubles = "panel", + opponentDoubles = "panel", + }, + } + b.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + local panels, summaries = 0, 0 + f.patch(love.graphics, "draw", function(img) + if img == image then + panels = panels + 1 + end + if img == summaryImage then + summaries = summaries + 1 + end + end) + for _, double in ipairs({ false, true }) do + b.isDouble = function() + return double + end + b.battlerAt = function(self, pos) + return pos % 2 == 0 and self.player or self.enemy + end + panels = 0 + G3.drawHUDs(b, 0) + T.eq(panels, double and 4 or 2, "native GBA art for every active panel") + panels = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.eq(panels, 0, "native GBA single/double art entirely hidden") + e.hide(false) + end + -- Intro party summaries are independent of status panels. + b.data.constants.gen3BattleHud.partySummary = { + bar = "summary", + balls = "summary", + count = 1, + icons = { healthy = 0, empty = 0 }, + player = { barX = 0, barY = 0, ballX = 0, ballY = 0 }, + } + b.introBalls, b.playerParty = true, { b.player.mon } + summaries = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.check(summaries > 0, "hidden GBA status preserves party summary") + elseif Version.generation() >= 4 then + local delegated = 0 + f.patch(G5, "active", function() + return true + end) + f.patch(G5, "drawHealthboxes", function() + delegated = delegated + 1 + return true + end) + local calls = e.seen.calls + T.eq(G4.drawHealthboxes(b), true, "existing DS delegation handled") + T.eq(e.seen.calls, calls + 1, "DS true dispatches once") + T.eq(delegated, 1, "native DS healthbox delegate executes") + e.hide(true) + T.eq(G4.drawHealthboxes(b), true, "hidden DS handled, no GB fallback") + T.eq(delegated, 1, "hidden DS skips healthbox delegate") + else + local balls = 0 + b.introBalls, b.playerParty = true, { b.player.mon } + b.drawBallRow = function() + balls = balls + 1 + end + e.hide(true) + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves intro party row") + b.introBalls, b.showEnemyBalls, b.enemyParty = false, true, { b.enemy.mon } + balls = 0 + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves replacement party row") + end + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native renderers") diff --git a/tests/status_hud/cases.lua b/tests/status_hud/cases.lua new file mode 100644 index 000000000..4dbf8c665 --- /dev/null +++ b/tests/status_hud/cases.lua @@ -0,0 +1,114 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native HUD draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.status_hud_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden HUD leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local off + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("battle.status_hud_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.status_hud_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + e.hide(false) + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.status_hud_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.status_hud_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.status_hud_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.status_hud_visible", function(next) + if after then + next() + end + error("intentional status HUD predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.status_hud_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.status_hud_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.status_hud_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_status_hud") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/status_hud/fixture.lua b/tests/status_hud/fixture.lua new file mode 100644 index 000000000..5ad707119 --- /dev/null +++ b/tests/status_hud/fixture.lua @@ -0,0 +1,201 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files, Json = {}, require("src.link.Json") + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_status_hud/" .. path, "rb")) + files["mods/portable_status_hud/" .. path] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_status_hud/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_status_hud/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_status_hud" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores, counters = {}, { ink = 0 } + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function ink() + counters.ink = counters.ink + 1 + return 16 + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local b, Ui, draw + if backend == "gen1" and generation == 3 then + patch(require("src.core.game3.battle.pic_coords"), "active", function() + return { front = {}, back = {}, elev = {} } + end) + local State = require("src.core.game3.battle.state") + local function mon(species) + return { + species = species, + nickname = "CONTRACT", + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + item = 0, + ability = 0, + } + end + b = State.new({ playerParty = { mon(4) }, foeParty = { mon(16) }, wild = true }) + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(b, { options = {} }) + local Anim = require("src.core.game3.battle.anim") + Anim.reset({ headless = true }) + for id = 0, 1 do + Anim.setShown(id, State.battler(b, id)) + end + -- Spy at the healthbox leaf, leaving the native GBA compositor/predicate intact. + patch(require("src.core.game3.battle.healthbox"), "draw", ink) + draw = function() + Ui.draw(240, 160, true) + end + else + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local function side() + return { + name = "CONTRACT", + mon = { species = "FIXMON_A", level = 20, hp = 60, stats = { hp = 60 } }, + shownHP = 60, + } + end + b = setmetatable( + { + game = game, + data = data, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + player = side(), + enemy = side(), + ballRows = {}, + showEnemyHud = true, + showPlayerHud = true, + }, + { __index = class } + ) + local Font = require("src.render.Font") + for _, name in ipairs({ "draw", "drawCode", "drawBox" }) do + patch(Font, name, ink) + end + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "printThrough", ink) + patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = ink, + drawPlayerFrame = ink, + drawExpBar = ink, + } + b.activeMon = function(self, side) + return self[side].mon + end + b.name = function() + return "CONTRACT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() + return nil + end + b.genderSymbol = function() + return nil + end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return 60 + end + b.drawHpBar = ink + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local exports = result.loader.exports.portable_status_hud + return { + T = T, + result = result, + game = game, + battle = b, + stack = stack, + input = Input, + api = api, + generation = generation, + exports = exports, + public = exports and exports.api, + patch = patch, + visible = function() + if Ui then + return Ui.statusHUDVisible() + end + return b:statusHUDVisible() + end, + draw = function() + counters.ink = 0 + draw() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/status_hud/worker.lua b/tests/status_hud/worker.lua new file mode 100644 index 000000000..31ff676a8 --- /dev/null +++ b/tests/status_hud/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/status_hud/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/status_hud/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.status_hud_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native HUD draw") +bare.close() +print("STATUS_HUD_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.status_hud_visible contract") diff --git a/tools/status-hud-contract-check/check.py b/tools/status-hud-contract-check/check.py new file mode 100644 index 000000000..def52b3db --- /dev/null +++ b/tools/status-hud-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged status HUD mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/status_hud/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("STATUS_HUD_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("STATUS_HUD_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle status HUD mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_status_hud/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From eae79aaaa0bb7048cc9c4d8e4b7b8919a9e908f1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:08:06 +0200 Subject: [PATCH 20/86] Normalize contract fixture formatting --- tests/status_hud/fixture.lua | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/status_hud/fixture.lua b/tests/status_hud/fixture.lua index 5ad707119..8123de1a3 100644 --- a/tests/status_hud/fixture.lua +++ b/tests/status_hud/fixture.lua @@ -87,22 +87,19 @@ function M.new(root, backend, version, api, format, noMod) shownHP = 60, } end - b = setmetatable( - { - game = game, - data = data, - phase = "contract", - frame = 0, - shown = {}, - fx = {}, - player = side(), - enemy = side(), - ballRows = {}, - showEnemyHud = true, - showPlayerHud = true, - }, - { __index = class } - ) + b = setmetatable({ + game = game, + data = data, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + player = side(), + enemy = side(), + ballRows = {}, + showEnemyHud = true, + showPlayerHud = true, + }, { __index = class }) local Font = require("src.render.Font") for _, name in ipairs({ "draw", "drawCode", "drawBox" }) do patch(Font, name, ink) From 95d6c7555cdcc99675a01f5cd716e40496ddc9dd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:00:11 +0200 Subject: [PATCH 21/86] Add portable world.talk dispatch and cross-engine NPC contracts --- .github/workflows/world-talk-contract.yml | 51 ++++++ docs/api3-portability.md | 2 +- docs/modding.md | 6 + docs/portable-world-talk.md | 95 ++++++++++ examples/portable_world_talk/main.lua | 18 ++ examples/portable_world_talk/manifest.json | 10 ++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- src/world/OverworldController.lua | 8 +- tests/engine/mod_supports.lua | 1 + tests/engine/world_talk.lua | 5 + tests/engine/world_talk_dialogue.lua | 87 ++++++++++ tests/engine/world_talk_routing.lua | 86 +++++++++ tests/world_talk/cases.lua | 118 +++++++++++++ tests/world_talk/fixture.lua | 193 +++++++++++++++++++++ tests/world_talk/worker.lua | 32 ++++ tools/world-talk-contract-check/check.py | 62 +++++++ 17 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/world-talk-contract.yml create mode 100644 docs/portable-world-talk.md create mode 100644 examples/portable_world_talk/main.lua create mode 100644 examples/portable_world_talk/manifest.json create mode 100644 tests/engine/world_talk.lua create mode 100644 tests/engine/world_talk_dialogue.lua create mode 100644 tests/engine/world_talk_routing.lua create mode 100644 tests/world_talk/cases.lua create mode 100644 tests/world_talk/fixture.lua create mode 100644 tests/world_talk/worker.lua create mode 100644 tools/world-talk-contract-check/check.py diff --git a/.github/workflows/world-talk-contract.yml b/.github/workflows/world-talk-contract.yml new file mode 100644 index 000000000..d364dcbb2 --- /dev/null +++ b/.github/workflows/world-talk-contract.yml @@ -0,0 +1,51 @@ +name: portable world.talk contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: world-talk-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native NPC dispatch and dialogue + run: | + luajit tests/engine/world_talk.lua + luajit tests/engine/world_talk_routing.lua + luajit tests/engine/world_talk_dialogue.lua + - name: Validate pinned native Gen1 NPC contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/world_talk.lua + luajit tests/modkit/cases/unified_npcs.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/world-talk-contract + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/world-talk-contract/api1-native.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/world-talk-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/world-talk-contract/api2-unified.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/world-talk-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: world-talk-contract + path: /tmp/world-talk-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 52f592613..bf6315cfd 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ 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 +`trainer.before_battle` 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. diff --git a/docs/modding.md b/docs/modding.md index 181abb341..455f2886f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ 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). +## NPC conversation hook + +The [portable NPC talk contract](portable-world-talk.md) documents +`world.talk(next, owner, target)`: claim a native NPC conversation or forward +to the engine, with explicit handle and Gen1 Gold producer boundaries. + ## Battle status HUD visibility The [portable battle status HUD contract](portable-status-hud.md) documents diff --git a/docs/portable-world-talk.md b/docs/portable-world-talk.md new file mode 100644 index 000000000..23c9b78d6 --- /dev/null +++ b/docs/portable-world-talk.md @@ -0,0 +1,95 @@ +# Portable NPC conversation hook + +`world.talk(next, owner, target)` runs when native A-button interaction resolves +a talkable NPC, before the native talk dispatcher acquires its freeze, faces +the actor or starts its dialogue/script. Call `next()` or `next(owner, target)` +to continue. Omit `next` to answer instead. A return value, including `false` +or `nil`, does not request fallthrough; this is a dispatch hook, not a boolean +visibility predicate. + +```lua +local mod = ... +mod.hooks:wrap("world.talk", function(next, owner, target) + if target and target.id == myRuntimeNpcId then + -- Start this mod's public dialogue/script flow here. + return + end + return next(owner, target) +end) +``` + +`examples/portable_world_talk` is an unchanged consumer loaded by both engines. +It can claim a selected actor ID, observe the original handles and unsubscribe. +It does not patch engine classes or require internal modules. + +## Native boundaries + +Both arguments are original opaque engine handles in API 1, native/unified +API 2 and API 3. They are not canonical NPC or Game records. Gen2Recomped's +owner is its OverworldController; Gen1's classic owner is its overworld, while +its separate native GBA field supplies Game. Use public mod APIs for game +operations and do not depend on a common owner layout. + +Gen2's shared producer covers all 15 supported hosts, including native GBA +and DS worlds. It retains the existing counter lookup, hidden/moving/blocker +and GBA elevation rules before dispatch. Special Pikachu/HGSS follower talk +keeps its separate native route. Signs, field tiles, ordinary script calls +to `talkTo` and trainer sight approaches are not new hook producers. +`world.interacted` still reports the original resolved actor once for the NPC +interaction, including when a mod claims it. This hook does not replace the +notification or the separate trainer-start contract. + +The default continuation matches Gen1's classic void `talkTo` dispatch. +Explicit `next` arguments reach downstream hooks unchanged. Redirecting the +native conversation to a different actor is not a portable promise: Gen1's +GBA script continuation closes over the originally resolved actor. Hook +priorities, unsubscribe, entry rollback, owner cleanup and failure recovery +retain their bus contracts. A failing wrapper before forwarding falls through; +a failure after forwarding does not repeat the completed native conversation. +Native talk errors propagate without replay. + +API 3 caches repeated continuation calls on both engines. Gen2 rejects a +continuation after its callback returns; pinned Gen1 can return the cached +result later. A portable mod calls `next` synchronously and does not retain it. +API 1/2 keep their existing native/unified continuation rules. + +The pinned Gen1 Gold engine **does not call `world.talk` from its native +`src.world.gen2.World:interactBody`**, despite the name being accepted by its +catalog. The shared checker exercises this real native boundary and expects +no callback there. Gen2 Gold does execute the common contract. The checker +does not substitute the classic Gen1 controller for Gold or modify reference +sources to force a match. + +## Verification + +```sh +luajit tests/engine/world_talk.lua +luajit tests/engine/world_talk_routing.lua +luajit tests/engine/world_talk_dialogue.lua +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The checker requires a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Each worker runs from its own +engine's working directory, using the real Loader, hook bus and native +interaction method. Red, Yellow, FireRed and Emerald have matching shared +observations; Gold has the explicit producer boundary above. Gen1 ignores +API-2 `callback_format`, so that row compares native Gen1 API 2 against unified +Gen2 API 2 with the same consumer. + +The common fixture spies on the native conversation/script destination after +real object lookup. Gen1 GBA uses its actual event-object pool, counter lookup, +Field.interact and freeze/face calls. Gold uses its own native interactBody. +Gen2 routing covers all 15 hosts and four callback modes; a separate native +runtime-NPC test exercises real spawn, NPC construction, talkTo, TextBox +confirmation, freeze release, unsubscribe and removal. It binds the same +module-local Game/script dependencies normally installed by enter(), avoiding +an imported map/GPU. Its spawn uses the native public WorldAPI, independently +of the separately stacked API-3 NPC adapters. + +The workflow also runs Gen1's unmodified `world_talk` and `unified_npcs` suites, +including real GBA runtime dialogue/registered scripts and cancellation. These +are ROM-free behavioral tests, not imported-ROM playthroughs or pixel goldens. diff --git a/examples/portable_world_talk/main.lua b/examples/portable_world_talk/main.lua new file mode 100644 index 000000000..1e129b1a1 --- /dev/null +++ b/examples/portable_world_talk/main.lua @@ -0,0 +1,18 @@ +-- A portable mod can answer its own NPC without replacing engine methods. +local mod = ... +local out = mod.exports +local claimedId +out.api, out.seen = mod, { calls = 0, claimed = 0 } +function out.claim(id) + claimedId = id +end +out.stop = mod.hooks:wrap("world.talk", function(next, owner, target, ...) + local seen = out.seen + seen.calls = seen.calls + 1 + seen.owner, seen.target, seen.extra = owner, target, select("#", ...) + if claimedId and target and target.id == claimedId then + seen.claimed = seen.claimed + 1 + return "claimed" + end + return next(owner, target) +end) diff --git a/examples/portable_world_talk/manifest.json b/examples/portable_world_talk/manifest.json new file mode 100644 index 000000000..c3212a247 --- /dev/null +++ b/examples/portable_world_talk/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_world_talk", + "name": "Portable NPC Talk", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 31211e4ce..d25eece43 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -22,7 +22,7 @@ add("hooks", "12345", [[ 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 screen.render_visible script.command trainer.party transition.style ui.list_menu ui.options.rows - ui.title_menu.items warp.destination world.tod zoom.range + ui.title_menu.items warp.destination world.talk 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. diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 3ba666cd1..7b6c34fb9 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -23,7 +23,7 @@ C.hooks = names([[ music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible 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 + ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) C.events = names([[ assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..65cc1d75c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -7007,6 +7007,12 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- The continuation receives the original opaque world and NPC handles. +-- Keep it outside interact so an unhooked A press allocates no closure. +local function vanillaTalk(world, target) + world:talkTo(target) +end + -- what the A press resolved to, for world.interacted's listeners -- A LINE THE CARTRIDGE ITSELF PRINTS, named by the import rather than typed -- here. `constants.gen3` holds a TEXT_ key and the text pool holds the @@ -7197,7 +7203,7 @@ function OverworldState:interact() -- rules, answered mid-step like the Pikachu above if not npc.hidden then require("src.world.HgssFollowMon").talk(Game, self, npc) end elseif not npc.moving then - self:talkTo(npc) + Runtime.call("world.talk", vanillaTalk, self, npc) end interacted(self, fx, fy, "npc", npc) return diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index edb226283..8837ecca4 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,6 +170,7 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo for _, version in ipairs(GameVersion.ORDER) do local mod = probe(version) local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'world.talk'), true, version .. ': native NPC talk seam') T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, version .. ': native GB/GBA/DS HUD seam') T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, diff --git a/tests/engine/world_talk.lua b/tests/engine/world_talk.lua new file mode 100644 index 000000000..0a084d32e --- /dev/null +++ b/tests/engine/world_talk.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/world_talk/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/world_talk/cases.lua").run(f) +f.close() +f.T.finish("portable world.talk") diff --git a/tests/engine/world_talk_dialogue.lua b/tests/engine/world_talk_dialogue.lua new file mode 100644 index 000000000..d5b733e26 --- /dev/null +++ b/tests/engine/world_talk_dialogue.lua @@ -0,0 +1,87 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/world_talk/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Stack = require("src.core.StateStack") +local NPC = require("src.world.NPC") +local TextBox = require("src.render.TextBox") +local originalVersion = Version.get() +-- Bind the same module-local dependencies that enter() installs, without +-- loading an imported map/GPU. Neither interact nor talkTo is replaced. +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game, ow = f.game, f.world + game.stack = setmetatable({}, { __index = Stack }) + game.stack:init() + game.stack.states = { ow } + f.result.loader.game = game + game.world, game.overworld = ow, ow + game.save.flags, game.save.party = {}, {} + local mapId = ow.map.id + ow.map.def = game.data.maps[mapId] + ow.map.def.objects = {} + ow.map.def.label = mapId + ow.entities, ow.npcPool, ow.npcs = {}, {}, {} + local restoreGame = bind(World.enter, "Game", game) + local restoreScripts = bind(World.enter, "mapScripts", require("src.script.MapScripts")) + -- Exercise the native public world adapter and real runtime NPC constructor. + local api = require("src.world.WorldAPI").new(game, "portable_world_talk") + local id, why = api:spawnNpc(mapId, { + name = "GUIDE", + x = 4, + y = 5, + sprite = "SPRITE_YOUNGSTER", + movement = "STAY", + facing = "UP", + text = "A native conversation.", + }) + T.check(type(id) == "string", version .. " runtime spawn: " .. tostring(why)) + local npc = ow.npcs[1] + assert(npc, "native runtime actor materialized") + T.eq(getmetatable(npc), NPC, "real NPC constructor") + T.eq(npc.def.owner, "portable_world_talk", "runtime ownership preserved") + ow.talkTo = nil + f.exports.claim(id) + ow:interact() + T.eq(game.stack:top(), ow, "claimed runtime actor opens no native dialogue") + T.eq(npc.frozen, false, "claim does not acquire native freeze") + f.exports.claim(nil) + ow:interact() + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "fallthrough opens actual native TextBox") + T.eq(npc.frozen, true, "native conversation freezes actor") + T.eq(npc.facing, "down", "native conversation faces player") + box.done = true + box:advance() + T.eq(game.stack:top(), ow, "native confirmation closes dialogue") + T.eq(npc.frozen, false, "native completion unfreezes actor") + f.exports.stop() + ow:interact() + box = game.stack:top() + T.eq(getmetatable(box), TextBox, "unsubscribe preserves native dialogue") + box.done = true + box:advance() + T.eq(api:removeNpc(id), true, "runtime owner can remove its actor") + T.eq(#ow.npcs, 0, "runtime actor removed from interaction pool") + restoreScripts() + restoreGame() + f.close() +end +Version.set(originalVersion) +T.finish("world.talk native runtime NPC dialogue") diff --git a/tests/engine/world_talk_routing.lua b/tests/engine/world_talk_routing.lua new file mode 100644 index 000000000..ae4f0aa08 --- /dev/null +++ b/tests/engine/world_talk_routing.lua @@ -0,0 +1,86 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/world_talk/fixture.lua") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + dofile("tests/world_talk/cases.lua").run(f) + local probe = f.public.hooks:wrap("world.talk", function(next, ...) + f.exports.seen.calls = f.exports.seen.calls + 1 + return next(...) + end) + -- Preserve the engine's existing guards before the public seam. + for _, guard in ipairs({ "moving", "hidden", "gen5Blocker" }) do + local polls, calls = f.exports.seen.calls, #f.calls + f.patch(f.world.map, "signAtCell", function() + return nil + end) + f.patch(f.world, "tryPcTile", function() + return true + end) + if guard == "gen5Blocker" then + f.npc.def.gen5Blocker = true + else + f.npc[guard] = true + end + f.interact() + T.eq(f.exports.seen.calls, polls, guard .. " never queries talk hook") + T.eq(#f.calls, calls, guard .. " never reaches native talk") + f.npc[guard], f.npc.def.gen5Blocker = false, nil + end + if f.gen == 3 then + f.world.player.elevation, f.npc.elevation = 1, 2 + local polls, calls = f.exports.seen.calls, #f.calls + f.interact() + T.eq(f.exports.seen.calls, polls, "GBA wrong elevation never talks") + T.eq(#f.calls, calls, "GBA wrong elevation keeps native guard") + end + for _, follower in ipairs({ + { "pikachuFollower", "src.world.PikachuFollower" }, + { "hgssFollowMon", "src.world.HgssFollowMon" }, + }) do + local called, polls = 0, f.exports.seen.calls + f.patch(require(follower[2]), "talk", function() + called = called + 1 + end) + f.npc[follower[1]] = true + f.world.player.elevation, f.npc.elevation = nil, nil + f.interact() + T.eq(called, 1, "special follower keeps native dispatch") + T.eq(f.exports.seen.calls, polls, "special follower bypasses general NPC hook") + f.npc[follower[1]] = nil + end + probe() + local returned + f.patch(f.world, "talkTo", function() + return "native private result" + end) + local capture = f.public.hooks:wrap("world.talk", function(next) + returned = next() + return returned + end) + f.interact() + T.eq(returned, nil, "default continuation matches Gen1's void talk contract") + capture() + -- Original talk failures propagate without being retried as a mod error. + f.world.player.elevation, f.npc.elevation = nil, nil + local failures = 0 + f.patch(f.world, "talkTo", function() + failures = failures + 1 + error("native talk failure") + end) + local off = f.public.hooks:wrap("world.talk", function(next) + return next() + end) + local ok, why = pcall(f.interact) + T.eq(ok, false, "native failure propagates") + T.check(tostring(why):find("native talk failure", 1, true) ~= nil, "native failure retained") + T.eq(failures, 1, "native failure is never replayed") + off() + f.close() + end +end +Version.set(originalVersion) +T.finish("world.talk native routing") diff --git a/tests/world_talk/cases.lua b/tests/world_talk/cases.lua new file mode 100644 index 000000000..49898f92c --- /dev/null +++ b/tests/world_talk/cases.lua @@ -0,0 +1,118 @@ +local M = {} +function M.run(f) + local T, e, api = f.T, f.exports, f.public + local rows = {} + local function observe(label, claimed) + e.claim(claimed and f.npc.id or nil) + local before, eventCount = #f.calls, #f.events + f.interact() + local native = #f.calls - before + T.eq(native, claimed and 0 or 1, label .. " native continuation count") + T.eq(#f.events - eventCount, 1, label .. " native interaction event remains") + local event = f.events[#f.events] + T.eq(event.target, f.npc, label .. " event keeps resolved original actor") + T.eq(event.kind, "npc", label .. " event keeps NPC kind") + T.eq(event.mapId, "FIX_ROUTE", label .. " event keeps native map") + T.eq(e.seen.owner, f.owner, label .. " opaque native owner") + T.eq(e.seen.target, f.npc, label .. " original actor identity") + T.eq(e.seen.extra, 0, label .. " no extra arguments") + rows[#rows + 1] = { label = label, native = native } + end + if api.supports then + T.eq(api:supports("hooks", "world.talk"), true, "capability at mod entry") + end + observe("unclaimed", false) + observe("claimed", true) + T.eq(e.seen.claimed, 1, "same consumer can answer its NPC") + f.counter(true) + observe("counter claimed", true) + observe("counter default", false) + f.counter(false) + f.npc.def.runtime = true + observe("runtime claimed", true) + observe("runtime default", false) + f.npc.def.runtime = nil + -- Returning false does not mean fall through: calling next controls dispatch. + local off = api.hooks:wrap("world.talk", function() + return false + end, 100) + local before = #f.calls + f.interact() + T.eq(#f.calls, before, "false without next claims the interaction") + off() + local order = {} + local outer = api.hooks:wrap("world.talk", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("world.talk", function(next, owner, target) + order[#order + 1] = "inner" + T.eq(owner, f.owner, "next() retains owner") + T.eq(target, f.npc, "next() retains actor") + return next() + end, 10) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "chained next reaches native once") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("world.talk", function(next) + if after then + next() + end + error("intentional talk hook failure") + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "failure before/after next does not duplicate native talk") + off() + end + local replacement = { id = "REPLACEMENT" } + off = api.hooks:wrap("world.talk", function(next, owner) + return next(owner, replacement) + end, 100) + f.interact() + T.eq(e.seen.target, replacement, "explicit next arguments reach downstream opaque") + off() + if f.api >= 3 then + local savedNext + off = api.hooks:wrap("world.talk", function(next) + savedNext = next + local value = next() + T.eq(next(), value, "API 3 repeated continuation retains result") + return value + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "API 3 next is cached") + T.eq(pcall(savedNext), f.backend == "gen1", "native continuation lifetime boundary") + T.eq(#f.calls, before + 1, "late continuation never repeats native talk") + off() + end + observe("restored", false) + e.stop() + e.stop() + local polls = e.seen.calls + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "unsubscribe restores native route") + T.eq(e.seen.calls, polls, "unsubscribe idempotent") + api.hooks:wrap("world.talk", function() end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("world.talk", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_talk_owner") + f.result.loader.hooks:removeOwner("portable_world_talk") + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "owner cleanup restores native talk") + T.eq(peerCalls, 1, "another owner's callback survives cleanup") + peer() + return rows +end +return M diff --git a/tests/world_talk/fixture.lua b/tests/world_talk/fixture.lua new file mode 100644 index 000000000..010adea82 --- /dev/null +++ b/tests/world_talk/fixture.lua @@ -0,0 +1,193 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_world_talk/" .. name, "rb")) + files["mods/portable_world_talk/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_world_talk/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_world_talk/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_world_talk/main.lua"] = files["mods/portable_world_talk/main.lua"] + .. '\nerror("intentional talk entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_world_talk" }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores, calls, events, counter = {}, {}, {}, false + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local npc = { + id = "GUIDE", + localId = 1, + cellX = 4, + cellY = 5, + targetX = 4, + targetY = 5, + moving = false, + visible = true, + facing = "down", + def = { scriptKey = "TALK", index = 0 }, + } + local player = { + cellX = 4, + cellY = 6, + facing = "up", + moving = false, + facingCell = function() + return 4, 5 + end, + } + local game = { data = data, save = { version = version, map = "FIX_ROUTE", options = {} } } + local world, owner, interact + if backend == "gen1" and gen == 3 then + local Objects = require("src.core.game3.objects") + local Field = require("src.core.game3.field") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local CollisionStd = require("src.core.game3.scripting.collision_std") + local Space = require("src.core.game3.scripting.space") + game.session = game.save + for key, value in pairs({ + cellX = 4, + cellY = 6, + targetX = 4, + targetY = 6, + facing = "up", + moving = false, + surfing = false, + }) do + patch(Player, key, value) + end + patch(Player, "boulderPush", nil) + patch(Objects, "_byId", { [1] = npc }) + patch(Objects, "_order", { 1 }) + patch(Objects, "_mapId", "FIX_ROUTE") + patch(Field, "running", true) + patch(Field, "_game", game) + patch(Field, "_session", game.save) + patch(Field, "_locks", {}) + patch(Space, "active", true) + patch(Space, "vm", { + ctx = {}, + isRunning = function() + return false + end, + }) + patch(Space, "startScript", function(key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end) + patch(require("src.core.game3.runtime"), "uiBusy", function() + return false + end) + patch(Collision, "cell", function() + return counter and 1 or 0 + end) + patch(CollisionStd, "isCounter", function(cell) + return cell == 1 + end) + owner, world = game, { player = player, npcs = { npc } } + interact = function() + return Field.interact(game) + end + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { id = "FIX_ROUTE" }, + vm = { + running = function() + return false + end, + start = function(_, key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end, + }, + facingObjectCell = function() + return 4, counter and 4 or 5 + end, + }, { __index = World }) + owner = world + interact = function() + return world:interactBody() + end + else + local World = require("src.world.OverworldController") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { + id = "FIX_ROUTE", + def = {}, + isCounterCell = function() + return counter + end, + }, + talkTo = function(_, target) + calls[#calls + 1] = target + end, + }, { __index = World }) + owner = world + interact = function() + return world:interact() + end + end + local off = result.loader.events:on("world.interacted", function(event) + events[#events + 1] = event + end) + local exports = result.loader.exports.portable_world_talk + return { + T = T, + result = result, + exports = exports, + public = exports and exports.api, + game = game, + world = world, + owner = owner, + npc = npc, + calls = calls, + events = events, + api = api, + gen = gen, + backend = backend, + patch = patch, + counter = function(value) + counter = value + npc.cellY, npc.targetY = value and 4 or 5, value and 4 or 5 + end, + interact = interact, + close = function() + off() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/world_talk/worker.lua b/tests/world_talk/worker.lua new file mode 100644 index 000000000..2e421d3ce --- /dev/null +++ b/tests/world_talk/worker.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local Fixture = dofile(root .. "/tests/world_talk/fixture.lua") +local f = Fixture.new(root, backend, version, api, format) +local rows +if backend == "gen1" and f.gen == 2 then + local before = #f.calls + f.interact() + f.T.eq(#f.calls, before + 1, "native Gold conversation runs") + f.T.eq(f.exports.seen.calls, 0, "native Gold has no world.talk producer despite catalog") + rows = { { label = "reference boundary", native = 1 } } +else + rows = dofile(root .. "/tests/world_talk/cases.lua").run(f) +end +f.close() +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, api, format, not failing, failing) + bare.T.eq( + require("src.mods.Runtime").wantsHook("world.talk"), + false, + "no-mod / entry rollback unsubscribed" + ) + local before = #bare.calls + bare.interact() + bare.T.eq(#bare.calls, before + 1, "no-mod / entry rollback keeps native interaction") + if failing then + bare.T.check(#bare.result.errors > 0, "entry error reported") + end + bare.close() +end +print("WORLD_TALK_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable world.talk contract") diff --git a/tools/world-talk-contract-check/check.py b/tools/world-talk-contract-check/check.py new file mode 100644 index 000000000..cab9ec430 --- /dev/null +++ b/tools/world-talk-contract-check/check.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Run one unchanged NPC talk mod through both engines' native NPC interaction paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/world_talk/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("WORLD_TALK_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("WORLD_TALK_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + if version == "gold": + assert observations[0] == [{"label": "reference boundary", "native": 1}] + print("PASS gold: reference has no native producer; Gen2 runs the shared cases") + continue + assert observations[0] == observations[1], f"NPC talk mismatch: {version}" + print(f"PASS {version}: matching native NPC interaction paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_world_talk/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From ca7aed554ba3bb06a26365430358574dd69d2e87 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:39:42 +0200 Subject: [PATCH 22/86] Add portable trainer preparation and battle-local party selection --- .../trainer-preparation-contract.yml | 52 +++ docs/api3-portability.md | 2 +- docs/modding.md | 6 + docs/portable-trainer-preparation.md | 114 +++++++ .../portable_trainer_preparation/main.lua | 21 ++ .../manifest.json | 1 + src/battle/BattleState.lua | 42 ++- src/battle/Gen3AI.lua | 2 +- src/battle/Gen3MoveEffects.lua | 4 +- src/battle/Gen5MoveEffects.lua | 8 +- src/battle/Gen5MoveRules.lua | 2 +- src/battle/PlayerPartyScope.lua | 36 ++ src/battle/TrainerPreparation.lua | 124 +++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- src/script/Commands.lua | 32 +- src/script/ScriptRunner.lua | 25 ++ src/ui/Gen3PartyMenu.lua | 4 +- src/ui/Gen4PartyMenu.lua | 4 +- src/ui/PartyMenu.lua | 2 +- src/world/OverworldController.lua | 36 +- tests/engine/mod_supports.lua | 1 + tests/engine/trainer_party_scope.lua | 68 ++++ tests/engine/trainer_preparation.lua | 21 ++ tests/engine/trainer_preparation_native.lua | 245 ++++++++++++++ tests/trainer_preparation/cases.lua | 87 +++++ tests/trainer_preparation/fixture.lua | 320 ++++++++++++++++++ tests/trainer_preparation/worker.lua | 8 + .../check.py | 58 ++++ 29 files changed, 1294 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/trainer-preparation-contract.yml create mode 100644 docs/portable-trainer-preparation.md create mode 100644 examples/portable_trainer_preparation/main.lua create mode 100644 examples/portable_trainer_preparation/manifest.json create mode 100644 src/battle/PlayerPartyScope.lua create mode 100644 src/battle/TrainerPreparation.lua create mode 100644 tests/engine/trainer_party_scope.lua create mode 100644 tests/engine/trainer_preparation.lua create mode 100644 tests/engine/trainer_preparation_native.lua create mode 100644 tests/trainer_preparation/cases.lua create mode 100644 tests/trainer_preparation/fixture.lua create mode 100644 tests/trainer_preparation/worker.lua create mode 100644 tools/trainer-preparation-contract-check/check.py diff --git a/.github/workflows/trainer-preparation-contract.yml b/.github/workflows/trainer-preparation-contract.yml new file mode 100644 index 000000000..afab720ef --- /dev/null +++ b/.github/workflows/trainer-preparation-contract.yml @@ -0,0 +1,52 @@ +name: portable trainer.before_battle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: trainer-preparation-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native trainer admission, scripts and party scope + run: | + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 trainer contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/trainer_before_battle.lua + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/trainer-preparation-contract + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/trainer-preparation-contract/api1-native.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/trainer-preparation-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/trainer-preparation-contract/api2-unified.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/trainer-preparation-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: trainer-preparation-contract + path: /tmp/trainer-preparation-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bf6315cfd..d5c5f3f6b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ 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, -`trainer.before_battle` can be subscribed to but reports unavailable here. Unknown +`pokemon.level_visible` 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. diff --git a/docs/modding.md b/docs/modding.md index 455f2886f..7c051609b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ 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). +## Trainer battle preparation + +The [portable trainer preparation contract](portable-trainer-preparation.md) +documents `trainer.before_battle(next, game, context, continueBattle)`, deferred +native admission, cancellation and battle-local player-party selection. + ## NPC conversation hook The [portable NPC talk contract](portable-world-talk.md) documents diff --git a/docs/portable-trainer-preparation.md b/docs/portable-trainer-preparation.md new file mode 100644 index 000000000..090f7c581 --- /dev/null +++ b/docs/portable-trainer-preparation.md @@ -0,0 +1,114 @@ +# Portable trainer battle preparation + +`trainer.before_battle(next, game, context, continueBattle)` runs before the +native trainer battle is constructed. Return literal `true` to defer it; +returning `false`, `nil`, zero or another value lets native admission continue. +`next(game, context, continueBattle)` delegates to other wrappers/default false. +The retained `continueBattle` is a separate one-shot admission function, not +an asynchronous use of the hook bus's synchronous `next`. + +```lua +local mod = ... +mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle) + -- A preparation UI can retain this function until its decision is ready. + openPreparationUI(function(indices) + continueBattle(indices and {playerPartyIndices = indices} or {cancel = true}) + end) + return true +end) +``` + +`continueBattle()` starts normally; `{cancel=true}` releases the encounter +without constructing a battle or granting victory. A valid first invocation +returns true, including cancellation; duplicates and invalidated requests +return false. A callback can also continue immediately and return true. +Wrappers failing before/after forwarding fall through without duplicating +native admission. Omitting a deferred decision keeps the encounter pending; +mods must offer a decision or cancellation in their UI. + +## Scope and native behavior + +The context has native `trainerClass`, `partyIndex`, and optional `mapId` and +`npcId`. Identifiers are not a newly canonicalized cross-generation trainer +registry. Gen2 keeps Game opaque and exposes the context through its ordinary +unified context adapter. Pinned Gen1 API 3 also creates a shallow context view +for Game; table identity/arbitrary Game edits are not portable guarantees. +API 1/2 keep their callback defaults; Gen1 ignores API-2 `callback_format`. + +The Gen2 producer covers the existing OverworldController trainer challenge +completion and `Commands.start_battle` trainer scripts, including their GBA/DS +wrappers. Trainer sight uses these native routes after its approach. It does +not wrap constructors globally. Direct facility constructors or mod calls to +private constructors do not become new preparation producers. Wild, link, +first-battle and tutorial-marked script entries retain their original routes. +`mod:supports("hooks", "trainer.before_battle")` reports these native producers +on all 15 supported hosts, not interception of every private constructor. + +The ordinary challenge TextBox remains before preparation. Victory text, +flags, rewards, post-battle flows and transition ownership remain native. +Cancellation does not defeat the trainer and suppresses immediate same-tile +sight retriggering. Script preparation parks the real ScriptRunner before +construction; its continuation resumes that coroutine, then the normal battle +completion resumes it again. Cancelled scripts receive `lastCheck=false` and +`lastBattleResult="cancel"`, then retire their owning coroutine and release +native script locks/caller completion. Post-battle rows and deferred rewards +never run, including for immediate cancellation and strict mod scripts. Native per-engine event +placement/cancellation details are not promised identical. + +A deferred Gen2 request is tied to the original Game/save/data/stack/session, +party table/member order, hook provider/subscriptions and native actor/map or +script coroutine/context. Retained callbacks cannot start against a replaced +session, removed NPC or changed trainer. Native core.update retires invalid +requests; removal of a subscriber cancels a still-current encounter and frees +its pending guard. Stale contexts are retired without running an old completion +against a replacement session. Core-update watcher cleanup preserves other +subscribers. Registration rollback, unsubscribe and owner cleanup still apply. + +## Battle-local player party + +`{playerPartyIndices={3,2}}` selects save-party members three and two, in that +order, for this battle only. It copies the index list and keeps references to +the selected Pokémon. It never replaces/reorders `save.party` or clones the +Game. HP/PP/status/EXP changes to selected members remain real save changes. +The scope governs the lead, doubles partner, switch/replacement eligibility, +native party menus, EXP participation/EXP.ALL, party-based move/AI queries, +party-row rendering and scope exhaustion. Public battle snapshots already read +the battle-local party. Catch storage and tutorial party restoration continue +to use the actual save. + +An empty, sparse, duplicate, fractional, out-of-range or non-table selection +falls back to the full party, matching the pinned reference's bounded behavior. +Selected fainted/egg members are not silently replaced by unselected healthy +ones: native eligibility applies within the selected party. All selected mons +fainting can end the battle even if an excluded bench mon is healthy. + +## Verification + +```sh +luajit tests/engine/trainer_preparation.lua +luajit tests/engine/trainer_preparation_native.lua +luajit tests/engine/trainer_party_scope.lua +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The clean pinned Gen1 reference is +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. One unchanged example consumer runs +on Red, Yellow, Gold, FireRed and Emerald through both real Loaders. Classic +Gen1 and Gen2 workers use their native prepareTrainerBattle and real battle +constructors; native Gen1 Gold uses startScriptedBattle and its own battle +engine; Gen1 GBA uses the real BattleBridge and native state. The worker compares +native construction, party order, deferral, cancellation, immediate continuation, +true-only claiming, wrapper failures, cleanup, rollback and invalid-scope fallback. +No reference sources are modified. + +Gen2-only tests exercise actual challenge TextBoxes, script coroutines, victory +flags/rewards, cancellation, stale references and one-shot admission on all 15 +hosts/four callback modes. Party-scope tests exercise actual doubles, native +party screens, EXP and defeat logic. Fixtures supply ROM-free data/asset seams; +the native producer, constructors and gameplay methods are not replaced. +The workflow additionally runs Gen1's unmodified trainer_before_battle, +unified_gen2_hooks and unified_gen3_hooks suites. These checks do not claim +an imported-ROM playthrough or GPU pixel equivalence. diff --git a/examples/portable_trainer_preparation/main.lua b/examples/portable_trainer_preparation/main.lua new file mode 100644 index 000000000..baaf869a3 --- /dev/null +++ b/examples/portable_trainer_preparation/main.lua @@ -0,0 +1,21 @@ +-- A preparation UI may retain continueBattle; next itself stays synchronous. +local mod = ... +local out = mod.exports +out.api, out.mode, out.calls = mod, "defer", 0 +out.stop = mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle, ...) + out.calls = out.calls + 1 + out.game, out.context, out.continue, out.extra = game, context, continueBattle, select("#", ...) + if out.mode == "error" or out.mode == "after error" then + if out.mode == "after error" then + next(game, context, continueBattle) + end + error("intentional trainer preparation failure") + end + if out.mode == "observe" then + return next(game, context, continueBattle) + end + if out.mode == "immediate" then + continueBattle(out.options) + end + return out.mode == "defer" or out.mode == "immediate" +end) diff --git a/examples/portable_trainer_preparation/manifest.json b/examples/portable_trainer_preparation/manifest.json new file mode 100644 index 000000000..fe5447bf1 --- /dev/null +++ b/examples/portable_trainer_preparation/manifest.json @@ -0,0 +1 @@ +{"id":"portable_trainer_preparation","name":"Portable Trainer Preparation","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index f3a59f20c..15f564b8c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -581,6 +581,10 @@ end BattleState.statusHUDVisible = StatusHUDVisibility.visible +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + function BattleState:bottomUIVisible() return UIVisibility.bottomVisible(self, true) end @@ -1710,10 +1714,12 @@ function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1741,7 +1747,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -1848,7 +1854,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, makeBattler(game.data, second, true, game.save)) @@ -1984,7 +1990,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2002,7 +2008,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -4473,7 +4479,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -4972,7 +4978,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -8454,7 +8460,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() or {} end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8642,7 +8648,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -8763,7 +8769,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -8793,9 +8799,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -8840,7 +8846,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9170,7 +9176,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9258,7 +9264,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9316,7 +9322,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView() or {}) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -10589,7 +10595,7 @@ function BattleState:finish() -- The old-man / PROF.OAK demo also skips it: the party never fought -- (Yellow's Pallet intro runs before the player owns a mon at all). if self.result ~= "lose" and not self.demo and not self.pokedudeDemo - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -12337,7 +12343,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self.playerParty or self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() if showStatus and self.player and not hidePlayer and not self.showPlayerBack diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index 42fe0fd4c..520c44ecc 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -433,7 +433,7 @@ OPS["count_usable_party_mons"] = function(vm, args) local party if b.isPlayer then party = battle.playerParty or (battle.game and battle.game.save - and battle.game.save.party) + and (battle.playerParty or battle.game.save.party)) else party = battle.enemyParty end diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index c69724dce..bed004a8c 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -1006,7 +1006,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/battle/TrainerPreparation.lua b/src/battle/TrainerPreparation.lua new file mode 100644 index 000000000..bd387361b --- /dev/null +++ b/src/battle/TrainerPreparation.lua @@ -0,0 +1,124 @@ +-- A synchronous hook can retain this one-shot native admission continuation. +local Runtime = require("src.mods.Runtime") +local M = { CANCEL_SCRIPT = {} } +local pending = setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function defaultGate() + return false +end +function M.active(game) + return pending[game] ~= nil +end +function M.prepare(game, context, startBattle, cancelBattle, valid, retireBattle) + if not Runtime.wantsHook("trainer.before_battle") then + startBattle() + return false + end + if pending[game] then + return true + end + local hooks, events = Runtime.hooks, Runtime.events + local save, data, stack, session = game.save, game.data, game.stack, game.sessionStartedAt + local world, overworld = game.world, game.overworld + local party = save and save.party + local members = {} + for i, mon in ipairs(party or {}) do + members[i] = mon + end + local entries = {} + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + entries[#entries + 1] = entry + end + local token, finished, unsubscribe = {}, false + pending[game] = token + local function current() + if + game.save ~= save + or game.data ~= data + or game.stack ~= stack + or game.sessionStartedAt ~= session + or game.world ~= world + or game.overworld ~= overworld + or (save and save.party ~= party) + then + return false + end + if #(party or {}) ~= #members then + return false + end + for i, mon in ipairs(members) do + if party[i] ~= mon then + return false + end + end + return not valid or valid() + end + local function live() + if Runtime.hooks ~= hooks or Runtime.events ~= events then + return false + end + for _, original in ipairs(entries) do + local found = false + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + if entry == original then + found = true + break + end + end + if not found then + return false + end + end + return true + end + local function discard() + finished = true + if pending[game] == token then + pending[game] = nil + end + if unsubscribe then + unsubscribe() + unsubscribe = nil + end + end + local function continueBattle(options) + if finished or pending[game] ~= token then + return false + end + local eligible = current() + if not eligible or not live() then + discard() + if eligible and cancelBattle then + cancelBattle() + elseif retireBattle then + retireBattle() + end + return false + end + discard() + if type(options) == "table" and options.cancel == true then + if cancelBattle then + cancelBattle() + end + else + startBattle(options) + end + return true + end + unsubscribe = hooks:wrap("core.update", function(next, ...) + local results = pack(next(...)) + if not finished and (not current() or not live()) then + continueBattle({ cancel = true }) + end + return unpack(results, 1, results.n) + end, -1000, "base") + local deferred = Runtime.call("trainer.before_battle", defaultGate, game, context, continueBattle) + if deferred ~= true and not finished then + continueBattle() + end + return deferred == true +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index d25eece43..c5b53d710 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -21,7 +21,7 @@ add("hooks", "12345", [[ fieldmove.eligibility input.key input.gamepad input.wheel 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 screen.render_visible - script.command trainer.party transition.style ui.list_menu ui.options.rows + script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.options.rows ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) -- Screens have distinct implementations: Screens.lua aliases the DS party, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 7b6c34fb9..f5af41721 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.key input.gamepad input.wheel 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 screen.render_visible script.command trainer.party transition.style + render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle 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.talk world.tod zoom.range ]]) @@ -39,6 +39,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, ["battle.crit"] = { "context" }, diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 568ad5dfc..3032dc380 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -620,6 +620,36 @@ end -- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex function Commands.start_battle(ctx, kind, a, b, opts) + local skipPreparation = opts and (opts.link or opts.firstBattle or opts.firstBattleKind + or opts.oldManTutorial or opts.tutorialKind) + if kind ~= "wild" and not skipPreparation + and require("src.mods.Runtime").wantsHook("trainer.before_battle") then + local runner, co, map = ctx.runner, ctx.runner.co, ctx.overworld and ctx.overworld.map + local ready, waiting, cancelled, selected = false, false, false, nil + local function finish(options) + selected, ready = options, true + if waiting then runner:resume() end + end + require("src.battle.TrainerPreparation").prepare(ctx.game, { + trainerClass = a, partyIndex = b or 1, + mapId = map and map.id, npcId = ctx.npc and ctx.npc.id, + }, finish, function() + cancelled, ready = true, true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + ctx.trainerPreparationCancelled = true + if waiting then runner:cancelTrainerPreparation(ctx, co) end + end, function() + return runner.co == co and runner.ctx == ctx and runner:isRunning() + and (not ctx.overworld or ctx.overworld.map == map) + end, function() runner:cancelTrainerPreparation(ctx, co, true) end) + if not ready then waiting = true; runner:yield(); waiting = false end + if cancelled then error(require("src.battle.TrainerPreparation").CANCEL_SCRIPT, 0) end + local original = opts + opts = {} + for key, value in pairs(original or {}) do opts[key] = value end + if type(selected) == "table" then opts.playerPartyIndices = selected.playerPartyIndices end + end + local BattleState = require("src.battle.BattleState") local runner = ctx.runner local battle @@ -649,7 +679,7 @@ function Commands.start_battle(ctx, kind, a, b, opts) battle.enemy.mon.fatefulEncounter = true end else - battle = BattleState.newTrainer(ctx.game, a, b) + battle = BattleState.newTrainer(ctx.game, a, b, opts) -- the beaten trainer's own line, printed on the battle screen before -- MoneyForWinningText rather than by the script afterwards battle.endBattleText = opts and opts.endBattleText or nil diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..65c7e83fa 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -125,6 +125,7 @@ function ScriptRunner:run(script, extra) end self.co = coroutine.create(function() self:exec(script, ctx) + if ctx.trainerPreparationCancelled then return end -- Commands can defer a game action until the script's own dialogue is -- done. start_battle uses this for win-path evolutions, which must not -- be covered by post-battle trainer text. @@ -251,6 +252,10 @@ function ScriptRunner:exec(script, ctx) if ok then jump = result else + if result == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(ctx, self.co) + return + end Logger.error("script: command '%s' failed at row %d (skipped): %s", tostring(name), pc, tostring(result)) jump = nil @@ -275,6 +280,22 @@ function ScriptRunner:exec(script, ctx) end end +-- Retire only the coroutine that owns this admission; cancellation is not a +-- native battle outcome and must never reach post-win script rows/callbacks. +function ScriptRunner:cancelTrainerPreparation(ctx, co, silent) + if self.ctx ~= ctx or self.co ~= co or not co then return false end + ctx.trainerPreparationCancelled = true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + self.co, self.waitingFrames, self.waitingCheck = nil, nil, nil + if silent then return true end + if ctx.g3Locked and Commands.g3_release then Commands.g3_release(ctx) end + if ctx.g4Locked and Commands.g4_release_all then Commands.g4_release_all(ctx) end + if ctx.g3FadedOut then require("src.script.Gen3Commands").restoreFade(ctx) end + if ctx.onDone then ctx.onDone() end + Runtime.emit("script.ended", { ctx = ctx, completed = false }) + return true +end + -- Called by blocking commands from inside the coroutine. function ScriptRunner:yield() return coroutine.yield() @@ -294,6 +315,10 @@ function ScriptRunner:resume(...) end local ok, err = coroutine.resume(co, ...) if not ok then + if err == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(self.ctx, co) + return + end local source = self.ctx and self.ctx.source local where = source and Strings(" [%s %s.%s]", tostring(source.modId or "engine"), diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 855f2c805..b6e997d82 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 851b8ec29..82a9929fe 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4b487d623..7b907a617 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -438,7 +438,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 65cc1d75c..e143fb17a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -11506,6 +11506,8 @@ function OverworldState:trainerDefeated(npc) return false end +OverworldState.prepareTrainerBattle = require("src.battle.TrainerPreparation").prepare + -- Run the pre-battle text -> battle -> won text -> flags sequence. function OverworldState:engageTrainer(npc, onDone) local d = npc.def @@ -11520,7 +11522,7 @@ function OverworldState:engageTrainer(npc, onDone) local wonText = header and header.won and Game.data.text[header.won] local BattleState = require("src.battle.BattleState") - Game.stack:push(TextBox.new(Game, battleText, function() + local function startBattle(options) -- A TEAM THE AUTHOR BUILT WINS OVER AN INDEX INTO SOMEBODY ELSE'S. -- -- `trainerParty` selects one of the parties the cartridge shipped for @@ -11531,10 +11533,10 @@ function OverworldState:engageTrainer(npc, onDone) local battle if type(d.trainerTeam) == "table" and #d.trainerTeam > 0 then battle = BattleState.newEditorTrainer(Game, d.trainerTeam, - d.trainerClass, d.trainerName) + d.trainerClass, d.trainerName, options) end battle = battle or BattleState.newTrainer(Game, d.trainerClass, - d.trainerParty) + d.trainerParty, options) -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -11563,6 +11565,34 @@ function OverworldState:engageTrainer(npc, onDone) end end self:pushBattle(battle) + end + Game.stack:push(TextBox.new(Game, battleText, function() + local liveGame, liveSave, liveData, liveStack, liveSession = Game, Game.save, Game.data, Game.stack, Game.sessionStartedAt + local map, def = self.map, npc.def + local class, party, npcId, team = d.trainerClass, d.trainerParty, npc.id, d.trainerTeam + local wasEngaging = self.engaging + self.engaging = true + OverworldState.prepareTrainerBattle(Game, { + trainerClass = class, partyIndex = party or 1, mapId = map.id, npcId = npc.id, + }, function(options) + self.engaging = wasEngaging + startBattle(options) + end, function() + self.engaging = wasEngaging + self.spotHush = { cellX = self.player.cellX, cellY = self.player.cellY, [npc] = true } + if onDone then onDone() end + end, function() + if Game ~= liveGame or self.map ~= map or npc.id ~= npcId or npc.def ~= def + or d.trainerTeam ~= team or def.trainerClass ~= class + or def.trainerParty ~= party then return false end + for _, actor in ipairs(self.npcs or {}) do if actor == npc then return true end end + return false + end, function() + if Game == liveGame and Game.save == liveSave and Game.data == liveData + and Game.stack == liveStack and Game.sessionStartedAt == liveSession and self.map == map then + self.engaging, npc.frozen = false, false + end + end) end)) end diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 8837ecca4..3fb17e290 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,6 +170,7 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo for _, version in ipairs(GameVersion.ORDER) do local mod = probe(version) local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'trainer.before_battle'), true, version .. ': native trainer preparation seam') T.eq(mod:supports('hooks', 'world.talk'), true, version .. ': native NPC talk seam') T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, version .. ': native GB/GBA/DS HUD seam') diff --git a/tests/engine/trainer_party_scope.lua b/tests/engine/trainer_party_scope.lua new file mode 100644 index 000000000..f7765beb7 --- /dev/null +++ b/tests/engine/trainer_party_scope.lua @@ -0,0 +1,68 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Scope = require("src.battle.PlayerPartyScope") +local Runtime = require("src.mods.Runtime") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + local original, members = game.save.party, { game.save.party[1], game.save.party[2], game.save.party[3] } + f.start() + f.exports.continue({ playerPartyIndices = { 3, 2 } }) + local b = f.battle() + T.eq(b.game, game, "battle keeps original Game") + T.eq(b:playerPartyView()[1], members[3], "scope holds original first reference") + T.eq(b:benchFor(true)[2], members[2], "bench only contains scope") + T.eq(#b:benchFor(true), 2, "excluded mon absent from switch eligibility") + b.double = true + b:sendOutSecondPair() + T.eq(b:battlerAt(Battle.POS.PLAYER_RIGHT).mon, members[2], "double partner uses scoped eligible mon") + local id = require("src.battle.Gen4Battle").partyScreenId(b) or "PartyMenu" + local menu = b:buildScreen(id, { battle = b, onSwitch = function() end }) + local party = type(menu.party) == "function" and menu:party() or menu.party + T.eq(party, b.playerParty, "actual native party menu uses scope") + T.eq(#party, 2, "native menu cannot select excluded mon") + -- Native EXP eligibility ignores an excluded mon even if old participant + -- bookkeeping contains it. EXP.ALL cannot pay the excluded bench either. + b.queue = {} + b.participants = { [members[1]] = true, [members[2]] = true, [members[3]] = true } + game.save.inventory.EXP_ALL = 1 + local excludedExp = members[1].exp + local seen + local off = f.result.loader.hooks:wrap("battle.exp_award", function(next, ctx) + seen = ctx + return next(ctx) + end) + b:awardExp(b.enemy) + T.eq(seen.participants, 2, "native EXP counts selected participants") + T.eq(#seen.alive, 2, "native EXP contains selected living mons") + T.eq(members[1].exp, excludedExp, "excluded mon receives no EXP") + off() + members[2].hp, members[3].hp = 0, 0 + T.check(members[1].hp > 0, "excluded bench stays healthy") + b.result = nil + b:playerMonFainted() + T.eq(b.result, "lose", "scope exhaustion loses despite healthy excluded bench") + T.eq(game.save.party, original, "native loss never swaps save party") + T.same(game.save.party, members, "native loss never reorders save party") + for _, invalid in ipairs({ + {}, + { 1, 1 }, + { 0 }, + { 4 }, + { 1.5 }, + { [1] = 1, [3] = 2 }, + { 1, unexpected = true }, + "bad", + }) do + local selected, indices = Scope.select(game, invalid) + T.eq(selected, nil, "invalid scope falls back to full party") + T.eq(indices, nil, "invalid scope keeps no remap") + end + f.close() +end +Version.set(originalVersion) +T.finish("trainer battle-local player party scope") diff --git a/tests/engine/trainer_preparation.lua b/tests/engine/trainer_preparation.lua new file mode 100644 index 000000000..e25727c2f --- /dev/null +++ b/tests/engine/trainer_preparation.lua @@ -0,0 +1,21 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local _, T = dofile("tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(".", "gen2", "red", 3, "unified", noMod, failing) +end) +-- Keep an executable public-API proof in the engine corpus as well as the +-- shared worker helpers (the coverage gate scans only its explicit tiers). +local f = Fixture.new(".", "gen2", "red", 3, "unified") +local mod, calls = f.exports.api, 0 +local off = mod.hooks:wrap("trainer.before_battle", function(next, ...) + calls = calls + 1 + return next(...) +end, 100) +f.start() +T.eq(calls, 1, "public API callback reached from native preparation") +T.eq(f.starts(), 0, "downstream preparation defers") +T.eq(f.exports.continue(), true, "public continuation starts native battle") +T.eq(f.starts(), 1, "public continuation constructs once") +off() +f.close() +T.finish("portable trainer.before_battle") diff --git a/tests/engine/trainer_preparation_native.lua b/tests/engine/trainer_preparation_native.lua new file mode 100644 index 000000000..ec25820d2 --- /dev/null +++ b/tests/engine/trainer_preparation_native.lua @@ -0,0 +1,245 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Runner = require("src.script.ScriptRunner") +local Preparation = require("src.battle.TrainerPreparation") +local originalVersion = Version.get() +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +local function setup(version, api, format) + local f = Fixture.new(".", "gen2", version, api, format) + local npc = { id = "TRAINER_1", def = { index = 1, trainerClass = "OPP_FIX_YOUNGSTER", trainerParty = 1 } } + local world = setmetatable({ + game = f.game, + npcs = { npc }, + map = { id = "FIX_ROUTE", def = { label = "FIX_ROUTE" } }, + player = { cellX = 4, cellY = 5 }, + engaging = false, + }, { __index = World }) + f.game.world, f.game.overworld = world, world + f.game.stack.states = { world } + local restore = bind(World.enter, "Game", f.game) + f.patch(f.game.data, "trainerHeader", function() + return { event = "TRAINER_WON", battle = "CHALLENGE", won = "WON" } + end) + f.game.data.text.CHALLENGE, f.game.data.text.WON = "A trainer challenge!", "You won!" + local battles, rewards, after = {}, 0, 0 + f.patch(world, "pushBattle", function(_, b) + battles[#battles + 1] = b + end) + f.patch(world, "checkVictoryRewards", function() + rewards = rewards + 1 + end) + f.patch(world, "afterBattle", function() + after = after + 1 + end) + f.patch(world, "runGen2AfterBattle", function() end) + f.world, f.npc, f.battles = world, npc, battles + f.finish = function() + restore() + f.close() + end + f.outcomes = function() + return rewards, after + end + return f +end +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, cancel in ipairs({ false, true }) do + local f = setup(version, mode[1], mode[2]) + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + T.eq(f.exports.calls, 0, "pre-battle challenge precedes preparation") + local box = f.game.stack:top() + box.done = true + box:advance() + T.eq(f.exports.calls, 1, "native challenge completion dispatches once") + T.eq(#f.battles, 0, "deferral constructs/pushes no battle") + T.eq(f.world.engaging, true, "native field stays busy while deciding") + T.eq( + f.exports.continue(cancel and { cancel = true } or { playerPartyIndices = { 3, 2 } }), + true, + "native decision accepted" + ) + T.eq(f.exports.continue(), false, "native decision one-shot") + T.eq(f.world.engaging, false, "preparation releases its engagement guard") + T.eq(#f.battles, cancel and 0 or 1, "native construction only on resume") + if cancel then + T.eq(done, 1, "cancel releases encounter caller") + T.eq(f.game.save.flags.TRAINER_WON, nil, "cancel sets no defeat flag") + T.eq(f.world.spotHush.cellX, 4, "cancel suppresses same-tile sight retrigger") + T.eq(f.world.spotHush[f.npc], true, "sight scan suppresses this cancelled trainer") + else + local b = f.battles[1] + T.eq(b.player.mon, f.game.save.party[3], "native lead uses scoped party") + T.same(b.playerPartyIndices, { 3, 2 }, "native ordered scope") + b.onFinish("win") + T.eq(f.game.save.flags.TRAINER_WON, true, "native win sets its flag") + T.eq(f.game.save.defeatedTrainers.TRAINER_1, true, "native win records trainer defeat") + local rewards, after = f.outcomes() + T.eq(rewards, 1, "native win keeps rewards") + T.eq(after, 1, "native win keeps post-battle flow") + T.eq(done, 1, "native win releases encounter caller") + end + T.eq(Preparation.active(f.game), false, "native pending request discarded") + f.finish() + end + local f = setup(version, mode[1], mode[2]) + local runner = Runner.new(f.game, f.world) + runner:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "AFTER_BATTLE" } }) + T.eq(f.exports.calls, 1, "actual script dispatches trainer gate") + T.eq(runner:isRunning(), true, "native script parks while preparing") + T.eq(#f.battles, 0, "script defers construction") + T.eq(f.exports.continue({ playerPartyIndices = { 2, 3 } }), true, "script continuation accepted") + T.eq(#f.battles, 1, "script constructs native battle once") + T.eq(f.exports.calls, 1, "resumed script never reintercepts itself") + T.eq(f.battles[1].player.mon, f.game.save.party[2], "script scope selects native lead") + T.eq(f.game.save.flags.AFTER_BATTLE, nil, "script still awaits actual battle outcome") + f.battles[1].onFinish("lose") + T.eq(runner:isRunning(), false, "native finish resumes script once") + T.eq(f.game.save.flags.AFTER_BATTLE, true, "native script continues after outcome") + f.finish() + for _, immediate in ipairs({ false, true }) do + for _, strict in ipairs({ false, true }) do + local cancelled = setup(version, mode[1], mode[2]) + local r = Runner.new(cancelled.game, cancelled.world) + local completed = 0 + if immediate then + cancelled.exports.mode, cancelled.exports.options = "immediate", { cancel = true } + end + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "UNSAFE_WIN" } }, { + source = strict and { modId = "strict_cancel_probe", strict = true } or nil, + onDone = function() + completed = completed + 1 + end, + afterScript = { + function() + cancelled.game.save.flags.UNSAFE_REWARD = true + end, + }, + }) + if not immediate then + cancelled.exports.continue({ cancel = true }) + end + T.eq(#cancelled.battles, 0, "cancelled script constructs no battle") + T.eq(r:isRunning(), false, "cancelled script releases coroutine") + T.eq(cancelled.game.save.flags.UNSAFE_WIN, nil, "cancel never runs win rows") + T.eq(cancelled.game.save.flags.UNSAFE_REWARD, nil, "cancel never runs deferred rewards") + T.eq(completed, 1, "cancel releases native caller once") + T.eq(r.ctx.lastCheck, false, "cancel has no successful battle result") + T.eq(#cancelled.result.errors, 0, "native cancellation is not a mod failure") + cancelled.finish() + end + end + for _, skip in ipairs({ "link", "firstBattle", "tutorialKind" }) do + local direct = setup(version, mode[1], mode[2]) + local r = Runner.new(direct.game, direct.world) + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1, { [skip] = true } } }) + T.eq(direct.exports.calls, 0, skip .. " keeps native non-preparation route") + T.eq(#direct.battles, 1, skip .. " constructs native battle directly") + direct.finish() + end + end +end +-- Retained continuations cannot cross session or actor/script identities. +for _, change in ipairs({ + "save", + "stack", + "data", + "party", + "map", + "npc", + "npc id", + "trainer", + "team", + "session", + "owner", + "provider", +}) do + local f = setup("red", 3, "unified") + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + local box = f.game.stack:top() + box.done = true + box:advance() + local resume = f.exports.continue + if change == "save" then + f.game.save = {} + elseif change == "stack" then + f.game.stack = {} + elseif change == "data" then + f.game.data = {} + elseif change == "party" then + f.game.save.party = {} + elseif change == "map" then + f.world.map = {} + elseif change == "npc" then + f.world.npcs = {} + elseif change == "npc id" then + f.npc.id = "OTHER" + elseif change == "team" then + f.npc.def.trainerTeam = {} + elseif change == "trainer" then + f.npc.def.trainerClass = "OTHER" + elseif change == "session" then + f.game.sessionStartedAt = 2 + elseif change == "owner" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + elseif change == "provider" then + require("src.mods.Runtime").hooks = require("src.mods.Hooks").new() + end + T.eq(resume(), false, change .. " invalidates native continuation") + T.eq(#f.battles, 0, change .. " cannot construct battle") + T.eq(resume(), false, change .. " stale continuation stays retired") + T.eq(Preparation.active(f.game), false, change .. " releases pending request") + if change == "npc" or change == "npc id" or change == "team" or change == "trainer" then + T.eq(f.world.engaging, false, "retired actor releases its field guard") + end + f.finish() +end +local f = setup("red", 3, "unified") +f.world:engageTrainer(f.npc, function() end) +local box = f.game.stack:top() +box.done = true +box:advance() +f.result.loader.hooks:removeOwner("portable_trainer_preparation") +local peerCalls = 0 +local peer = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) +end, 10, "other_core_owner") +local value, extra = require("src.mods.Runtime").call("core.update", function() + return "native", 7 +end) +T.eq(value, "native", "watcher preserves native update result") +T.eq(extra, 7, "watcher preserves native update tuple") +T.eq(peerCalls, 1, "watcher preserves another mod's callback") +require("src.mods.Runtime").call("core.update", function() end) +T.eq(peerCalls, 2, "other callback survives watcher removal") +peer() +T.eq(Preparation.active(f.game), false, "native update retires removed owner without resume") +T.eq(f.world.engaging, false, "native update releases removed owner's field guard") +T.eq(f.exports.continue(), false, "removed owner's continuation remains retired") +f.finish() +Version.set(originalVersion) +T.finish("trainer.before_battle native dialogue, script and lifetime") diff --git a/tests/trainer_preparation/cases.lua b/tests/trainer_preparation/cases.lua new file mode 100644 index 000000000..bff3a297c --- /dev/null +++ b/tests/trainer_preparation/cases.lua @@ -0,0 +1,87 @@ +local M = {} +function M.run(new) + local rows, T = {}, nil + for _, mode in ipairs({ + "no-mod", + "observe", + "false", + "truthy", + "nil", + "defer", + "cancel", + "immediate", + "error", + "after error", + "unsubscribe", + "owner cleanup", + "rollback", + "invalid scope", + }) do + local f = new(mode == "no-mod", mode == "rollback") + T = f.T + local e, original = f.exports, f.game.save.party + local members = { original[1], original[2], original[3] } + if e then + e.mode = (mode == "cancel" or mode == "invalid scope") and "defer" or mode + e.options = { playerPartyIndices = { 3, 2 } } + if mode == "unsubscribe" then + e.stop() + e.stop() + end + if mode == "owner cleanup" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + end + end + local off + if e and (mode == "truthy" or mode == "nil") then + local api = e.api + off = api.hooks:wrap("trainer.before_battle", function() + if mode == "truthy" then + return 1 + end + end, 100) + end + f.start() + local selected = mode == "defer" or mode == "immediate" + if mode == "defer" or mode == "cancel" or mode == "invalid scope" then + T.eq(f.starts(), 0, mode .. " constructs no battle before decision") + T.eq(e.calls, 1, mode .. " native hook fires once") + T.eq(type(e.context), "table", "data-only trainer context") + T.eq(e.context.partyIndex, 1, "native party index") + T.eq(e.extra, 0, "native hook arity") + if f.backend == "gen2" or f.api < 3 then + T.eq(e.game, f.game, "opaque original Game") + else + T.eq(e.game.sessionStartedAt, 1, "Gen1 API3 Game context boundary") + end + local options = mode == "cancel" and { cancel = true } + or { playerPartyIndices = mode == "invalid scope" and { 1, 1 } or { 3, 2 } } + T.eq(e.continue(options), true, "retained native continuation accepted") + T.eq(e.continue(), false, "retained native continuation one-shot") + end + local cancelled = mode == "cancel" + T.eq(f.starts(), cancelled and 0 or 1, mode .. " starts exactly once or cancels") + T.eq(f.cancels(), cancelled and 1 or 0, mode .. " cancellation callback") + T.eq(f.game.save.party, original, "save party table is never swapped") + T.same(f.game.save.party, members, "save party order and references unchanged") + for i, mon in ipairs(members) do + T.eq(f.game.save.party[i], mon, "save mon identity retained") + end + if not cancelled then + local party = assert(f.party(), "real native battle party") + T.eq(#party, selected and 2 or 3, mode .. " native battle party size") + T.eq(party[1].nickname, selected and "THREE" or "ONE", mode .. " native lead/order") + if selected then + T.eq(party[2].nickname, "TWO", "second selected member") + end + end + rows[#rows + 1] = + { mode = mode, starts = f.starts(), cancels = f.cancels(), size = f.party() and #f.party() or 0 } + if off then + off() + end + f.close() + end + return rows, T +end +return M diff --git a/tests/trainer_preparation/fixture.lua b/tests/trainer_preparation/fixture.lua new file mode 100644 index 000000000..80b3c7987 --- /dev/null +++ b/tests/trainer_preparation/fixture.lua @@ -0,0 +1,320 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_trainer_preparation/" .. name, "rb")) + files["mods/portable_trainer_preparation/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_trainer_preparation/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_trainer_preparation/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_trainer_preparation/main.lua"] = files["mods/portable_trainer_preparation/main.lua"] + .. '\nerror("intentional entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + if backend == "gen1" and gen == 2 then + data = { + version = version, + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + }, + MAGIKARP = { + id = "MAGIKARP", + name = "MAGIKARP", + index = 129, + dex = 129, + types = { "WATER", "WATER" }, + genderRatio = 127, + catchRate = 255, + baseExp = 20, + growthRate = "GROWTH_MEDIUM_FAST", + baseStats = { + hp = 80, + attack = 10, + defense = 55, + speed = 80, + specialAttack = 15, + specialDefense = 20, + }, + levelMoves = { { level = 1, move = "SPLASH" } }, + evolutions = {}, + }, + }, + moves = { + SPLASH = { + id = "SPLASH", + name = "SPLASH", + type = "NORMAL", + power = 0, + accuracy = 100, + pp = 40, + effect = "EFFECT_SPLASH", + }, + }, + type_chart = { + types = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + WATER = { id = "WATER", index = 21, category = "special" }, + }, + matchups = {}, + }, + items = {}, + } + end + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_trainer_preparation" }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local input = setmetatable({}, { __index = require("src.core.Input") }) + input:init() + local game = { + data = data, + stack = stack, + input = input, + sessionStartedAt = 1, + save = { + version = version, + party = {}, + player = { name = "PROBE" }, + options = {}, + flags = {}, + defeatedTrainers = {}, + engineFlags = {}, + inventory = {}, + money = 1000, + pokedex = { seen = {}, caught = {} }, + }, + } + local context = + { trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, mapId = "FIX_ROUTE", npcId = "TRAINER_1" } + local battle, starts, cancels, start, closeNative = nil, 0, 0 + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + local P = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + patch(P, key, value) + end + patch(P, "_front", {}) + patch(P, "_back", {}) + patch(P, "_national", { toNational = { [4] = 4, [16] = 16 } }) + patch(P, "_battleMoves", data.gen3Moves._rom) + patch(P, "_romMoveNames", data.gen3Pokemon._moveNames) + local function mon(species, nickname) + return { + species = species, + nickname = nickname, + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + ability = 0, + personality = 1, + growthRate = 0, + exp = 8000, + item = 0, + otId = 1, + otSecretId = 2, + } + end + local session = { + version = version, + party = { mon(4, "ONE"), mon(4, "TWO"), mon(4, "THREE") }, + name = "RED", + trainerId = 1, + secretId = 2, + flags = {}, + vars = {}, + options = {}, + } + game.session = session + game.save.party = session.party + local R = require("src.core.game3.runtime") + patch(R, "active", true) + patch(R, "session", session) + patch(R, "_game", game) + local Field = require("src.core.game3.field") + patch(Field, "running", false) + patch(Field, "_session", session) + patch(Field, "_game", game) + patch(Field, "_locks", {}) + patch(require("src.core.game3.map"), "current", nil) + local Space = require("src.core.game3.scripting.space") + patch(Space, "vm", nil) + patch(Space, "bundle", { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key), v = tostring(key), text = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + maps = {}, + events = {}, + }) + local B = require("src.core.game3.battle") + local Bridge = require("src.core.game3.battle_bridge") + B.reset() + Bridge.reset() + local foe = mon(16, "FOE") + foe.party = { mon(16, "FOE") } + foe.trainerClass, foe.trainerId = 1, 1 + patch(require("src.core.game3.scripting.trainers"), "_pack", { + classNames = { [0] = "TRAINER", [1] = "PROBE" }, + trainers = { + [0] = { + class = 0, + name = "TRAINER", + pic = 0, + party = { { species = 16, level = 5, moves = { 33 } } }, + }, + [1] = { class = 1, name = "PROBE", pic = 0, party = { { species = 16, level = 5, moves = { 33 } } } }, + }, + }) + local cache = require("src.core.game3.dataset").cache() + local read = cache.read + patch(cache, "read", function(self, path, ...) + if path:match("/battle_moves%.lua$") then + return "return {moves={[33]={effect=0,power=35,type=0,accuracy=100,pp=35,target=0,priority=0}}}" + end + return read(self, path, ...) + end) + result.loader.events:on("battle.started", function() + starts = starts + 1 + battle = B.getState() + end) + start = function() + return Bridge.start(nil, game, foe, { + headless = true, + fade = false, + autoFight = false, + cache = cache, + song = 1, + mapBehavior = 0, + noWhiteout = true, + cancel = function() + cancels = cancels + 1 + end, + failed = function(reason) + error("native trainer start: " .. tostring(reason)) + end, + }) + end + closeNative = function() + B.reset() + Bridge.reset() + R.drainDeferred() + end + elseif backend == "gen1" and gen == 2 then + local Mon = require("src.battle.gen2.Mon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + local m = Mon.new(data, "MAGIKARP", 20) + m.nickname = name + game.save.party[i] = m + end + local World = require("src.world.gen2.World") + local world = World.new(game) + game.world = world + local record = { + class = 1, + member = 1, + event = 700, + name = "PROBE", + className = "YOUNGSTER", + roster = { { species = "MAGIKARP", level = 5 } }, + } + world.vm = require("src.script.gen2.Vm").new({}, {}, world.events) + result.loader.events:on("battle.started", function(payload) + local native = payload.battle.battle or payload.battle + if native ~= battle then + battle = native + starts = starts + 1 + end + end) + start = function() + return world:startScriptedBattle(record, nil, function() + cancels = cancels + 1 + end) + end + else + local Save = require("src.core.SaveData") + game.save = Save.newGame() + local P = require("src.pokemon.Pokemon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + game.save.party[i] = P.new(data, "FIXMON_A", 20) + game.save.party[i].nickname = name + end + local World = require("src.world.OverworldController") + start = function() + return World.prepareTrainerBattle(game, context, function(options) + battle = require("src.battle.BattleState").newTrainer(game, "OPP_FIX_YOUNGSTER", 1, options) + starts = starts + 1 + end, function() + cancels = cancels + 1 + end) + end + end + result.loader.game = game + return { + T = T, + api = api, + backend = backend, + generation = gen, + result = result, + game = game, + exports = result.loader.exports.portable_trainer_preparation, + patch = patch, + start = start, + starts = function() + return starts + end, + cancels = function() + return cancels + end, + party = function() + local b = battle + return b and (b.playerParty or b.party or (b.player and b.player.party) or game.save.party) or nil + end, + battle = function() + return battle + end, + close = function() + if closeNative then + closeNative() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/trainer_preparation/worker.lua b/tests/trainer_preparation/worker.lua new file mode 100644 index 000000000..dbfe78eb0 --- /dev/null +++ b/tests/trainer_preparation/worker.lua @@ -0,0 +1,8 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local Fixture = dofile(root .. "/tests/trainer_preparation/fixture.lua") +local rows, T = dofile(root .. "/tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(root, backend, version, api, format, noMod, failing) +end) +print("TRAINER_PREPARATION_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable trainer.before_battle contract") diff --git a/tools/trainer-preparation-contract-check/check.py b/tools/trainer-preparation-contract-check/check.py new file mode 100644 index 000000000..059bd545f --- /dev/null +++ b/tools/trainer-preparation-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged trainer preparation mod through both engines' native trainer admission paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/trainer_preparation/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("TRAINER_PREPARATION_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("TRAINER_PREPARATION_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"trainer preparation mismatch: {version}" + print(f"PASS {version}: matching native trainer admission paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_trainer_preparation/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 6e61c01ebe6ccaca1c2b9588d6e9fe5cedbe24cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:19 +0200 Subject: [PATCH 23/86] Add portable GB and GBA Pokemon level visibility hooks --- .github/workflows/level-visible-contract.yml | 49 +++ docs/api3-portability.md | 2 +- docs/portable-level-visibility.md | 73 +++++ examples/portable_level_visible/main.lua | 16 + examples/portable_level_visible/manifest.json | 10 + src/battle/BattleState.lua | 5 +- src/battle/Gen3Battle.lua | 10 +- src/battle/WideBattle.lua | 5 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- src/ui/Gen3PartyMenu.lua | 5 +- src/ui/Gen3SummaryMenu.lua | 17 +- src/ui/LevelDisplay.lua | 16 + src/ui/PartyMenu.lua | 3 +- src/ui/SummaryMenu.lua | 21 +- tests/engine/level_visible.lua | 11 + tests/engine/level_visible_contract.lua | 39 +++ tests/engine/level_visible_renderers.lua | 289 ++++++++++++++++++ tests/level_visible/cases.lua | 104 +++++++ tests/level_visible/fixture.lua | 57 ++++ tests/level_visible/native.lua | 191 ++++++++++++ tests/level_visible/worker.lua | 15 + tools/level-visible-contract-check/check.py | 60 ++++ 23 files changed, 982 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/level-visible-contract.yml create mode 100644 docs/portable-level-visibility.md create mode 100644 examples/portable_level_visible/main.lua create mode 100644 examples/portable_level_visible/manifest.json create mode 100644 src/ui/LevelDisplay.lua create mode 100644 tests/engine/level_visible.lua create mode 100644 tests/engine/level_visible_contract.lua create mode 100644 tests/engine/level_visible_renderers.lua create mode 100644 tests/level_visible/cases.lua create mode 100644 tests/level_visible/fixture.lua create mode 100644 tests/level_visible/native.lua create mode 100644 tests/level_visible/worker.lua create mode 100644 tools/level-visible-contract-check/check.py diff --git a/.github/workflows/level-visible-contract.yml b/.github/workflows/level-visible-contract.yml new file mode 100644 index 000000000..0c9d7e72a --- /dev/null +++ b/.github/workflows/level-visible-contract.yml @@ -0,0 +1,49 @@ +name: portable pokemon.level_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: level-visible-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native level rendering and SDK contracts + run: | + luajit tests/engine/level_visible.lua + luajit tests/engine/level_visible_contract.lua + luajit tests/engine/level_visible_renderers.lua + - name: Validate pinned native Gen1 level contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/level-visible-contract + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/level-visible-contract/api1-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/level-visible-contract/api2-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/level-visible-contract/api2-unified.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/level-visible-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: level-visible-contract + path: /tmp/level-visible-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index d5c5f3f6b..8cf430692 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ 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, -`pokemon.level_visible` can be subscribed to but reports unavailable here. Unknown +`battle.menu_auxiliary` 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. diff --git a/docs/portable-level-visibility.md b/docs/portable-level-visibility.md new file mode 100644 index 000000000..1dce40ad1 --- /dev/null +++ b/docs/portable-level-visibility.md @@ -0,0 +1,73 @@ +# Portable Pokémon level visibility + +`pokemon.level_visible` lets a mod hide the native level label and digits +without replacing a battle, party or summary screen. It uses Gen1Recomp's +existing GB/GBA contract: + +```lua +local mod = ... +if mod:supports("hooks", "pokemon.level_visible") then + mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + if context.where == "battle.enemy" then return false end + return next(mon, context) + end) +end +``` + +The default is `true`; only a first return value of literal `false` hides the +label. `nil`, `0` and other values leave it visible. A caller only asks where +the native screen would otherwise print a level. The decision can be queried +several times per frame and should be deterministic and free of side effects. +Unsubscribing or unloading the mod restores the native display. + +`context.where` is one of `battle.enemy`, `battle.player`, `party`, or `summary`. +The native callback receives the actual Pokémon and `{where, game}`. Gen2's unified +API 2 and the common API 3 use their existing Pokémon/context record adapters, including +normal ID conversion, `next` forwarding, validation and failure recovery. API 3 +is recommended for portable consumers. The Game value is an engine handle; +Gen1's unified context can expose a forwarding view while Gen2 retains the +native handle. Consumers should not inspect engine-private Game fields. + +The native call sites cover Gen2Recomped's Gen1/2 games (including Prism and +Polished Crystal) and FireRed/Emerald. Classic and wide GB healthboxes, GBA +single/double healthboxes, party rows and summary variants use the same hook. +GB summaries also omit the printed next-level number and its `TO` word/arrow. +Status/faint labels, egg eligibility, names, HP, EXP values/bars, and historical +met-level text retain their native behavior. GBA move-detail pages retain their +existing level eligibility. The level, stats and progression are not changed by +hiding their display. + +The DS renderers are outside this change. `supports` returns `false` for +Gen4/5; accepting the API-3 hook name alone is not a support claim. + +## Validation + +The unchanged example in `examples/portable_level_visible` is loaded by both +engines' actual SDKs. The checker pins a clean Gen1 reference at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and compares Red, Yellow, Gold, +FireRed and Emerald in native API 1/2 and API 3. Gen2's additional unified API 2 +mode is compared against Gen1's native API 2: Gen1 does not implement the +`callback_format` manifest extension. API 3 is the common unified contract. +Workers compare +native battle level omission/restoration and all four predicate contexts. The +Gen1 GBA worker uses Healthbox's intact private level producer with a native +session context; GB and Gen2 GBA workers invoke native HUD methods. Only +font/tile leaves are recorded. + +```sh +luajit tests/engine/level_visible.lua +luajit tests/engine/level_visible_contract.lua +luajit tests/engine/level_visible_renderers.lua +python3 tools/level-visible-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/level-visible.json +``` + +Gen2 renderer tests exercise actual party/summary draw methods and native +healthbox variants across every supported version and SDK mode. They check +labels and digits together, status/egg eligibility, per-side hiding, level 100, +next-level arrows, native context, restoration, and a distinct doubles partner. +Leaf-spy tests disable JIT compilation only for the HUD function whose captured +leaf is replaced through debug upvalues. Predicate and SDK behavior remain +covered separately. CI also runs the pinned Gen1 Gen2/Gen3 native hook suites +unchanged. These are ROM-free draw-command and behavior tests, not GPU pixel +goldens or full cartridge playthroughs. diff --git a/examples/portable_level_visible/main.lua b/examples/portable_level_visible/main.lua new file mode 100644 index 000000000..f026590af --- /dev/null +++ b/examples/portable_level_visible/main.lua @@ -0,0 +1,16 @@ +-- Hide levels per surface while leaving the native screen and input active. +-- Rendering can query a predicate repeatedly; keep the decision deterministic. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(where, value) + hidden[where] = value == true +end +mod.exports.stop = mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + seen.calls = seen.calls + 1 + seen.mon, seen.context = mon, context + if hidden[context.where] then + return false + end + return next(mon, context) +end) diff --git a/examples/portable_level_visible/manifest.json b/examples/portable_level_visible/manifest.json new file mode 100644 index 000000000..60aef31ee --- /dev/null +++ b/examples/portable_level_visible/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_level_visible", + "name": "Portable Level Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 15f564b8c..a3031115d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -19,6 +19,7 @@ local HoldItems = require("src.battle.HoldItems") local Experience = require("src.battle.Experience") local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -12273,7 +12274,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) - else + elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) end @@ -12355,7 +12356,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.player.name, nameX(10, self.player.name), 56) if self.player.shownStatus then Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) - else + elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) end diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index d7d19462b..155df7754 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -39,6 +39,7 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Theme = require("src.ui.Theme") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") @@ -891,7 +892,11 @@ end local HUD_TEXT_STYLE = { text = { 0.16, 0.16, 0.16, 1 } } Gen3Battle.HUD_TEXT_STYLE = HUD_TEXT_STYLE -local function levelAt(battle, battler, x, y, text) +local function levelAt(battle, battler, x, y, text, isStatus) + if not (isStatus or (not text and battler.shownStatus)) + and not LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then return end if text then Font.draw(text, x, y) elseif battler.shownStatus then @@ -1127,7 +1132,8 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.draw(symbol, ix + 1 + Font.width(shown), nameY) Font.popStyle() end - levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level) + levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level, + not badge and battler.shownStatus) -- the foe's exact HP is never shown, as on every cartridge if player then diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index e280d8f1f..59bd18163 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -12,6 +12,7 @@ -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") @@ -88,7 +89,9 @@ end local function levelAt(battle, battler, x, y) if battler.shownStatus then Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y) - else + elseif LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then HudTiles.tile(0x6E, x, y) -- '' Font.draw(tostring(battler.mon.level), x + 8, y) end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index c5b53d710..76d17a339 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -32,6 +32,7 @@ add("hooks", "12345", [[ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") +add("hooks", "123", "pokemon.level_visible") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index f5af41721..b3107e963 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -20,7 +20,7 @@ C.hooks = names([[ battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel 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 + music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle 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.talk world.tod zoom.range @@ -39,6 +39,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index b6e997d82..f9c62320d 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -50,6 +50,7 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -1464,7 +1465,9 @@ function Gen3PartyMenu:drawMember(mon, panel, selected, inset) Font.draw(Font.fit(name, rects.name.width + 16), nx, ny) local lx, ly = at("level") - Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + if LevelDisplay.visible(mon, "party", self.game) then + Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + end -- Gen 3 derives gender from species ratio + personality when the save does -- not carry an explicit field. Use the engine's common derivation instead of diff --git a/src/ui/Gen3SummaryMenu.lua b/src/ui/Gen3SummaryMenu.lua index 9d54ee41b..8e4678f3f 100644 --- a/src/ui/Gen3SummaryMenu.lua +++ b/src/ui/Gen3SummaryMenu.lua @@ -33,6 +33,7 @@ -- screenshot. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Stats = require("src.pokemon.Stats") local Strings = require("src.core.Strings") @@ -962,8 +963,10 @@ function Gen3SummaryMenu:drawHeader() if bandWin then local species = (def and def.name) or tostring(mon.species) putIn(bandWin, species, 0, 1, PLAIN) - putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, - PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, + PLAIN) + end local mark = self:genderMark() if mark then putIn(bandWin, mark, PANEL_GENDER_X, 17, @@ -971,7 +974,9 @@ function Gen3SummaryMenu:drawHeader() end else local _, levelY = self:panelRows() - put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + end end end put(title, COL_RIGHT - Font.width(title), 4, PLAIN) @@ -989,7 +994,9 @@ function Gen3SummaryMenu:drawHeader() return end put(name, 40, 10, PLAIN) - put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + end -- the page name sits on the right of the header, where the cartridge's -- page indicator is put(title, GBA_W - 8 - Font.width(title), 10, PLAIN) @@ -1780,7 +1787,7 @@ function Gen3SummaryMenu:drawFireRed(r) frlgText(self.pages[self.page], 4, 1, header) if not mon then return end local t = r.text or {} - if not detail then + if not detail and LevelDisplay.visible(mon, "summary", self.game) then frlgText("Lv" .. tostring(mon.level or 1), 4, 18, header, true) end frlgText(mon.nickname or def.name or tostring(mon.species), 40, 18, header) diff --git a/src/ui/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 000000000..0092e81c8 --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,16 @@ +-- Shared level-display predicate for native GB/GBA screens. Only literal +-- false hides the complete level label; callers retain status/egg eligibility. +local Runtime = require("src.mods.Runtime") +local LevelDisplay = {} +local function visibleByDefault() + return true +end + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then + return true + end + return Runtime.call("pokemon.level_visible", visibleByDefault, mon, { where = where, game = game }) ~= false +end +return LevelDisplay diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 7b907a617..cc22df2fa 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -12,6 +12,7 @@ local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -981,7 +982,7 @@ function PartyMenu:draw() -- level at column 13 ( tile + digits, PrintLevel) AND the -- status/FNT text at column 17 (PrintStatusCondition), like the -- original rows -- statused mons keep their level display - if isEgg then -- no level row + if isEgg or not LevelDisplay.visible(mon, "party", self.game) then -- no level row elseif mon.level < 100 then HudTiles.tile(0x6E, 104, y) -- Font.draw(tostring(mon.level), 112, y) diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..69941f5f6 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -13,6 +13,7 @@ -- go, and one of four lines about how close it is to hatching. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") -- status_screen.asm PrintMonType prints the type's DISPLAY name from the -- TypeNames table, not the constant: species types are stored as pokered -- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it @@ -359,7 +360,9 @@ function SummaryMenu:drawGen2Header() HudTiles.statusTile(0x74, 64, 0) -- № Font.drawCode(0xF2, 72, 0) -- Font.draw(("%03d"):format(def.dex or 0), 80, 0) - printLevel(14, 0, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 0, mon.level) + end local gender = require("src.pokemon.DayCare").gender(data, mon) if gender == "male" or gender == "female" then Font.draw(gender == "male" and "♂" or "♀", 144, 0) @@ -445,8 +448,10 @@ function SummaryMenu:drawGen2PinkPage() local nextExp = (mon.level or 1) < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - (mon.exp or 0)) or 0 rightAlign(("%d"):format(math.max(0, nextExp)), 160, 104) - Font.draw(Strings("TO"), 112, 112) - printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + Font.draw(Strings("TO"), 112, 112) + printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + end -- FillInExpBar between the $40/$41 end caps, its RIGHT edge pinned at -- column 18 and the bar growing leftward from there: eight tiles from @@ -583,7 +588,9 @@ function SummaryMenu:draw() -- bracket around the name/HP block, and PrintLevel at (14,2). The -- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea -- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280 - printLevel(14, 2, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 2, mon.level) + end drawLineBox(19, 1, 6, 10) -- same as the pink page above: the HP palette covers the whole screen, so -- the fill stays grey and the zone pass colours it @@ -641,8 +648,10 @@ function SummaryMenu:draw() local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) - HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) - printLevel(16, 6, math.min(100, mon.level + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) + printLevel(16, 6, math.min(100, mon.level + 1)) + end -- Gen2's stats screen carries the same exp bar the battle HUD does -- (stats_screen.asm calls FillInExpBar too). It lands on DrawLineBox's -- bottom run, which is exactly the eight tiles PlaceExpBar writes. diff --git a/tests/engine/level_visible.lua b/tests/engine/level_visible.lua new file mode 100644 index 000000000..c575768b0 --- /dev/null +++ b/tests/engine/level_visible.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/level_visible/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +-- Public registration is executable proof for the hook coverage audit. +local off = f.exports.api.hooks:wrap("pokemon.level_visible", function(next) + return next() +end) +dofile("tests/level_visible/cases.lua").run(f) +off() +f.close() +f.T.finish("portable pokemon.level_visible") diff --git a/tests/engine/level_visible_contract.lua b/tests/engine/level_visible_contract.lua new file mode 100644 index 000000000..dc6dd0a54 --- /dev/null +++ b/tests/engine/level_visible_contract.lua @@ -0,0 +1,39 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Display = require("src.ui.LevelDisplay") +local Capabilities = require("src.mods.Capabilities") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local supported = Version.generation(version) <= 3 + T.eq( + Capabilities.supports("hooks", "pokemon.level_visible", version), + supported, + version .. " native capability" + ) + if supported then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + dofile("tests/level_visible/native.lua").run(f) + dofile("tests/level_visible/cases.lua").run(f) + f.close() + end + local f = Fixture.new(".", "gen2", version, 3, "unified", true) + local calls = 0 + f.patch(Runtime, "call", function() + calls = calls + 1 + end) + local opaque = setmetatable({}, { + __index = function() + error("no-mod path touched mon") + end, + }) + T.eq(Display.visible(opaque, "party", opaque), true, "unsubscribed default") + T.eq(calls, 0, "no-mod hot path skips dispatch and record adaptation") + f.close() + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible capability and SDK modes") diff --git a/tests/engine/level_visible_renderers.lua b/tests/engine/level_visible_renderers.lua new file mode 100644 index 000000000..e2c010418 --- /dev/null +++ b/tests/engine/level_visible_renderers.lua @@ -0,0 +1,289 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Font, Hud = require("src.render.Font"), require("src.render.HudTiles") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +-- Upvalue leaf spies require interpreted calls: LuaJIT can retain old leaf closures. +if jit then + jit.off(Battle.drawHUDs, true) +end +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + if Version.generation(version) <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local game, mon, e = f.game, f.mon, f.exports + game.save = require("src.core.SaveData").newGame() + game.save.party = { mon } + mon.nickname, mon.hp, mon.stats.hp = "SCOUT", 60, 60 + f.result.loader.game = game + f.patch(love.graphics, "polygon", function() end) + f.patch(love.graphics, "getScissor", function() end) + Font.load(game.data) + local rows = {} + local function text(s, x, y) + rows[#rows + 1] = { text = tostring(s), x = x, y = y } + return Font.width(s) + end + f.patch(Font, "draw", text) + f.patch(Font, "drawCode", function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + for _, key in ipairs({ "tile", "statusTile" }) do + f.patch(Hud, key, function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + end + local function capture(draw) + rows = {} + draw() + return rows + end + local function has(list, needle) + for _, row in ipairs(list) do + if row.text and row.text:find(needle, 1, true) then + return true + end + end + return false + end + local function levelCount(list) + local count = 0 + for _, row in ipairs(list) do + if + row.text + and ( + row.text == "42" + or row.text == "43" + or row.text == "100" + or row.text == "Lv42" + or row.text == "Lv100" + ) + then + count = count + 1 + end + end + return count + end + local b = Battle.newWild(game, "FIXMON_C", 42) + b.player.isPlayer, b.enemy.isPlayer = true, false + b:syncSides() + b.phase, b.frame, b.shown, b.fx = "contract", 0, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + -- The classic compositor captures its tile leaf during module load. + for i = 1, 100 do + local name, value = debug.getupvalue(Battle.drawHUDs, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(Battle.drawHUDs, i, Hud.tile) + local close = f.close + f.close = function() + debug.setupvalue(Battle.drawHUDs, i, value) + close() + end + break + end + end + local nativeSeen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(ctx.game, game, "native producer supplies live Game") + T.check(m == mon or m == b.enemy.mon or m == b.player.mon, "native producer supplies actual mon") + if ctx.where == "battle.player" or ctx.where == "battle.enemy" then + T.eq( + ctx.where, + m == b.enemy.mon and "battle.enemy" or "battle.player", + "native side, including partners" + ) + end + nativeSeen[ctx.where] = (nativeSeen[ctx.where] or 0) + 1 + return next(m, ctx) + end, 1000) + local function cycle(label, surfaces, draw, expected) + for _, where in ipairs(surfaces) do + e.hide(where, false) + end + local stock = capture(draw) + T.eq(levelCount(stock), expected, version .. " " .. label .. " native level count") + nativeSeen = {} + for _, where in ipairs(surfaces) do + e.hide(where, true) + end + local hidden = capture(draw) + T.eq(levelCount(hidden), 0, label .. " omits level digits") + for _, row in ipairs(hidden) do + T.check(row.code ~= 0x6E and row.code ~= 0x70, label .. " no orphan level/TO glyph") + T.check(row.text ~= "TO" and not (row.text or ""):find("Lv42", 1, true), "no orphan label") + end + for _, where in ipairs(surfaces) do + T.check((nativeSeen[where] or 0) > 0, label .. " producer identifies " .. where) + e.hide(where, false) + end + T.check(has(hidden, "SCOUT") or has(hidden, b.player.name), label .. " nickname remains") + local restored = capture(draw) + T.same(restored, stock, label .. " restores exact printed rows") + end + if Version.generation() <= 2 then + cycle("classic battle", { "battle.player", "battle.enemy" }, function() + b:drawHUDs(0) + end, 2) + cycle("wide battle", { "battle.player", "battle.enemy" }, function() + local wide = require("src.battle.WideBattle") + wide.gen2Panel(b, b.player, 9 * 8, 7 * 8, true, false) + wide.gen2Panel(b, b.enemy, 0, 0, false, false) + end, 2) + -- One surface can hide without hiding the other side. + e.hide("battle.enemy", true) + T.eq( + levelCount(capture(function() + b:drawHUDs(0) + end)), + 1, + "per-side native battle omission" + ) + e.hide("battle.player", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + b:drawHUDs(0) + end), + "PSN" + ), + "hidden levels retain battle status" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local party = require("src.ui.PartyMenu").new(game) + cycle("party", { "party" }, function() + party:draw() + end, 1) + mon.level = 100 + cycle("party level 100", { "party" }, function() + party:draw() + end, 1) + mon.level = 42 + local summary = require("src.ui.SummaryMenu").new(game, mon) + for page = 1, Version.isGen2() and 3 or 2 do + summary.page = page + local count = Version.isGen2() and (page == 1 and 2 or 1) or 1 + cycle("summary page " .. page, { "summary" }, function() + summary:draw() + end, count) + end + mon.isEgg = true + local calls = e.seen.calls + capture(function() + party:draw() + end) + T.eq(e.seen.calls, calls, "egg does not query or invent a level") + mon.isEgg = nil + else + cycle("GBA no-art battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + e.hide("battle.player", true) + e.hide("battle.enemy", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + G3.drawHUDs(b, 0) + end), + "PSN" + ), + "GBA fallback preserves status text" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local image = love.graphics.newCanvas(128, 64) + f.patch(require("src.render.Assets"), "image", function() + return image + end) + local record = { images = { player = "panel", opponent = "panel" } } + game.data.constants.gen3BattleHud = record + cycle("GBA panel battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + local w = { + name = { x = 8, y = 3, w = 56 }, + level = { x = 64, y = 3 }, + bar = { x = 40, y = 16 }, + label = { x = 24, y = 16 }, + } + record.windows = { player = w, opponent = w } + cycle("GBA native windows", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + record.windows = nil + game.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + b.isDouble = function() + return true + end + local partner = { name = "SCOUT", mon = b.player.mon, isPlayer = true, shownHP = mon.hp } + b.battlerAt = function(self, pos) + return pos == 2 and partner or (pos % 2 == 0 and self.player or self.enemy) + end + cycle("GBA doubles", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 4) + local party = require("src.ui.Gen3PartyMenu").new(game) + local panel = { x = 0, y = 0, wide = true, width = 100, index = 1 } + cycle("GBA party slot", { "party" }, function() + party:drawMember(mon, panel, false) + end, 1) + local summary = require("src.ui.Gen3SummaryMenu").new(game, { mon = mon }) + cycle("GBA summary fallback", { "summary" }, function() + summary:drawHeader() + end, 1) + summary.hasArt = true + cycle("GBA summary art", { "summary" }, function() + summary:drawHeader() + end, 1) + local labels = {} + for i = 1, 20 do + labels[i] = { x = 0, y = 0 } + end + game.data.constants.gen3SummaryWindows = { labels = labels } + cycle("GBA summary windows", { "summary" }, function() + summary:drawHeader() + end, 1) + if version == "firered" then + summary.page = 3 + cycle("FireRed summary", { "summary" }, function() + summary:drawFireRed({}) + end, 1) + summary.moveSelect = true + e.hide("summary", true) + local calls = e.seen.calls + local detailRows = capture(function() + summary:drawFireRed({}) + end) + T.eq(has(detailRows, "Lv42"), false, "move detail native omission") + T.eq(e.seen.calls, calls, "move detail cannot invent a level") + end + end + off() + f.close() + end + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible native renderers") diff --git a/tests/level_visible/cases.lua b/tests/level_visible/cases.lua new file mode 100644 index 000000000..ad20a8643 --- /dev/null +++ b/tests/level_visible/cases.lua @@ -0,0 +1,104 @@ +local M = {} +function M.run(f) + local T, e, mon = f.T, f.exports, f.mon + local api, rows = e.api, {} + if api.supports then + T.eq(api:supports("hooks", "pokemon.level_visible"), true, "native capability") + end + for _, where in ipairs({ "battle.enemy", "battle.player", "party", "summary" }) do + local function observe(label, value) + T.eq(f.visible(where), value, where .. " " .. label) + rows[#rows + 1] = { where = where, label = label, visible = value } + end + observe("native", true) + T.eq(e.seen.context.where, where, "surface context") + T.eq(e.seen.mon.level, 42, "level in every callback format") + if f.api < 3 and f.format ~= "unified" then + T.eq(e.seen.mon, mon, "native mon identity") + T.eq(e.seen.context.game, f.game, "native Game identity") + else + T.eq(e.seen.mon.species, "FIXMON_A", "normalized species") + T.check(e.seen.mon ~= mon, "unified mon record") + end + e.hide(where, true) + observe("hidden", false) + local other = where == "party" and "summary" or "party" + T.eq(f.visible(other), true, "per-surface decision") + e.hide(where, false) + observe("restored", true) + end + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "string", value = "" }, + { name = "table", value = {} }, + }) do + local off = api.hooks:wrap("pokemon.level_visible", function() + return case.value + end, 100) + T.eq(f.visible("party"), true, case.name .. " remains visible") + off() + end + local off = api.hooks:wrap("pokemon.level_visible", function() + return false, true + end, 100) + T.eq(f.visible("party"), false, "only first return controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + order[#order + 1] = "outer" + T.eq(ctx.where, "party", "outer context") + local result = next(m, ctx) + order[#order + 1] = "after" + return result + end, 20) + local inner = api.hooks:wrap("pokemon.level_visible", function(next) + order[#order + 1] = "inner" + return next() + end, 10) + T.eq(f.visible("party"), true, "next() retains arguments") + T.same(order, { "outer", "inner", "after" }, "priority and delegation") + outer() + inner() + e.hide("party", true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("pokemon.level_visible", function(next) + if after then + next() + end + error("intentional level predicate failure") + end, 100) + T.eq(f.visible("party"), false, "error preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("pokemon.level_visible", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.visible("party"), false, "API 3 caches false") + T.eq(e.seen.calls, calls + 1, "API 3 does not replay downstream") + off() + end + e.stop() + e.stop() + local calls = e.seen.calls + T.eq(f.visible("party"), true, "unsubscribe restores default") + T.eq(e.seen.calls, calls, "idempotent unsubscribe") + api.hooks:wrap("pokemon.level_visible", function() + return false + end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "level_peer") + f.result.loader.hooks:removeOwner("portable_level_visible") + T.eq(f.visible("party"), true, "owner cleanup restores default") + T.eq(peerCalls, 1, "owner cleanup preserves peer") + peer() + return rows +end +return M diff --git a/tests/level_visible/fixture.lua b/tests/level_visible/fixture.lua new file mode 100644 index 000000000..aaecf5211 --- /dev/null +++ b/tests/level_visible/fixture.lua @@ -0,0 +1,57 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version, Json = require("src.core.GameVersion"), require("src.link.Json") + Version.set(version) + local files = {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_level_visible/" .. name, "rb")) + files["mods/portable_level_visible/" .. name] = file:read("*a") + file:close() + end + local actualFormat = api >= 3 and "unified" or (backend == "gen1" and "native" or format) + local manifest = Json.decode(files["mods/portable_level_visible/manifest.json"]) + manifest.api, manifest.callback_format = api, backend == "gen2" and format or nil + files["mods/portable_level_visible/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_level_visible" }, { + data = data, + generation = Version.generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local game = { data = data, save = { options = {} } } + local mon = require("src.pokemon.Pokemon").new(data, "FIXMON_A", 42) + local e = result.loader.exports.portable_level_visible + return { + T = T, + game = game, + mon = mon, + data = data, + result = result, + exports = e, + api = api, + format = actualFormat, + backend = backend, + patch = patch, + visible = function(where, value) + return require("src.ui.LevelDisplay").visible(value or mon, where, game) + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/level_visible/native.lua b/tests/level_visible/native.lua new file mode 100644 index 000000000..d73318787 --- /dev/null +++ b/tests/level_visible/native.lua @@ -0,0 +1,191 @@ +-- Exercise the actual battle producers, spying only at font/tile leaves. +local M = {} +local function upvalue(owner, wanted) + local visited = {} + local function search(fn) + if type(fn) ~= "function" or visited[fn] then + return + end + visited[fn] = true + 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 + local found = search(value) + if found then + return found + end + end + end + for _, fn in pairs(owner) do + local found = search(fn) + if found then + return found + end + end + error("native producer missing: " .. wanted) +end +function M.run(f) + local T, Version = f.T, require("src.core.GameVersion") + local e, mon, game = f.exports, f.mon, f.game + local levels, labels, draw, b = 0, 0 + local restoreLeaf + local function ink(text) + text = tostring(text) + if text == "42" or text == "Lv42" or text == "42" then + levels = levels + 1 + end + if text:find("Lv42", 1, true) or text:find("42", 1, true) then + labels = labels + 1 + end + return 16 + end + if f.backend == "gen1" and Version.generation() == 3 then + require("src.import.gba.versions").select(Version.get()) + local Font = require("src.ui.game3.frlg_font") + f.patch(Font, "draw", ink) + f.patch(Font, "drawGlyph", function(code) + if code == Font.CHAR_LV_2 then + labels = labels + 1 + end + end) + f.patch(Font, "advance", function() + return 6 + end) + -- This is the private routine Healthbox.draw uses for singles and doubles; + -- keep it intact, with a real native state/session context. + local level = upvalue(require("src.core.game3.battle.healthbox"), "draw_level") + draw = function() + for _, side in ipairs({ "enemy", "player" }) do + level(mon.level, 0, 0, 0, mon, side, { session = { game = game } }) + end + end + else + local Font = require("src.render.Font") + Font.load(game.data) + f.patch(Font, "draw", ink) + f.patch(Font, "drawCode", function() end) + f.patch(Font, "drawBox", function() end) + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function(code) + if code == 0x6E then + labels = labels + 1 + end + end) + local class = require( + f.backend == "gen1" and Version.generation() == 2 and "src.ui.gen2.BattleState" + or "src.battle.BattleState" + ) + if jit and class.drawHUDs then + jit.off(class.drawHUDs, true) + end + -- BattleState captures this font/tile leaf when its module is loaded. + -- The SDK may already have loaded it before we install the recorder. + for i = 1, 100 do + local name, value = debug.getupvalue(class.drawHUDs or function() end, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(class.drawHUDs, i, Hud.tile) + restoreLeaf = function() + debug.setupvalue(class.drawHUDs, i, value) + end + break + end + end + b = setmetatable({ + game = game, + data = game.data, + phase = "contract", + frame = 0, + fx = {}, + ballRows = {}, + shown = {}, + showEnemyHud = true, + showPlayerHud = true, + player = { name = "SCOUT", mon = mon, shownHP = mon.hp, isPlayer = true }, + enemy = { name = "FOE", mon = mon, shownHP = mon.hp, isPlayer = false }, + }, { __index = class }) + if f.backend == "gen1" and Version.generation() == 2 then + local Chrome = require("src.ui.gen2.Chrome") + f.patch(Chrome, "printThrough", ink) + f.patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = function() end, + drawPlayerFrame = function() end, + drawExpBar = function() end, + } + b.activeMon = function() + return mon + end + b.name = function() + return "SCOUT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() end + b.genderSymbol = function() end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return mon.hp + end + b.drawHpBar = function() end + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local seen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(m, mon, "native battle supplies actual Pokemon") + T.eq(ctx.game, game, "native battle supplies current Game") + seen[ctx.where] = true + return next(m, ctx) + end, 1000) + local rows = {} + for _, phase in ipairs({ + { enemy = false, player = false, count = 2 }, + { enemy = true, player = false, count = 1 }, + { enemy = false, player = true, count = 1 }, + { enemy = true, player = true, count = 0 }, + { enemy = false, player = false, count = 2 }, + }) do + e.hide("battle.enemy", phase.enemy) + e.hide("battle.player", phase.player) + levels, labels, seen = 0, 0, {} + draw() + T.eq(levels, phase.count, "native battle level digits") + T.eq(labels, phase.count, "native battle level labels") + T.check(seen["battle.enemy"] and seen["battle.player"], "native battle surfaces") + rows[#rows + 1] = { enemy = phase.enemy, player = phase.player, levels = levels, labels = labels } + end + off() + if restoreLeaf then + restoreLeaf() + end + return rows +end +return M diff --git a/tests/level_visible/worker.lua b/tests/level_visible/worker.lua new file mode 100644 index 000000000..6ddd39985 --- /dev/null +++ b/tests/level_visible/worker.lua @@ -0,0 +1,15 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/level_visible/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = { + native = dofile(root .. "/tests/level_visible/native.lua").run(f), + predicate = dofile(root .. "/tests/level_visible/cases.lua").run(f), +} +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("pokemon.level_visible"), false, "bare Runtime") +bare.T.eq(bare.visible("party"), true, "no-mod default") +bare.close() +print("LEVEL_VISIBLE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable pokemon.level_visible") diff --git a/tools/level-visible-contract-check/check.py b/tools/level-visible-contract-check/check.py new file mode 100644 index 000000000..c1d37b10a --- /dev/null +++ b/tools/level-visible-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged level visibility mod through both engines' real SDK and display predicate.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/level_visible/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LEVEL_VISIBLE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LEVEL_VISIBLE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"level visibility mismatch: {version}" + print(f"PASS {version}: matching native level display predicates across {len(observations[0]['native']) + len(observations[0]['predicate'])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_level_visible/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 97eac5593cbab7e3ff537879a148fb084046982d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:59:50 +0200 Subject: [PATCH 24/86] Add portable auxiliary battle menu actions with native admission guards --- .../workflows/battle-auxiliary-contract.yml | 50 ++++++ docs/api3-portability.md | 2 +- docs/portable-battle-auxiliary.md | 65 +++++++ examples/portable_battle_auxiliary/main.lua | 18 ++ .../portable_battle_auxiliary/manifest.json | 1 + src/battle/BattleState.lua | 5 + src/battle/MenuAuxiliary.lua | 163 ++++++++++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 + tests/battle_auxiliary/fixture.lua | 54 ++++++ tests/battle_auxiliary/shared.lua | 97 +++++++++++ tests/battle_auxiliary/worker.lua | 27 +++ tests/engine/battle_auxiliary.lua | 131 ++++++++++++++ tests/trainer_preparation/fixture.lua | 17 +- .../battle-auxiliary-contract-check/check.py | 60 +++++++ 15 files changed, 684 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/battle-auxiliary-contract.yml create mode 100644 docs/portable-battle-auxiliary.md create mode 100644 examples/portable_battle_auxiliary/main.lua create mode 100644 examples/portable_battle_auxiliary/manifest.json create mode 100644 src/battle/MenuAuxiliary.lua create mode 100644 tests/battle_auxiliary/fixture.lua create mode 100644 tests/battle_auxiliary/shared.lua create mode 100644 tests/battle_auxiliary/worker.lua create mode 100644 tests/engine/battle_auxiliary.lua create mode 100644 tools/battle-auxiliary-contract-check/check.py diff --git a/.github/workflows/battle-auxiliary-contract.yml b/.github/workflows/battle-auxiliary-contract.yml new file mode 100644 index 000000000..612f4ced7 --- /dev/null +++ b/.github/workflows/battle-auxiliary-contract.yml @@ -0,0 +1,50 @@ +name: portable battle.menu_auxiliary contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-auxiliary-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native auxiliary battle dispatch and SDK contracts + run: | + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 auxiliary battle contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-auxiliary-contract + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-auxiliary-contract/api1-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-auxiliary-contract/api2-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-auxiliary-contract/api2-unified.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-auxiliary-contract + path: /tmp/battle-auxiliary-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 8cf430692..e324d5c36 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ 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, -`battle.menu_auxiliary` can be subscribed to but reports unavailable here. Unknown +`battle.caught_marker_visible` 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. diff --git a/docs/portable-battle-auxiliary.md b/docs/portable-battle-auxiliary.md new file mode 100644 index 000000000..f84b49cf2 --- /dev/null +++ b/docs/portable-battle-auxiliary.md @@ -0,0 +1,65 @@ +# Portable auxiliary battle action + +`battle.menu_auxiliary` lets a mod handle START at a settled native player +command menu. It avoids replacing a generation-specific battle controller just +to open an auxiliary tool. Native menu navigation continues when no subscriber +exists or the first hook result is anything other than literal `true`. + +```lua +local mod = ... +mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + if myToolIsAvailable(context.kind) then + openMyTool(game) + return true + end + return next(game, context) +end) +``` + +Arguments are the Game handle and `{kind = "wild" | "trainer"}`. Gen2 retains +the opaque Game handle in all SDK modes; Gen1 API 3 can provide its existing +forwarding context view. The small context uses the normal unified record +adapter. Consumers should use public APIs and supported UI methods rather than +inspect private Game/controller fields. + +Native dispatch covers Gen2Recomped's GB, GBA and DS command menu paths. +Availability does not promise every battle variant. The boundary excludes +covered screens, link/safari/tutorial/automatic/facility variants, forced +moves, pending double choices, faint/replacement states, queued work, and +unfinished HP/status/animation presentation. Numerical counters at zero are +idle in Gen2's controller. Eligible double battles use the current player +actor and allow absent slots while requiring a live opponent. + +Eligibility is sampled before native presentation catch-up. A START press +cannot become eligible halfway through an update just because HP/status was +snapped to its final value. Dispatch rechecks the current owner/session. If a +hook changes the session, opens an overlay or invalidates the menu, that +update stops using the old menu input even if the wrapper returns false or +raises. A normal false/default result retains native navigation exactly once. +This is an input boundary, not a checkpoint/reconstruction API. + +## Validation + +The unchanged `examples/portable_battle_auxiliary` consumer runs through both +real SDKs and native command-menu update methods for Red, Yellow, Gold, +FireRed and Emerald. Native API 1/2 and API 3 are compared directly. Gen2's +additional unified API 2 mode is compared against Gen1's native API 2; Gen1 +does not implement the `callback_format` manifest extension. The reference is +pinned to clean Gen1 commit `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The shared fixture reuses native trainer construction from the previous +trainer contract with a different consumer; it constructs native battle +objects and settles their command menus. GBA uses the real owned battle +runtime and presentation state, not a replacement hook producer. Native Gen1 +safety regressions also run unchanged. The Gen2 suite tests every game and +SDK mode, including native fallthrough, wrapper failures, stale sessions, +covered/unsafe states and rejection before presentation catch-up. + +```sh +luajit tests/engine/battle_auxiliary.lua +python3 tools/battle-auxiliary-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary.json +``` + +These are ROM-free behavior tests. Native cartridge boot and GPU frame checks +are separate from the shared command-menu contract. diff --git a/examples/portable_battle_auxiliary/main.lua b/examples/portable_battle_auxiliary/main.lua new file mode 100644 index 000000000..d509dd253 --- /dev/null +++ b/examples/portable_battle_auxiliary/main.lua @@ -0,0 +1,18 @@ +-- Set claim(true) when a custom auxiliary battle UI is available. Its opener +-- belongs in onOpen; return true only when taking ownership of this input. +local mod = ... +local claimed, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.claim(value) + claimed = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + seen.calls, seen.game, seen.context = seen.calls + 1, game, context + if claimed then + if mod.exports.onOpen then + mod.exports.onOpen(game, context) + end + return true + end + return next(game, context) +end) diff --git a/examples/portable_battle_auxiliary/manifest.json b/examples/portable_battle_auxiliary/manifest.json new file mode 100644 index 000000000..ae5e702db --- /dev/null +++ b/examples/portable_battle_auxiliary/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_auxiliary","name":"Portable Battle Auxiliary Action","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a3031115d..e316853d8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -20,6 +20,7 @@ local Experience = require("src.battle.Experience") local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") +local MenuAuxiliary = require("src.battle.MenuAuxiliary") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -4275,6 +4276,7 @@ function BattleState:touchpressed(_, px, py) return true end function BattleState:update(dt) + local auxiliary = MenuAuxiliary.request(self) self:tickFx() -- one sample a second into the fight, while it is still on screen self._stadiumReportTicks = (self._stadiumReportTicks or 0) + 1 @@ -4493,6 +4495,9 @@ function BattleState:update(dt) self:resolveTurn(locked) return end + -- Capture eligibility before presentation catch-up can hide an unfinished + -- HP/status drain. Dispatch only at the ordinary player-decision boundary. + if auxiliary and MenuAuxiliary.dispatch(self, auxiliary) then return end -- PLATINUM'S ACTION MENU IS NOT A 2x2, and walking it as one is what a -- player feels as the cursor jumping about. -- diff --git a/src/battle/MenuAuxiliary.lua b/src/battle/MenuAuxiliary.lua new file mode 100644 index 000000000..2364a1c7d --- /dev/null +++ b/src/battle/MenuAuxiliary.lua @@ -0,0 +1,163 @@ +-- Public START action at an idle player command menu, before native navigation. +local Runtime = require("src.mods.Runtime") +local M = {} +local BUSY = { + "current", + "nextInsert", + "pendingHit", + "waitingUI", + "waitingSound", + "draining", + "animPlaying", + "growIn", + "shrinkOut", + "ghostReveal", + "mimicCtx", + "result", + "gen3BallPlaying", + "gen4TouchPress", +} +local COUNTERS = { "waitFrames", "hitSfxWait", "introSlide" } +local VARIANTS = { + "safari", + "ghost", + "scopeReveal", + "demo", + "pokedudeDemo", + "noCatch", + "palace", + "arena", + "ally", + "factory", + "pyramid", + "pike", + "battleTower", + "trainerTower", + "secretBase", + "eReader", +} +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end +local function defaultAction() + return false +end +function M.ready(battle) + local game = battle and battle.game + if + not game + or not game.save + or not game.data + or battle.phase ~= "menu" + or (battle.kind ~= "wild" and battle.kind ~= "trainer") + then + return false + end + local stack = game.stack + if not stack or not stack.top or stack:top() ~= battle then + return false + end + if + nonempty(battle.queue) + or nonempty(battle.pendingActions) + or nonempty(battle.chosenTargets) + or (battle.afterQueue and battle.afterQueue ~= "menu") + then + return false + end + for _, field in ipairs(VARIANTS) do + if battle[field] then + return false + end + end + for _, field in ipairs(BUSY) do + if battle[field] then + return false + end + end + for _, field in ipairs(COUNTERS) do + local value = battle[field] + if value and value ~= 0 then + return false + end + end + if battle.showEnemyTrainer or battle.showPlayerBack or battle.enemySendingOut then + return false + end + local actor = battle.choosingBattler and battle:choosingBattler() or battle.player + if + not actor + or actor.ally + or not actor.mon + or (actor.mon.hp or 0) <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(actor)) + or actor.bwSkyDropped + then + return false + end + local count = battle.isDouble and battle:isDouble() and 4 or 2 + local enemyStanding = false + for position = 0, count - 1 do + local battler = battle.battlerAt and battle:battlerAt(position) + or (position == 0 and battle.player or position == 1 and battle.enemy) + if battler and battler.mon then + local mon = battler.mon + if + (mon.hp or 0) <= 0 + or battler.fainted + or battler.faintQueued + or battler.draining + or battler.drainFloor ~= nil + or battler.drainHold ~= nil + or battler.shownHP ~= mon.hp + or battler.shownStatus ~= mon.status + then + return false + end + if position % 2 == 1 then + enemyStanding = true + end + elseif count == 2 then + return false + end + end + return enemyStanding +end +function M.request(battle) + if not Runtime.wantsHook("battle.menu_auxiliary") then + return + end + local game = battle.game + if not game or not game.input or not game.input:wasPressed("start") or not M.ready(battle) then + return + end + return { + game = game, + save = game.save, + data = game.data, + stack = game.stack, + session = game.sessionStartedAt, + kind = battle.kind, + } +end +local function current(battle, token) + local game = token.game + return battle.game == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and game.sessionStartedAt == token.session + and battle.kind == token.kind + and M.ready(battle) +end +function M.dispatch(battle, token) + -- Retire input captured for an owner/session that changed during native FX. + if not current(battle, token) then + return true + end + local result = Runtime.call("battle.menu_auxiliary", defaultAction, token.game, { kind = token.kind }) + -- A pushed overlay or changed session must never receive the same input as + -- the retired battle menu, even if the wrapper returned false or raised. + return result == true or not current(battle, token) +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 76d17a339..b2ad7c7af 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update + battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index b3107e963..c3a078874 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,6 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ + battle.menu_auxiliary battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -39,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, diff --git a/tests/battle_auxiliary/fixture.lua b/tests/battle_auxiliary/fixture.lua new file mode 100644 index 000000000..267ec0a8e --- /dev/null +++ b/tests/battle_auxiliary/fixture.lua @@ -0,0 +1,54 @@ +local M = {} +function M.new(root, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_battle_auxiliary/" .. name, "rb")) + files["mods/portable_battle_auxiliary/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_battle_auxiliary/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_auxiliary/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_battle_auxiliary" }, { + data = data, + generation = require("src.core.GameVersion").generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { data = data, save = require("src.core.SaveData").newGame(), sessionStartedAt = 1 } + game.input = setmetatable({}, { __index = require("src.core.Input") }) + game.input:init() + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 20) } + result.loader.game = game + require("src.render.Font").load(data) + local b = require("src.battle.BattleState").newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "menu", 0, {} + b.gen4CursorActive = true + b.menuIndex, b.moveIndex = 1, 1 + game.stack.states = { b } + local exports = result.loader.exports.portable_battle_auxiliary + return { + T = T, + game = game, + battle = b, + exports = exports, + result = result, + api = api, + press = function(start, right) + local direction = require("src.core.GameVersion").generation() >= 4 and "down" or "right" + game.input.pressed = { start = start, [direction] = right } + b:update(1 / 60) + end, + close = function() + result.release() + end, + } +end +return M diff --git a/tests/battle_auxiliary/shared.lua b/tests/battle_auxiliary/shared.lua new file mode 100644 index 000000000..ae233d56d --- /dev/null +++ b/tests/battle_auxiliary/shared.lua @@ -0,0 +1,97 @@ +-- Reuse native backend construction from trainer admission; use a different +-- unchanged consumer and no trainer hook, then settle the actual native menu. +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + "portable_battle_auxiliary" + ) + f.start() + local battle, game = assert(f.battle()), f.game + local update, index, ready + if backend == "gen1" and f.generation == 3 then + local B = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Anim = require("src.core.game3.battle.anim") + local State = require("src.core.game3.battle.state") + B.reset() + B._active, B._st = true, battle + B._adapter = require("src.core.game3.battle.adapter").new(battle) + B._phase, B._auto, B._headless, B._fade = "command", false, false, false + Ui.reset({ headless = false }) + Ui.bindState(battle, game.session) + Ui._headless = false + Ui.openMenu() + Anim.reset({ headless = true }) + for id = 0, 1 do + local b, p = State.battler(battle, id), Anim.present(id) + Anim.setShown(id, b) + p.displayHp, p.displayMaxHp = b.mon.hp, b.mon.maxHp + p.displayStatus = b.status or b.mon.status or b.mon.status1 + end + update = function() + B.update(0, game) + end + index = function(value) + if value then + Ui._menuIndex = value + end + return Ui._menuIndex + end + ready = function() + return B._phase == "command" + end + elseif backend == "gen1" and f.generation == 2 then + local screen = game.stack:top() + assert(screen and screen.battle == battle, "actual Gen1 Gold battle screen missing") + screen.phase, screen.slideFrame, screen.queue = "menu", 1000, {} + screen.showPlayerHud, screen.showEnemyHud = true, true + screen.ballRows = { player = false, enemy = false } + screen.shownMon = { player = battle.player, enemy = battle.enemy } + screen.shownHp = { player = battle.player.hp, enemy = battle.enemy.hp } + screen.shownStatus = { player = false, enemy = false } + screen.shownExp = 0 + update = function() + screen:update(0) + end + index = function(value) + if value then + screen.menuIndex = value + end + return screen.menuIndex + end + ready = function() + return screen:menuAuxiliarySafe() + end + else + battle:syncSides() + battle.phase, battle.frame, battle.queue = "menu", 0, {} + battle.gen4CursorActive = true + battle.checkpointOrigin = { kind = "trainer_encounter" } + game.stack.states = { battle } + update = function() + battle:update(0) + end + index = function(value) + if value then + battle.menuIndex = value + end + return battle.menuIndex + end + ready = function() + if backend == "gen2" then + return require("src.battle.MenuAuxiliary").ready(battle) + end + return require("src.battle.BattleSafety").inspect(game, battle) == true + end + end + f.update, f.index, f.ready = update, index, ready + return f +end +return M diff --git a/tests/battle_auxiliary/worker.lua b/tests/battle_auxiliary/worker.lua new file mode 100644 index 000000000..2f5ef22f3 --- /dev/null +++ b/tests/battle_auxiliary/worker.lua @@ -0,0 +1,27 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new(root, backend, version, api, format) +local e, T = f.exports, f.T +T.eq(f.ready(), true, "actual native menu settled") +local rows = {} +for _, claim in ipairs({ false, true, false }) do + e.claim(claim) + f.index(1) + f.game.input.pressed = { start = true, right = true } + local calls = e.seen.calls + f.update() + T.eq(e.seen.calls, calls + 1, "one native dispatch") + T.eq(f.index(), claim and 1 or 2, "native claim/fallthrough") + T.eq(e.seen.context.kind, "trainer", "shared trainer context") + rows[#rows + 1] = { claim = claim, index = f.index(), kind = e.seen.context.kind } +end +e.stop() +f.index(1) +f.game.input.pressed = { start = true, right = true } +local calls = e.seen.calls +f.update() +T.eq(e.seen.calls, calls, "unsubscribe stops native hook") +T.eq(f.index(), 2, "unsubscribe preserves native navigation") +f.close() +print("BATTLE_AUXILIARY_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable battle.menu_auxiliary shared native contract") diff --git a/tests/engine/battle_auxiliary.lua b/tests/engine/battle_auxiliary.lua new file mode 100644 index 000000000..2502dc60e --- /dev/null +++ b/tests/engine/battle_auxiliary.lua @@ -0,0 +1,131 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = dofile("tests/battle_auxiliary/fixture.lua") +local Version, Auxiliary = require("src.core.GameVersion"), require("src.battle.MenuAuxiliary") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", version, mode[1], mode[2]) + local b, game, e = f.battle, f.game, f.exports + T.eq(e.api:supports("hooks", "battle.menu_auxiliary"), true, version .. " capability") + T.eq(Auxiliary.ready(b), true, "native command menu ready") + f.press(false, true) + T.eq(e.seen.calls, 0, "no START leaves hook idle") + T.eq(b.menuIndex, 2, "ordinary native navigation") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, 1, "START invokes native hook once") + T.eq(b.menuIndex, 2, "unclaimed START preserves native navigation") + T.eq(e.seen.game, game, "opaque native Game") + T.same(e.seen.context, { kind = "wild" }, "data-only native context") + e.claim(true) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 1, "claimed input skips native menu navigation") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + return row.value + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 2, "only literal true claims START") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function(next) + if after then + next() + end + error("intentional auxiliary failure") + end, 100) + b.menuIndex = 1 + local calls = e.seen.calls + f.press(true, true) + T.eq(b.menuIndex, 1, "wrapper failure preserves downstream claim") + T.eq(e.seen.calls, calls + 1, "failure does not repeat downstream") + off() + end + -- Presentation is caught up during native update; an unsettled frame must + -- not turn into an eligible START action halfway through that same update. + local calls = e.seen.calls + b.player.shownHP = b.player.mon.hp - 1 + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsettled HP rejected before catch-up") + T.eq(b.menuIndex, 2, "native catch-up/navigation still runs") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls + 1, "fresh START after settling works") + local bad = { + { owner = b, key = "kind", value = "link" }, + { owner = b, key = "safari", value = {} }, + { owner = b, key = "pokedudeDemo", value = true }, + { owner = b, key = "palace", value = true }, + { owner = b, key = "phase", value = "moveSelect" }, + { owner = b, key = "queue", value = { {} } }, + { owner = b, key = "pendingActions", value = { [0] = { kind = "move" } } }, + { owner = b, key = "animPlaying", value = true }, + { owner = b, key = "waitFrames", value = 1 }, + { owner = b, key = "introSlide", value = 1 }, + { owner = b, key = "waitingUI", value = true }, + { owner = b.player, key = "mustRecharge", value = true }, + { owner = b.player, key = "shownStatus", value = "PSN" }, + { owner = b.player, key = "drainFloor", value = 0 }, + { owner = game.stack, key = "states", value = { b, {} } }, + } + for _, change in ipairs(bad) do + local old = change.owner[change.key] + change.owner[change.key] = change.value + T.eq(Auxiliary.ready(b), false, "unsafe " .. change.key) + game.input.pressed = { start = true } + T.eq(Auxiliary.request(b), nil, "unsafe START does not allocate request") + change.owner[change.key] = old + end + game.input.pressed = { start = true } + local token = assert(Auxiliary.request(b)) + calls = e.seen.calls + game.sessionStartedAt = 2 + T.eq(Auxiliary.dispatch(b, token), true, "retired session input consumed") + T.eq(e.seen.calls, calls, "retired session never dispatches hook") + game.sessionStartedAt = 1 + -- Even a faulty wrapper that opens an overlay and returns false cannot + -- feed that same input through the old native menu. + local overlay = {} + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + game.stack.states = { b, overlay } + return false + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(game.stack:top(), overlay, "custom overlay stays on stack") + T.eq(b.menuIndex, 1, "overlay invalidation prevents stale native fallthrough") + off() + game.stack.states = { b } + e.stop() + e.stop() + calls = e.seen.calls + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + T.eq(b.menuIndex, 2, "unsubscribe restores native menu") + local player, enemy = b.player, b.enemy + b.double = true + b:placeBattler(2, player) + b:placeBattler(0, nil) + b.choosingSlot = 2 + T.eq(Auxiliary.ready(b), true, "double partner can own an otherwise empty player side") + b:placeBattler(1, nil) + T.eq(Auxiliary.ready(b), false, "no live opponent cannot open an auxiliary menu") + b:placeBattler(1, enemy) + T.eq(Auxiliary.ready(b), true, "restored opponent returns to a live decision") + f.close() + local unreadable = setmetatable({}, { + __index = function() + error("no-hook path touched battle") + end, + }) + T.eq(Auxiliary.request(unreadable), nil, "no-mod hot path skips native traversal") + end +end +Version.set(oldVersion) +T.finish("portable battle.menu_auxiliary native dispatch") diff --git a/tests/trainer_preparation/fixture.lua b/tests/trainer_preparation/fixture.lua index 80b3c7987..59047ca47 100644 --- a/tests/trainer_preparation/fixture.lua +++ b/tests/trainer_preparation/fixture.lua @@ -1,20 +1,21 @@ local M = {} -function M.new(root, backend, version, api, format, noMod, failing) +function M.new(root, backend, version, api, format, noMod, failing, consumer) + consumer = consumer or "portable_trainer_preparation" local T = require("tests.modkit") local Version = require("src.core.GameVersion") Version.set(version) local gen = Version.generation() local Json, files = require("src.link.Json"), {} for _, name in ipairs({ "main.lua", "manifest.json" }) do - local file = assert(io.open(root .. "/examples/portable_trainer_preparation/" .. name, "rb")) - files["mods/portable_trainer_preparation/" .. name] = file:read("*a") + local file = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = file:read("*a") file:close() end - local manifest = Json.decode(files["mods/portable_trainer_preparation/manifest.json"]) + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) manifest.api, manifest.callback_format = api, format - files["mods/portable_trainer_preparation/manifest.json"] = Json.encode(manifest) + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) if failing then - files["mods/portable_trainer_preparation/main.lua"] = files["mods/portable_trainer_preparation/main.lua"] + files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] .. '\nerror("intentional entry failure")' end local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() @@ -68,7 +69,7 @@ function M.new(root, backend, version, api, format, noMod, failing) items = {}, } end - local result = T.sdk.loadMods(noMod and {} or { "mods/portable_trainer_preparation" }, { + local result = T.sdk.loadMods(noMod and {} or { "mods/" .. consumer }, { data = data, generation = gen, fs = T.sdk.memfs(noMod and {} or files), @@ -290,7 +291,7 @@ function M.new(root, backend, version, api, format, noMod, failing) generation = gen, result = result, game = game, - exports = result.loader.exports.portable_trainer_preparation, + exports = result.loader.exports[consumer], patch = patch, start = start, starts = function() diff --git a/tools/battle-auxiliary-contract-check/check.py b/tools/battle-auxiliary-contract-check/check.py new file mode 100644 index 000000000..f8c2fbefd --- /dev/null +++ b/tools/battle-auxiliary-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged auxiliary battle mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_auxiliary/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_AUXILIARY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_AUXILIARY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"auxiliary menu mismatch: {version}" + print(f"PASS {version}: matching native command-menu behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_auxiliary/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 3800b634d9f362bde275b63985f82ac49a9c759b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:15:12 +0200 Subject: [PATCH 25/86] Add portable battle move-grid navigation and native controller contracts --- .github/workflows/battle-grid-contract.yml | 51 +++++++++++ docs/portable-battle-navigation.md | 61 +++++++++++++ examples/portable_battle_grid/main.lua | 16 ++++ examples/portable_battle_grid/manifest.json | 1 + src/battle/BattleState.lua | 11 +-- src/battle/MoveNavigation.lua | 52 +++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- tests/battle_auxiliary/shared.lua | 4 +- tests/battle_grid/cases.lua | 95 ++++++++++++++++++++ tests/battle_grid/fixture.lua | 96 +++++++++++++++++++++ tests/battle_grid/worker.lua | 7 ++ tests/engine/battle_grid_navigation.lua | 69 +++++++++++++++ tools/battle-grid-contract-check/check.py | 60 +++++++++++++ 14 files changed, 519 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/battle-grid-contract.yml create mode 100644 docs/portable-battle-navigation.md create mode 100644 examples/portable_battle_grid/main.lua create mode 100644 examples/portable_battle_grid/manifest.json create mode 100644 src/battle/MoveNavigation.lua create mode 100644 tests/battle_grid/cases.lua create mode 100644 tests/battle_grid/fixture.lua create mode 100644 tests/battle_grid/worker.lua create mode 100644 tests/engine/battle_grid_navigation.lua create mode 100644 tools/battle-grid-contract-check/check.py diff --git a/.github/workflows/battle-grid-contract.yml b/.github/workflows/battle-grid-contract.yml new file mode 100644 index 000000000..8ff91ed2b --- /dev/null +++ b/.github/workflows/battle-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable battle.move_grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-grid-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native move navigation dispatch and SDK contracts + run: | + luajit tests/engine/battle_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 move navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-grid-contract + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-grid-contract/api1-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-grid-contract/api2-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-grid-contract/api2-unified.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-grid-contract + path: /tmp/battle-grid-contract + if-no-files-found: ignore diff --git a/docs/portable-battle-navigation.md b/docs/portable-battle-navigation.md new file mode 100644 index 000000000..2de4a3ef2 --- /dev/null +++ b/docs/portable-battle-navigation.md @@ -0,0 +1,61 @@ +# Portable battle move navigation + +`battle.move_grid_navigation` selects native move-menu navigation without +replacing a battle update method. The callback receives the actual opaque +battle/screen handle. Only a first result of literal `true` enables the grid; +`false`, `nil`, zero and other values select the list. `next(battle)` asks for +the host layout's normal mode. Unsubscribing restores that mode. + +```lua +local mod = ... +mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + if myOptions.useMoveGrid then return true end + return next(battle) +end) +``` + +The native default is a list for classic GB and a grid for GBA/DS and Gen2's +wide layout. As in Gen1Recomp's original GB battle, the Gen1 wide layout is a +fixed grid and bypasses the predicate. Gen2's Gold-family wide screen allows +an explicit list override; its unwrapped native default remains a grid, while +Gen1's distinct Gold screen defaults to a list. Portable custom UIs should +choose their desired mode explicitly rather than infer host-private fields. + +Grid slots are ordered `1 2 / 3 4`. Missing cells retain the current slot. +Single-battle grids wrap between row/column peers. GBA doubles use the native +clamped player controller: left at the left edge and up at the top edge stay +put. This corrects Gen2's previous generic wrapping at GBA doubles edges and +matches Gen1's native GBA controller. DS retains its existing grid behavior. +The list uses native up/down wrapping. A/B/SELECT stay with their normal +handlers; the same decision also applies to Gen2's Mimic copy menu. + +Both native and unified callback formats keep the battle handle opaque. +Subscribing does not normalize engine-private controller fields. Use public +snapshots/actions for gameplay data and input actions. Availability is reported +for the actual shared native producer in Gen1–5. + +## Validation + +The unchanged `portable_battle_grid` example is loaded through both real SDKs. +Shared cases drive native move-menu updates for Red, Yellow, Gold, FireRed and +Emerald: all four directions, every occupied slot and every move count from +one to four, in explicit list/grid modes. They also drive the native GBA +doubles input controller and compare its edge and missing-cell behavior. +The Gen1 reference is pinned to clean +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +Native API 1/2 and API 3 are compared directly. Gen2's optional unified API 2 +is compared with Gen1's native API 2, since Gen1 has no `callback_format` +extension. Gen2 additionally tests all fifteen games, native defaults, wide +layout rules, Mimic, wrapper failure recovery, API-3 next caching, unsubscribe +and empty move grids. + +```sh +luajit tests/engine/battle_grid_navigation.lua +python3 tools/battle-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-grid.json +``` + +These are ROM-free native-controller and SDK tests, not cartridge playthroughs +or GPU pixel goldens. This predicate changes navigation; custom grid/list art +remains the mod's responsibility. diff --git a/examples/portable_battle_grid/main.lua b/examples/portable_battle_grid/main.lua new file mode 100644 index 000000000..2a3fc7f21 --- /dev/null +++ b/examples/portable_battle_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local mode, seen = "native", { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.mode(value) + mode = value +end +mod.exports.stop = mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + seen.calls, seen.battle = seen.calls + 1, battle + if mode == "grid" then + return true + end + if mode == "list" then + return false + end + return next(battle) +end) diff --git a/examples/portable_battle_grid/manifest.json b/examples/portable_battle_grid/manifest.json new file mode 100644 index 000000000..c1ce41463 --- /dev/null +++ b/examples/portable_battle_grid/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_grid","name":"Portable Battle Navigation","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e316853d8..996a86d68 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -21,6 +21,7 @@ local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") local MenuAuxiliary = require("src.battle.MenuAuxiliary") +local MoveNavigation = require("src.battle.MoveNavigation") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -365,12 +366,12 @@ end -- its RIGHT. Left and right then did nothing at all, because a vertical list -- has no use for them. The fix is one clause, not new maths: the grid this -- layout already draws is the grid it should navigate. +function BattleState:moveGridNavigation() + return MoveNavigation.grid(self) +end + function BattleState:moveGridNavigate(index, count, input) - if not (self:isWideBattleLayout() or self:gen3Layout() - or self:gen4Layout()) then - return nil - end - return WideBattle.navigate(index, count, input) + return MoveNavigation.navigate(self, index, count, input) end -- The four actions, in the order this layout's grid reads them. Gen 1 goes diff --git a/src/battle/MoveNavigation.lua b/src/battle/MoveNavigation.lua new file mode 100644 index 000000000..6b757a473 --- /dev/null +++ b/src/battle/MoveNavigation.lua @@ -0,0 +1,52 @@ +-- Public choice of navigation mode; battle handles stay opaque to callbacks. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Wide = require("src.battle.WideBattle") +local M = {} +local function nativeGrid(battle) + return battle:isWideBattleLayout() or battle:gen3Layout() or battle:gen4Layout() +end +function M.grid(battle) + -- Gen1's wide GB layout has a fixed grid. Gold's distinct native screen + -- allows wrappers to choose a list even when composed on a wider surface. + if battle:isWideBattleLayout() and not Version.isGen2() then + return true + end + if not Runtime.wantsHook("battle.move_grid_navigation") then + return nativeGrid(battle) + end + return Runtime.call("battle.move_grid_navigation", nativeGrid, battle) == true +end +function M.navigate(battle, index, count, input) + if not M.grid(battle) or count < 1 then + return nil + end + if not (battle:gen3Layout() and battle:isDouble()) then + return Wide.navigate(index, count, input) + end + -- GBA's doubles controller clamps to its current row/column. Singles and + -- the GB mod grid wrap; missing cells retain the current occupied slot. + local target = index + local column, row = (index - 1) % 2, math.floor((index - 1) / 2) + if input:wasPressed("left") then + if column == 1 then + target = index - 1 + end + elseif input:wasPressed("right") then + if column == 0 then + target = index + 1 + end + elseif input:wasPressed("up") then + if row == 1 then + target = index - 2 + end + elseif input:wasPressed("down") then + if row == 0 then + target = index + 2 + end + else + return nil + end + return target >= 1 and target <= count and target or index +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index b2ad7c7af..b1b319721 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.run battle.turn_order catch.rate core.logic_speed core.update + battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.move_grid_navigation battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c3a078874..a3836bd8a 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.menu_auxiliary + battle.menu_auxiliary battle.move_grid_navigation battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -40,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, diff --git a/tests/battle_auxiliary/shared.lua b/tests/battle_auxiliary/shared.lua index ae233d56d..675edc2df 100644 --- a/tests/battle_auxiliary/shared.lua +++ b/tests/battle_auxiliary/shared.lua @@ -1,7 +1,7 @@ -- Reuse native backend construction from trainer admission; use a different -- unchanged consumer and no trainer hook, then settle the actual native menu. local M = {} -function M.new(root, backend, version, api, format) +function M.new(root, backend, version, api, format, consumer) local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( root, backend, @@ -10,7 +10,7 @@ function M.new(root, backend, version, api, format) format, false, false, - "portable_battle_auxiliary" + consumer or "portable_battle_auxiliary" ) f.start() local battle, game = assert(f.battle()), f.game diff --git a/tests/battle_grid/cases.lua b/tests/battle_grid/cases.lua new file mode 100644 index 000000000..8ec5ba52d --- /dev/null +++ b/tests/battle_grid/cases.lua @@ -0,0 +1,95 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + local rows = {} + if e.api.supports then + T.eq(e.api:supports("hooks", "battle.move_grid_navigation"), true, "native capability") + end + -- The exact same consumer drives every slot, direction and missing-cell + -- shape through the native controller. Expected values are contract data. + for _, mode in ipairs({ "grid", "list" }) do + e.mode(mode) + T.eq(f.grid(), mode == "grid", "public mode selection") + T.eq(e.seen.battle, f.handle, "original opaque battle/screen handle") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local expected = index + if mode == "list" then + if dir == "up" then + expected = index > 1 and index - 1 or count + end + if dir == "down" then + expected = index < count and index + 1 or 1 + end + else + if dir == "left" or dir == "right" then + expected = index % 2 == 1 and index + 1 or index - 1 + else + expected = index <= 2 and index + 2 or index - 2 + end + if expected > count then + expected = index + end + end + T.eq(f.press(dir), expected, "native " .. mode .. " " .. count .. "/" .. index .. " " .. dir) + rows[#rows + 1] = { mode = mode, count = count, index = index, direction = dir, target = f.index() } + end + end + end + end + f.count(4) + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + e.mode("grid") + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + if after then + next() + end + error("intentional navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream grid") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + if f.generation == 3 then + f.double(true) + e.mode("grid") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local target = index + if dir == "left" and index % 2 == 0 then + target = index - 1 + elseif dir == "right" and index % 2 == 1 then + target = index + 1 + elseif dir == "up" and index >= 3 then + target = index - 2 + elseif dir == "down" and index <= 2 then + target = index + 2 + end + if target > count then + target = index + end + T.eq(f.press(dir), target, "native GBA doubles edge and sparse row") + rows[#rows + 1] = + { mode = "double", count = count, index = index, direction = dir, target = f.index() } + end + end + end + f.double(false) + end + return rows +end +return M diff --git a/tests/battle_grid/fixture.lua b/tests/battle_grid/fixture.lua new file mode 100644 index 000000000..395d9c1ab --- /dev/null +++ b/tests/battle_grid/fixture.lua @@ -0,0 +1,96 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new( + root, + backend, + version, + api, + format, + "portable_battle_grid" + ) + local b, game = f.battle(), f.game + local Ui, screen + if backend == "gen1" and f.generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui._mode, Ui._moveIndex = "moves", 1 + f.handle = b + f.grid = function() + return Ui.moveGridNavigation() + end + f.index = function(value) + if value then + Ui._moveIndex = value + end + return Ui._moveIndex + end + f.count = function(n) + b.player.mon.moves, b.player.mon.pp, b.player.mon.maxPp = {}, {}, {} + for i = 1, n do + b.player.mon.moves[i] = 33 + b.player.mon.pp[i] = 35 + b.player.mon.maxPp[i] = 35 + end + end + f.double = function(value) + b.double = value + if value then + f.update = function() + Ui.handleInput(game.input) + end + else + f.update = function() + require("src.core.game3.battle").update(0, game) + end + end + end + elseif backend == "gen1" and f.generation == 2 then + screen = game.stack:top() + screen.phase, screen.moveIndex = "moves", 1 + f.handle = screen + f.grid = function() + return screen:moveGridNavigation() + end + f.index = function(value) + if value then + screen.moveIndex = value + end + return screen.moveIndex + end + f.count = function(n) + b.player.moves = {} + for i = 1, n do + b.player.moves[i] = "SPLASH" + end + end + else + b.phase, b.moveIndex = "moveSelect", 1 + f.handle = b + f.grid = function() + return b:moveGridNavigation() + end + f.index = function(value) + if value then + b.moveIndex = value + end + return b.moveIndex + end + f.count = function(n) + local moves = {} + for i = 1, n do + moves[i] = { id = "FIX_TACKLE", pp = 35 } + end + b.player.curMoves, b.player.mon.moves = moves, moves + end + f.double = function(value) + b.double = value + end + end + f.count(4) + f.press = function(direction) + game.input.pressed = direction and { [direction] = true } or {} + f.update() + return f.index() + end + return f +end +return M diff --git a/tests/battle_grid/worker.lua b/tests/battle_grid/worker.lua new file mode 100644 index 000000000..f2dbeae4c --- /dev/null +++ b/tests/battle_grid/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/battle_grid/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_grid/cases.lua").run(f) +f.close() +print("BATTLE_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.move_grid_navigation native contract") diff --git a/tests/engine/battle_grid_navigation.lua b/tests/engine/battle_grid_navigation.lua new file mode 100644 index 000000000..7818c1e63 --- /dev/null +++ b/tests/engine/battle_grid_navigation.lua @@ -0,0 +1,69 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_grid/fixture.lua") +local Cases = dofile("tests/battle_grid/cases.lua") +local Version = require("src.core.GameVersion") +local Navigation = require("src.battle.MoveNavigation") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local b, e, game = f.battle(), f.exports, f.game + local native = Version.generation() >= 3 + T.eq(f.grid(), native, "native default is unchanged") + Cases.run(f) + if mode[1] >= 3 then + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "API 3 retains downstream grid") + T.eq(e.seen.calls, calls + 1, "API 3 repeated next is cached") + off() + end + e.mode("grid") + b.phase, b.mimicMoves, b.mimicIndex = "mimicSelect", b.player.curMoves, 1 + f.press("down") + T.eq(b.mimicIndex, 3, "native Mimic copy menu uses shared grid") + e.mode("list") + b.mimicIndex = 1 + f.press("down") + T.eq(b.mimicIndex, 2, "native Mimic copy menu can use list") + b.phase = "moveSelect" + if Version.generation() <= 2 then + game.save.options.battleLayout = "wide" + e.mode("list") + local calls = e.seen.calls + T.eq(f.grid(), Version.generation() == 1, "Gen1 wide stays fixed; Gen2 wide can choose list") + if Version.generation() == 1 then + T.eq(e.seen.calls, calls, "Gen1 wide bypasses predicate") + end + game.save.options.battleLayout = "classic" + end + local calls = e.seen.calls + e.stop() + e.stop() + T.eq(f.grid(), native, "unsubscribe restores native mode") + T.eq(e.seen.calls, calls, "unsubscribe stops callbacks") + if Version.isGen3() then + f.double(true) + f.count(4) + f.index(1) + T.eq(f.press("left"), 1, "native GBA double clamps left without wrappers") + T.eq(f.press("up"), 1, "native GBA double clamps top without wrappers") + T.eq(f.press("right"), 2, "native GBA double moves to existing neighbour") + T.eq(f.press("down"), 4, "native GBA double moves to lower row") + f.double(false) + end + local input = setmetatable({}, { + __index = function() + error("empty grid read input") + end, + }) + T.eq(Navigation.navigate(b, 1, 0, input), nil, "empty grid does not read input") + f.close() + end +end +Version.set(old) +T.finish("portable battle.move_grid_navigation native controllers") diff --git a/tools/battle-grid-contract-check/check.py b/tools/battle-grid-contract-check/check.py new file mode 100644 index 000000000..194340128 --- /dev/null +++ b/tools/battle-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged battle-grid mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle navigation mismatch: {version}" + print(f"PASS {version}: matching native move-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5ed6353372be5f70fb7812780bfcea338c1815b9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:26:35 +0200 Subject: [PATCH 26/86] Add portable battle-party grid navigation and native controller contracts --- .github/workflows/party-grid-contract.yml | 51 ++++++++++ docs/portable-party-navigation.md | 68 +++++++++++++ examples/portable_party_grid/main.lua | 16 +++ examples/portable_party_grid/manifest.json | 1 + src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- src/ui/Gen3PartyMenu.lua | 14 +++ src/ui/PartyMenu.lua | 11 ++- src/ui/PartyNavigation.lua | 86 ++++++++++++++++ tests/engine/party_grid_navigation.lua | 101 +++++++++++++++++++ tests/party_grid/cases.lua | 65 ++++++++++++ tests/party_grid/fixture.lua | 109 +++++++++++++++++++++ tests/party_grid/worker.lua | 7 ++ tools/party-grid-contract-check/check.py | 60 ++++++++++++ 14 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/party-grid-contract.yml create mode 100644 docs/portable-party-navigation.md create mode 100644 examples/portable_party_grid/main.lua create mode 100644 examples/portable_party_grid/manifest.json create mode 100644 src/ui/PartyNavigation.lua create mode 100644 tests/engine/party_grid_navigation.lua create mode 100644 tests/party_grid/cases.lua create mode 100644 tests/party_grid/fixture.lua create mode 100644 tests/party_grid/worker.lua create mode 100644 tools/party-grid-contract-check/check.py diff --git a/.github/workflows/party-grid-contract.yml b/.github/workflows/party-grid-contract.yml new file mode 100644 index 000000000..d686447ce --- /dev/null +++ b/.github/workflows/party-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable ui.party.grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: party-grid-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native party navigation dispatch and SDK contracts + run: | + luajit tests/engine/party_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 party navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/party-grid-contract + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/party-grid-contract/api1-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/party-grid-contract/api2-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/party-grid-contract/api2-unified.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/party-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: party-grid-contract + path: /tmp/party-grid-contract + if-no-files-found: ignore diff --git a/docs/portable-party-navigation.md b/docs/portable-party-navigation.md new file mode 100644 index 000000000..e2014cbc5 --- /dev/null +++ b/docs/portable-party-navigation.md @@ -0,0 +1,68 @@ +# Portable battle-party navigation + +`ui.party.grid_navigation` lets a mod select the battle party controller's +native grid policy without replacing a screen's update method. The hook +receives the original opaque party-menu handle in every SDK format. Only +literal `true` opts in; `next(menu)` returns the normal `false` decision. +Removing the wrapper restores the host's existing navigation. + +```lua +local mod = ... +if mod:supports("hooks", "ui.party.grid_navigation") then + mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + if myOptions.usePartyGrid then return true end + return next(menu) + end) +end +``` + +The predicate is battle-only. Field menus, submenus, healing animations, +scripted no-input screens and swap/HP-transfer selection retain their existing +input ownership. Confirm and cancel use the native handlers. GBA also retains +its separate confirm/cancel processing when direction and A/B arrive together. +This hook changes navigation, not the menu art or battle legality checks. + +## Native policies + +GB/Gold-family opt-in navigation treats occupied party entries as a two-column, +row-major grid (`1 2 / 3 4 / 5 6`). Horizontal input chooses the peer in its +row; a missing peer retains the current slot. Vertical input wraps in the same +column, skipping missing cells. Native host menus still differ when the hook +returns false, including whether a Cancel row is present. + +GBA uses spatial navigation from the imported slot icon centers. Horizontal +input chooses the closest vertical peer in the requested column; vertical +input chooses the nearest entry in the same column. No vertical neighbor +selects Cancel. From Cancel, up selects the last occupied entry and down the +first. No horizontal neighbor retains the cursor. On old caches without +imported icon positions, the rendered panel positions provide the fallback. +The active battle's party scope determines occupied entries and Cancel; +internal cursor numbering remains engine-specific and opaque. + +The common single-layout fixture matches Gen1's actual GB, Gold and GBA +controllers. This does not add missing GBA double-layout artwork or require +identical internal screen objects. Other imported GBA geometries are navigated +according to their own positions. DS party screens retain their existing +controller and report this hook unsupported until a native seam is supplied. + +## Validation + +The unchanged `portable_party_grid` example is loaded through both real SDKs. +The checker drives native menu input for Red, Yellow, Gold, FireRed and Emerald, +covering one to six party members, every occupied slot, every direction and +GBA Cancel. Gen1 is pinned clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. API 1/2 native, Gen2 unified API 2 +and API 3 are compared; Gen1 API 2 has no optional unified format. + +The Gen2 suite additionally checks every GB/GBA host version in all four SDK +modes, native defaults, wrapper errors and removal, repeated API-3 `next`, +field/submenu/heal/no-input exclusions, imported and old-cache geometry, +empty parties, battle-local party scope and native input-handler routing. +These are ROM-free SDK/controller checks, not GPU pixel or full-playthrough +validation. + +```sh +luajit tests/engine/party_grid_navigation.lua +python3 tools/party-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/party-grid.json +``` diff --git a/examples/portable_party_grid/main.lua b/examples/portable_party_grid/main.lua new file mode 100644 index 000000000..9fc580cb6 --- /dev/null +++ b/examples/portable_party_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local enabled = false +local seen = { calls = 0 } +local stop = mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + seen.calls, seen.menu = seen.calls + 1, menu + if enabled then + return true + end + return next(menu) +end) +mod.exports.api = mod +mod.exports.seen = seen +mod.exports.grid = function(value) + enabled = value +end +mod.exports.stop = stop diff --git a/examples/portable_party_grid/manifest.json b/examples/portable_party_grid/manifest.json new file mode 100644 index 000000000..ae978eb57 --- /dev/null +++ b/examples/portable_party_grid/manifest.json @@ -0,0 +1 @@ +{"id": "portable_party_grid", "name": "Portable party navigation", "version": "1.0.0", "api": 3, "generations": [1, 2, 3], "entry": "main.lua", "games": ["all"], "affects_link": false} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index b1b319721..52f2f930f 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -32,7 +32,7 @@ add("hooks", "12345", [[ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") -add("hooks", "123", "pokemon.level_visible") +add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index a3836bd8a..c28a85dc8 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.menu_auxiliary battle.move_grid_navigation + battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -40,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["ui.party.grid_navigation"] = { false }, ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index f9c62320d..710527d08 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -48,6 +48,7 @@ -- bounce below is this port's, since the cartridge's is a sprite-callback -- rather than data. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") @@ -1050,6 +1051,10 @@ function Gen3PartyMenu:cancel() self:close() end +function Gen3PartyMenu:gridNavigation() + return PartyNavigation.enabled(self) +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 @@ -1094,6 +1099,15 @@ function Gen3PartyMenu:update(dt) return end + local grid = not (self.switchFrom or self.hpTransferFrom) + and self:gridNavigation() and PartyNavigation.spatial(self, input) + if grid then + self.index = grid + -- GBA handles confirm/cancel after navigation, including combined input. + if input:wasPressed("a") then self:confirm() + elseif input:wasPressed("b") then self:cancel() end + return + end local n = self:slots() if input:wasPressed("down") then self.index = self.index % n + 1 elseif input:wasPressed("up") then self.index = (self.index - 2) % n + 1 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index cc22df2fa..268b2156a 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -9,6 +9,7 @@ -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") @@ -866,6 +867,10 @@ function PartyMenu:cancel() if self.onCancel then self.onCancel() end end +function PartyMenu:gridNavigation() + return PartyNavigation.enabled(self) +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -906,7 +911,11 @@ function PartyMenu:update(dt) return end - if input:wasPressed("up") then + local grid = not (self.swapFrom or self.softboiledFrom) + and self:gridNavigation() and PartyNavigation.rows(self.index, #party, input) + if grid then + self.index = grid + elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 diff --git a/src/ui/PartyNavigation.lua b/src/ui/PartyNavigation.lua new file mode 100644 index 000000000..50faa39d1 --- /dev/null +++ b/src/ui/PartyNavigation.lua @@ -0,0 +1,86 @@ +-- The party handle remains native; only the navigation decision is shared. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.enabled(menu) + if not menu.battle or not Runtime.wantsHook("ui.party.grid_navigation") then + return false + end + return Runtime.call("ui.party.grid_navigation", function() + return false + end, menu) == true +end +function M.rows(index, count, input) + if count < 1 then + return nil + end + local row, col = math.floor((index - 1) / 2), (index - 1) % 2 + if input:wasPressed("left") or input:wasPressed("right") then + local other = row * 2 + (1 - col) + 1 + return other <= count and other or index + end + local step = input:wasPressed("up") and -1 or input:wasPressed("down") and 1 + if not step then + return nil + end + local rows = math.ceil(count / 2) + for offset = 1, rows do + local other = ((row + step * offset) % rows) * 2 + col + 1 + if other <= count then + return other + end + end + return index +end +local function position(menu, i) + local panel = menu:panelFor(i) + if panel.icon then + return panel.icon.x, panel.icon.y + end + -- The fallback art draws a 32-pixel icon at the left of its panel. + return panel.x + 20, panel.y + panel.height / 2 +end +function M.spatial(menu, input) + local dir = input:wasPressed("up") and "up" + or input:wasPressed("down") and "down" + or input:wasPressed("left") and "left" + or input:wasPressed("right") and "right" + if not dir then + return nil + end + local count, cur = #menu:party(), menu.index + local cancel, target = count + 1, cur + if cur == cancel then + if count > 0 then + if dir == "up" then + target = count + elseif dir == "down" then + target = 1 + end + end + elseif cur >= 1 and cur <= count then + local x, y = position(menu, cur) + local best + for i = 1, count do + if i ~= cur then + local tx, ty = position(menu, i) + local dx, dy = tx - x, ty - y + local distance + if + (dir == "left" and dx < 0) + or (dir == "right" and dx > 0) + or (dx == 0 and ((dir == "up" and dy < 0) or (dir == "down" and dy > 0))) + then + distance = math.abs(dy) + end + if distance and (not best or distance < best) then + target, best = i, distance + end + end + end + if not best and (dir == "up" or dir == "down") then + target = cancel + end + end + return target +end +return M diff --git a/tests/engine/party_grid_navigation.lua b/tests/engine/party_grid_navigation.lua new file mode 100644 index 000000000..cf11024af --- /dev/null +++ b/tests/engine/party_grid_navigation.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/party_grid/fixture.lua") +local Cases = dofile("tests/party_grid/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + Version.set(version) + if Version.generation() <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + Cases.run(f) + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 2 or 1, "unwrapped native horizontal navigation") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 6, "unwrapped native vertical navigation") + f.index(1) + local menu, api, game = f.menu, f.exports.api, f.game + local calls = 0 + local off = api.hooks:wrap("ui.party.grid_navigation", function() + calls = calls + 1 + return true + end) + if mode[1] >= 3 then + local outer = api.hooks:wrap("ui.party.grid_navigation", function(next) + next() + return next() + end, 100) + T.eq(f.grid(), true, "API 3 retains grid decision") + T.eq(calls, 1, "API 3 repeated next is cached") + outer() + end + f.count(5) + f.index(1) + menu.submenu, menu.subItems, menu.subIndex = + { { action = "CANCEL" }, { action = "CANCEL" } }, { "A", "B" }, 1 + local before = calls + f.press("down") + T.eq(f.index(), 1, "submenu owns cursor input") + T.eq(menu.subIndex, 2, "submenu uses its own navigation") + T.eq(calls, before, "submenu bypasses grid predicate") + menu.submenu = nil + local mon = game.save.party[1] + menu.heal = { mon = mon, shown = 0 } + before = calls + f.press("right") + T.eq(f.index(), 1, "healing animation owns input") + T.eq(calls, before, "healing bypasses grid predicate") + menu.heal = nil + if f.generation == 3 then + menu.noInput = true + f.press("right") + T.eq(f.index(), 1, "scripted no-input screen retains cursor") + T.eq(calls, before, "no-input bypasses predicate") + menu.noInput = false + local record = game.data.constants.gen3PartyMenu + record.slots[2].icon.y, record.slots[3].icon.y = 40, 80 + T.eq(f.press("right"), 2, "imported sprite coordinates determine spatial peer") + game.data.constants.gen3PartyMenu = nil + f.index(1) + T.eq(f.press("right"), 3, "old-cache fallback uses rendered panel positions") + game.data.constants.gen3PartyMenu = record + f.count(0) + f.index("cancel") + T.eq(f.press("up"), "cancel", "empty GBA party retains cancel") + else + f.count(0) + f.index(1) + T.eq(f.press("right"), 1, "empty GB party retains cursor") + end + f.count(6) + f.index(1) + local confirm, cancel = 0, 0 + local oldConfirm, oldCancel = menu.confirm, menu.cancel + menu.confirm = function() + confirm = confirm + 1 + end + menu.cancel = function() + cancel = cancel + 1 + end + f.press("a") + f.press("b") + T.eq(confirm, 1, "native confirm handler remains reachable") + T.eq(cancel, 1, "native cancel handler remains reachable") + f.press({ right = true, a = true }) + T.eq(confirm, f.generation == 3 and 2 or 1, "combined input retains generation-specific priority") + menu.confirm, menu.cancel = oldConfirm, oldCancel + off() + f.close() + end + else + T.eq( + require("src.mods.Capabilities").supports("hooks", "ui.party.grid_navigation", version), + false, + "DS has no shared party producer yet" + ) + end +end +Version.set(old) +T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tests/party_grid/cases.lua b/tests/party_grid/cases.lua new file mode 100644 index 000000000..e21743d26 --- /dev/null +++ b/tests/party_grid/cases.lua @@ -0,0 +1,65 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + if e.api.supports then + T.eq(e.api:supports("hooks", "ui.party.grid_navigation"), true, "native capability") + end + T.eq(f.grid(), false, "native party navigation remains the default") + e.grid(true) + T.eq(f.grid(), true, "opt-in grid") + T.eq(e.seen.menu, f.menu, "original opaque menu") + local rows = {} + for count = 1, 6 do + f.count(count) + for index = 1, count + (f.generation == 3 and 1 or 0) do + local slot = index > count and "cancel" or index + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(slot) + local target = f.press(dir) + T.check( + target == "cancel" or (type(target) == "number" and target >= 1 and target <= count), + "native target is occupied or cancel" + ) + rows[#rows + 1] = { count = count, index = slot, direction = dir, target = target } + end + end + end + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 3 or 2, "lead's horizontal peer") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 5, "top edge follows native grid policy") + f.count(5) + f.index(5) + T.eq(f.press("down"), f.generation == 3 and "cancel" or 1, "odd final row") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function(next) + if after then + next() + end + error("intentional party navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream decision") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + local calls = e.seen.calls + f.battleMode(false) + T.eq(f.grid(), false, "field party does not opt in") + T.eq(e.seen.calls, calls, "field party bypasses battle predicate") + f.battleMode(true) + e.stop() + e.stop() + T.eq(f.grid(), false, "unsubscribe restores native controller") + T.eq(e.seen.calls, calls, "unsubscribe stops consumer") + return rows +end +return M diff --git a/tests/party_grid/fixture.lua b/tests/party_grid/fixture.lua new file mode 100644 index 000000000..4fbd83c25 --- /dev/null +++ b/tests/party_grid/fixture.lua @@ -0,0 +1,109 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + "portable_party_grid" + ) + f.start() + local game, battle, party = f.game, f.battle(), f.party() + local menu + local original = party[1] + if backend == "gen1" and f.generation == 3 then + menu = require("src.ui.game3.party_menu") + menu.show(party, nil, { session = game.session, battle = battle, mode = "battle_switch" }) + f.index = function(value) + if value then + menu.cursor = value == "cancel" and 7 or value + end + return menu.cursor == 7 and "cancel" or menu.cursor + end + f.count = function(n) + menu._party = {} + for i = 1, n do + menu._party[i] = original + end + end + local oldClose = f.close + f.close = function() + menu.open = false + oldClose() + end + f.update = function() + menu.handleInput(game.input) + end + f.battleMode = function(value) + menu._battle = value and battle or nil + end + elseif backend == "gen1" and f.generation == 2 then + menu = require("src.ui.gen2.PartyMenu").new(game, { party = party, battle = true }) + f.count = function(n) + menu.party = {} + for i = 1, n do + menu.party[i] = original + end + end + else + local class = f.generation == 3 and "src.ui.Gen3PartyMenu" or "src.ui.PartyMenu" + menu = require(class).new(game, { battle = battle, party = party }) + f.count = function(n) + local list = {} + for i = 1, n do + list[i] = original + end + if f.generation ~= 3 then + menu.party = list + end + battle.playerParty = list + end + if f.generation == 3 then + game.data.constants = game.data.constants or {} + local slots = {} + for i = 1, 6 do + slots[i] = { + window = { x = 0, y = 0, width = 80, height = 32 }, + icon = { x = i == 1 and 16 or 104, y = i == 1 and 40 or 18 + 24 * (i - 2) }, + } + end + game.data.constants.gen3PartyMenu = { slots = slots } + end + end + if not f.index then + f.index = function(value) + if value then + menu.index = value == "cancel" and #menu:party() + 1 or value + end + if f.generation == 3 and menu.index == #menu:party() + 1 then + return "cancel" + end + return menu.index + end + end + f.battleMode = f.battleMode or function(value) + menu.battle = value and battle or nil + end + f.update = f.update or function() + menu:update(0) + end + f.menu = menu + f.press = function(direction) + game.input.pressed = type(direction) == "table" and direction + or (direction and { [direction] = true } or {}) + f.update() + return f.index() + end + f.grid = function() + if backend == "gen1" and f.generation == 3 then + return menu.gridNavigation() + end + return menu:gridNavigation() + end + f.count(6) + return f +end +return M diff --git a/tests/party_grid/worker.lua b/tests/party_grid/worker.lua new file mode 100644 index 000000000..2e46e6fa5 --- /dev/null +++ b/tests/party_grid/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/party_grid/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/party_grid/cases.lua").run(f) +f.close() +print("PARTY_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tools/party-grid-contract-check/check.py b/tools/party-grid-contract-check/check.py new file mode 100644 index 000000000..b7cddeca7 --- /dev/null +++ b/tools/party-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged party-grid mod through both engines' native party controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/party_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("PARTY_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("PARTY_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"party navigation mismatch: {version}" + print(f"PASS {version}: matching native party-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_party_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From c4152a50219af8256b0df849e3d21fa12030fe8c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:45:40 +0200 Subject: [PATCH 27/86] Add API-3 playthrough storage with shared persistence contracts --- .github/workflows/storage-contract.yml | 44 ++ docs/api3-storage.md | 90 ++++ examples/portable_storage/main.lua | 17 + examples/portable_storage/manifest.json | 1 + src/core/PlaythroughIdentity.lua | 194 +++++++++ src/core/SaveData.lua | 46 +- src/mods/Api3.lua | 6 + src/mods/PlaythroughStorage.lua | 472 +++++++++++++++++++++ tests/engine/api3_playthrough_storage.lua | 138 ++++++ tests/engine/api3_storage_transactions.lua | 312 ++++++++++++++ tests/playthrough_storage/cases.lua | 96 +++++ tests/playthrough_storage/fixture.lua | 67 +++ tests/playthrough_storage/lifecycle.lua | 27 ++ tests/playthrough_storage/worker.lua | 11 + tools/storage-contract-check/check.py | 55 +++ 15 files changed, 1570 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/storage-contract.yml create mode 100644 docs/api3-storage.md create mode 100644 examples/portable_storage/main.lua create mode 100644 examples/portable_storage/manifest.json create mode 100644 src/core/PlaythroughIdentity.lua create mode 100644 src/mods/PlaythroughStorage.lua create mode 100644 tests/engine/api3_playthrough_storage.lua create mode 100644 tests/engine/api3_storage_transactions.lua create mode 100644 tests/playthrough_storage/cases.lua create mode 100644 tests/playthrough_storage/fixture.lua create mode 100644 tests/playthrough_storage/lifecycle.lua create mode 100644 tests/playthrough_storage/worker.lua create mode 100644 tools/storage-contract-check/check.py diff --git a/.github/workflows/storage-contract.yml b/.github/workflows/storage-contract.yml new file mode 100644 index 000000000..cb3634656 --- /dev/null +++ b/.github/workflows/storage-contract.yml @@ -0,0 +1,44 @@ +name: portable API-3 storage contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: storage-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check Gen2 native storage, save lifecycle and legacy cache + run: | + luajit tests/engine/api3_storage_transactions.lua + luajit tests/engine/api3_playthrough_storage.lua + luajit tests/engine/save_slots.lua + luajit tests/mod_storage_tests.lua + - name: Validate pinned Gen1 storage and identity contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/storage.lua + luajit tests/engine/playthrough_identity.lua + - name: Compare unchanged storage consumer and native save lifecycle + run: | + mkdir -p /tmp/storage-contract + python3 tools/storage-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/storage-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: storage-contract + path: /tmp/storage-contract + if-no-files-found: ignore diff --git a/docs/api3-storage.md b/docs/api3-storage.md new file mode 100644 index 000000000..2614b295a --- /dev/null +++ b/docs/api3-storage.md @@ -0,0 +1,90 @@ +# API-3 playthrough storage + +API 3 uses the Gen1Recomp public `mod.storage` contract: data-only records and +opaque bytes belong to a mod, a game version and an opaque playthrough identity. +The handle contains only `context`, `selected`, `read`, `write`, `readBytes`, +`writeBytes`, `list` and `delete`; private paths and mutable engine objects are +not exposed. The transactional implementation follows the pinned Gen1Recomp +`Storage.lua` at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```lua +local mod = ... +local context, code = mod.storage:context(mod.game) +if context then + local state, reason = mod.storage:read(mod.game, "progress/visited") + if not state and reason == "not_found" then state = { maps = {} } end + -- Other errors require handling; they do not mean an empty namespace. +end +``` + +`context(game)` returns `engineVersion`, `gameVersion` and `playthroughId`. +The identity is opaque, stable across normal save/load and independent of +slot names. A new game has a distinct requested identity; duplicate imported +slots receive distinct scopes. The engine allocates it lazily without consuming +gameplay RNG. Unused vanilla saves remain identity-free. Legacy backfill writes +routing metadata to options; requesting storage alone does not rewrite normal +progress. The next ordinary save stamps the identity into its metadata. + +Records must be serializable data tables. Bytes are strings, including empty +strings and NULs, up to 512 MiB per key. Keys use nonempty letters/numbers/dash/ +underscore segments separated by `/`. Absolute paths, `..`, dots, empty segments +and backslashes are rejected. A key cannot change between records and bytes +until explicitly deleted. Lists are sorted, de-duplicated logical keys; a prefix +selects an exact key or a directory. Reads return decoded copies. + +Writes stage and verify data, retain a verified backup, replace and verify the +main copy, then remove the staging file. Reads recover from valid staging or +backup data. Reads return `nil, code, message` on failure; writes/deletes return +`false, code, message`. Important codes include `not_found`, `invalid_key`, +`encode_failed`, `type_conflict`, `type_mismatch`, `write_failed`, `verify_failed`, +`storage_unavailable` and `not_in_playthrough`. Recovery is best-effort; this is +not a multi-key transaction or a concurrent-writer database. + +## Selected storage at the title + +`mod.storage:selected(mod.game)` is available only while a native TitleState is +in the stack. It resolves the engine-selected durable playthrough without +allocating a new identity. The returned handle binds that scope: + +```lua +local selected, reason = mod.storage:selected(mod.game) +if selected then + local context = selected:context() -- may include normalSavedAt + local state = selected:read("progress/visited") -- no game argument +end +``` + +The title handle offers the same read/write/list/delete methods without a game +argument. Returned contexts are detached copies; callers cannot reroute the +bound handle by changing them. Init-time reads against a fresh boot skeleton +cannot replace an existing real save's selection. A successfully committed +normal save is authoritative if a later options update fails. Merely browsing +a fresh title without durable state reports `no_selected_playthrough`. + +## Existing Gen2 mods and cache data + +API 1/2 retain the existing `src.mods.Storage` behavior and `modstorage/` data +unchanged. API 3 uses a separate playthrough namespace. No automatic copy guesses +which save owns an old installation-wide payload. ROM-derived, reproducible +assets belong in the existing `mod.cache` service. Authors upgrading a legacy +mod must explicitly export/migrate authored bulk state while using its old API, +or rebuild derived caches through their dedicated cache/import service. Changing +the manifest to API 3 does not automatically assign old shared records to a save. + +## Verification + +The unchanged API-3 example runs through real SDKs for the eight games both +engines support: Red/Blue/Yellow, Gold/Silver/Crystal, FireRed and Emerald. +Shared checks cover data/byte transactions, isolation, recovery/failures, +listing, title handles and native New Game/save/load. Gen2 additionally tests +all fifteen supported hosts and slot promotion, boot skeletons, duplicate slot +imports, failed saves, deletion, legacy API retention and RNG independence. +These are ROM-free persistence/controller contracts; native cartridge smoke +tests are separate and do not establish full playthrough compatibility. + +```sh +luajit tests/engine/api3_storage_transactions.lua +luajit tests/engine/api3_playthrough_storage.lua +python3 tools/storage-contract-check/check.py --gen1 /path/to/gen1 \ + --report /tmp/storage-contract.json +``` diff --git a/examples/portable_storage/main.lua b/examples/portable_storage/main.lua new file mode 100644 index 000000000..35d8e0373 --- /dev/null +++ b/examples/portable_storage/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local storage = mod.storage +for _, method in ipairs({ + "context", + "selected", + "read", + "write", + "readBytes", + "writeBytes", + "list", + "delete", +}) do + local name = method + mod.exports[name] = function(...) + return storage[name](storage, ...) + end +end diff --git a/examples/portable_storage/manifest.json b/examples/portable_storage/manifest.json new file mode 100644 index 000000000..4c01872f9 --- /dev/null +++ b/examples/portable_storage/manifest.json @@ -0,0 +1 @@ +{"id": "portable_storage", "name": "Portable playthrough storage", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua"} diff --git a/src/core/PlaythroughIdentity.lua b/src/core/PlaythroughIdentity.lua new file mode 100644 index 000000000..8aac1f53e --- /dev/null +++ b/src/core/PlaythroughIdentity.lua @@ -0,0 +1,194 @@ +-- Opaque storage identity. Never consumes gameplay RNG or rewrites progress +-- merely because a mod requests a namespace. Slot routing is engine-owned. +local Version = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local copy = require("src.mods.Merge").deepCopy +local M = {} +local fresh = setmetatable({}, { __mode = "k" }) +local sequence = 0 +local function saves() + return require("src.core.SaveData") +end +local function valid(id) + return type(id) == "string" and id:match("^[%w_-]+$") ~= nil +end +function M.markFresh(save) + if type(save) == "table" then + fresh[save] = true + end +end +function M.newId() + sequence = sequence + 1 + local address = tostring({}):match("0x(%x+)") or "0" + local function word(n) + return math.floor(tonumber(n) or 0) % 4294967296 + end + return ("%08x%08x%08x%08x"):format( + word(os.time()), + word(os.clock() * 1000000), + word(tonumber(address:sub(-8), 16)), + word(sequence) + ) +end +local function routing(version, fs) + local opts = saves().loadOptions(fs) + local reg = opts.saveSlots and opts.saveSlots[version] + local slot = saves()._storageSlot(version, fs) + or (reg and (reg.active or (reg.list and reg.list[1]))) + or "legacy" + if not valid(slot) then + slot = "legacy" + end + local ids = opts.playthroughIds and opts.playthroughIds[version] + return opts, slot, ids and ids[slot] +end +local function normalSave(version, slot, fs) + local main = slot == "legacy" and ("save" .. Version.saveSuffix(version) .. ".lua") + or ("saves/" .. version .. "/" .. slot .. ".lua") + for _, path in ipairs({ main, main .. ".tmp", main .. ".bak" }) do + local body = fs.read(path) + local data = type(body) == "string" and Serializer.decode(body) + if type(data) == "table" and data.version == version then + return data + end + end +end +local function bind(opts, version, slot, id, fs) + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + if opts.playthroughIds[version][slot] == id then + return true + end + opts.playthroughIds[version][slot] = id + return saves().saveOptions(opts, fs) ~= nil +end +function M.ensure(save, fs) + if type(save) ~= "table" or not Version.info(save.version) then + return nil + end + save.meta = type(save.meta) == "table" and save.meta or {} + if valid(save.meta.playthroughId) then + return save.meta.playthroughId + end + local opts, slot, mapped = routing(save.version, fs) + local id = not fresh[save] and valid(mapped) and mapped or M.newId() + -- Boot/New Game skeletons must not strand an already-bound real save. + if not (fresh[save] and valid(mapped)) and not bind(opts, save.version, slot, id, fs) then + return nil + end + save.meta.playthroughId = id + return id +end +function M.inherit(save, fs) + if type(save) ~= "table" or fresh[save] or not Version.info(save.version) then + return + end + if save.meta and valid(save.meta.playthroughId) then + return + end + local _, _, id = routing(save.version, fs) + if valid(id) then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = id + end +end +function M.selected(save, fs) + if type(save) ~= "table" then + return nil, "not_in_playthrough", "No selected playthrough is available." + end + if not Version.info(save.version) then + return nil, "unknown_game", "The selected game version is unavailable." + end + local id = save.meta and save.meta.playthroughId + if not fresh[save] and valid(id) then + return id + end + local _, slot, mapped = routing(save.version, fs) + -- A committed normal save is authoritative even if its options update + -- failed, or the registry was recovered from an older backup. + local normal = normalSave(save.version, slot, fs) + local stamped = normal and normal.meta and normal.meta.playthroughId + if valid(stamped) then + return stamped + end + if valid(mapped) then + return mapped + end + return nil, "no_selected_playthrough", "The selected playthrough has no durable tool state." +end +function M.remember(save, fs, slot, version) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + fresh[save] = nil + return true + end + version = version or save.version + local opts, active = routing(version, fs) + local ok = bind(opts, version, slot or active, id, fs) + if ok then + fresh[save] = nil + if save.options then + save.options.playthroughIds = copy(opts.playthroughIds) + end + end + return ok +end +function M.preserveOptions(opts, fs) + local stored = saves().loadOptions(fs) + -- A live options snapshot can predate lazy legacy-to-slot promotion. + opts.playthroughIds = copy(stored.playthroughIds) + opts.saveSlots = copy(stored.saveSlots) +end +function M.prepareSlot(save, version, slot, fs) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + return save + end + local opts = saves().loadOptions(fs) + local ids = opts.playthroughIds and opts.playthroughIds[version] + local collision = false + for other, used in pairs(ids or {}) do + if other ~= slot and used == id then + collision = true + end + end + local reg = opts.saveSlots and opts.saveSlots[version] + for _, other in ipairs(reg and reg.list or {}) do + if other ~= slot then + local base = "saves/" .. version .. "/" .. other .. ".lua" + for _, path in ipairs({ base, base .. ".tmp", base .. ".bak" }) do + local bytes = fs.read(path) + local data = type(bytes) == "string" and Serializer.decode(bytes) + if type(data) == "table" and data.meta and data.meta.playthroughId == id then + collision = true + end + end + end + end + if collision then + save = copy(save) + save.meta.playthroughId = M.newId() + end + return save +end +function M.normalInfo(save, fs) + local id, code, message = M.selected(save, fs) + if not id then + return nil, code, message + end + local _, slot = routing(save.version, fs) + local normal = normalSave(save.version, slot, fs) + if + not normal + or normal.version ~= save.version + or (normal.meta and normal.meta.playthroughId and normal.meta.playthroughId ~= id) + then + return { exists = false } + end + local at = normal.meta and normal.meta.savedAt + if type(at) ~= "number" or at < 0 or at ~= at or at == math.huge then + at = nil + end + return { exists = true, savedAt = at } +end +return M diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..8fa8afde8 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -404,8 +404,8 @@ end -- Public seam for mods (Stadium import, HD zip install, custom pickers). -- Same routing as options/saves: injected fs, then portable.io, then LOVE. -function SaveData.persistenceFs() - return persistFs(love and love.filesystem) +function SaveData.persistenceFs(injectedFs) + return persistFs(injectedFs or (love and love.filesystem)) end -- Port + original Options menu defaults. Missing keys on load are filled @@ -853,6 +853,8 @@ local function tryMigrateLegacy(version, fs) local opts = SaveData.loadOptions(fs) opts.saveSlots = opts.saveSlots or {} opts.saveSlots[version] = { list = { id }, active = id } + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids and ids.legacy then ids[id], ids.legacy = ids.legacy, nil end SaveData.saveOptions(opts, fs) return id end @@ -1131,11 +1133,14 @@ function SaveData.writeSlot(version, slotId, saveTable) if type(slotId) ~= "string" then return false, "missing slot id" end if type(saveTable) ~= "table" then return false, "missing save table" end local main, bak = slotNames(version, slotId) - local encoded = SaveSerializer.encode(saveTable) local fs = persistFs(nil) + saveTable = require("src.core.PlaythroughIdentity").prepareSlot(saveTable, version, slotId, fs) + local encoded = SaveSerializer.encode(saveTable) ensureParentDir(fs, main) -- atomic, previous good copy kept (SaveData.commitSave) - return SaveData.commitSave(fs, main, bak, encoded, saveTable) + local ok, err = SaveData.commitSave(fs, main, bak, encoded, saveTable) + if ok then require("src.core.PlaythroughIdentity").remember(saveTable, fs, slotId, version) end + return ok, err end -- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the @@ -1174,6 +1179,8 @@ function SaveData.deleteSlot(version, slotId) end end + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids then ids[slotId] = nil end table.remove(reg.list, idx) if reg.names then reg.names[slotId] = nil end if reg.active == slotId then @@ -1194,6 +1201,14 @@ function SaveData.resetSlotState() for k in pairs(slotsChecked) do slotsChecked[k] = nil end end +-- Internal identity routing follows this process's resolved save destination. +-- A different launcher window can change options without moving this game. +function SaveData._storageSlot(version, fs) + if fs == persistFs(nil) and slotsChecked[version] then + return activeSlotCache[version] or "legacy" + end +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the @@ -1216,6 +1231,7 @@ function SaveData.buildMeta(mods, previous) engine = Version.engine, savedAt = os.time(), mods = list, + playthroughId = type(previous) == "table" and previous.playthroughId or nil, } end @@ -1719,7 +1735,11 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME = saveNames(data.version) + local identity = require("src.core.PlaythroughIdentity") + local fs = persistFs(nil) + identity.inherit(data, fs) if data.options then + identity.preserveOptions(data.options, fs) SaveData.saveOptions(data.options) end if mods ~= nil or data.meta == nil then @@ -1732,7 +1752,6 @@ function SaveData.save(data, mods) end return SaveSerializer.encode(gameOnly) end - local fs = persistFs(nil) -- the active slot may live in saves//, which must exist before -- the .tmp/.bak/main writes land (a no-op for the flat legacy path) ensureParentDir(fs, FILENAME) @@ -1741,6 +1760,7 @@ function SaveData.save(data, mods) Logger.error("save failed: %s", tostring(err)) return false end + identity.remember(data, fs) markOpen(fs, FILENAME) Logger.info("saved game") return true @@ -1802,6 +1822,7 @@ function SaveData.load(version) loadedWalkerKeys[FILENAME] = SaveSafety.walkerKeys(data) markOpen(fs, FILENAME) SaveData.runMigrations(data) + require("src.core.PlaythroughIdentity").inherit(data, fs) data.options = SaveData.optionsFor(version) Logger.info("loaded save") return data, recovered @@ -2570,7 +2591,20 @@ function SaveData.newGame(boot) -- -- Callers that ARE starting a playthrough pass nothing and are unaffected. if boot.noHooks then return save end - return Runtime.call("save.new_game", function(s) return s end, save) + save = Runtime.call("save.new_game", function(s) return s end, save) + require("src.core.PlaythroughIdentity").markFresh(save) + return save +end + +-- Engine-owned persistence scope used by the portable storage facade. +function SaveData.ensurePlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").ensure(save, persistFs(fs)) +end +function SaveData.selectedPlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").selected(save, persistFs(fs)) +end +function SaveData.selectedNormalSaveInfo(save, fs) + return require("src.core.PlaythroughIdentity").normalInfo(save, persistFs(fs)) end return SaveData diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index f5de4a039..984a886bf 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -29,6 +29,12 @@ local function block(gen) } end function A.install(api, loader, readContext, overviewContext, engineRequire) + local storage = require("src.mods.PlaythroughStorage").new(api.id, loader.fs) + api.storage = {} + for _, method in ipairs({ "context", "selected", "read", "write", "readBytes", "writeBytes", "list", "delete" }) do + local name = method + api.storage[name] = function(_, ...) return storage[name](storage, ...) end + end local gen = api.generation local supportedGeneration = gen >= 1 and gen <= 3 -- Passthrough registries keep native shapes but expose the same handle metadata. diff --git a/src/mods/PlaythroughStorage.lua b/src/mods/PlaythroughStorage.lua new file mode 100644 index 000000000..6410bb00b --- /dev/null +++ b/src/mods/PlaythroughStorage.lua @@ -0,0 +1,472 @@ +-- Data-only and opaque-byte per-mod persistence, scoped by game version and +-- opaque playthrough. +-- API-3 contract adapted from Gen1Recomp Storage at 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. +-- Engine-private; the public handle contains only bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") + +local Storage = {} +Storage.__index = Storage +Storage.MAX_BYTES = 512 * 1024 * 1024 + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then + return false + end + if key == "" then + return true + end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then + return false + end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + fs.createDirectory(dir) + end +end + +local function remove(fs, path) + if fs.remove then + fs.remove(path) + end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + local data = SaveSerializer.decode(body) + if not data then + return nil + end + return data, body +end + +local function readOpaqueAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + return body +end + +local function hasAny(fs, paths) + for _, path in ipairs(paths) do + if fs.getInfo(path) then + return true + end + end + return false +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + if not (save and validSegment(version)) then + return failure("not_in_playthrough", "Storage is available only inside an identified playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local playthroughId = meta and meta.playthroughId + if not validSegment(playthroughId) then + playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs) + end + if not validSegment(playthroughId) then + return failure("not_in_playthrough", "Storage could not identify the active playthrough.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, base = base, fs = fs } +end + +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then + return false + end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then + return true + end + end + return false +end + +-- Bind this mod only to the engine-selected existing playthrough while the +-- title session is active. Unlike _scope this must never allocate an identity: +-- browsing history before the first normal SAVE is a read of durable state, +-- not the start of a New Game. The returned facade closes over its private +-- proxy game, so callers cannot substitute another playthrough id or path. +function Storage:selected(game) + if not isTitleSession(game) then + return failure("not_at_title", "Selected playthrough storage is available only from the title session.") + end + local save = game and game.save + local version = save and save.version + if not validSegment(version) then + return failure("not_in_playthrough", "The title session has no selected game version.") + end + local playthroughId, code, message = SaveData.selectedPlaythroughId(save, self.injectedFs) + if not validSegment(playthroughId) then + return failure(code, message) + end + + local selectedGame = { + save = { version = version, meta = { playthroughId = playthroughId } }, + } + local context = { + engineVersion = Version.engine, + gameVersion = version, + playthroughId = playthroughId, + } + local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs) + if type(normal) == "table" and normal.savedAt ~= nil then + context.normalSavedAt = normal.savedAt + end + return { + context = function() + local copy = { + engineVersion = context.engineVersion, + gameVersion = context.gameVersion, + playthroughId = context.playthroughId, + } + if context.normalSavedAt ~= nil then + copy.normalSavedAt = context.normalSavedAt + end + return copy + end, + read = function(_, key) + return self:read(selectedGame, key) + end, + write = function(_, key, value) + return self:write(selectedGame, key, value) + end, + readBytes = function(_, key) + return self:readBytes(selectedGame, key) + end, + writeBytes = function(_, key, bytes) + return self:writeBytes(selectedGame, key, bytes) + end, + list = function(_, prefix) + return self:list(selectedGame, prefix) + end, + delete = function(_, key) + return self:delete(selectedGame, key) + end, + } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + return { + engineVersion = Version.engine, + gameVersion = scope.gameVersion, + playthroughId = scope.playthroughId, + } +end + +function Storage:_names(game, key, allowEmpty, extension) + if not validKey(key, allowEmpty) then + return failure( + "invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments." + ) + end + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + extension = extension or ".lua" + return scope, path .. extension, path .. extension .. ".bak", path .. extension .. ".tmp", path +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return false, "type_conflict", "A byte value already exists for this storage key; delete it first." + end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " .. tostring(encoded) + end + + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then + _, previous = decodeAt(fs, bak) + end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then + fs.write(bak, previous) + end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return failure("type_mismatch", "This storage key contains opaque bytes; use readBytes instead.") + end + local data, body = decodeAt(fs, main) + if data then + return data + end + + data, body = decodeAt(fs, tmp) + if not data then + data, body = decodeAt(fs, bak) + end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then + fs.write(bak, body) + end + remove(fs, tmp) + return data +end + +function Storage:writeBytes(game, key, bytes) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return false, main, bak + end + if type(bytes) ~= "string" then + return false, "invalid_bytes", "Opaque storage values must be strings." + end + if #bytes > Storage.MAX_BYTES then + return false, "size_limit", ("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES) + end + + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return false, "type_conflict", "A table value already exists for this storage key; delete it first." + end + + ensureParent(fs, main) + local previous = readOpaqueAt(fs, main) + if previous == nil then + previous = readOpaqueAt(fs, bak) + end + + local ok, err = fs.write(tmp, bytes) + if not ok then + return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err) + end + local staged = readOpaqueAt(fs, tmp) + if staged == nil or staged ~= bytes then + remove(fs, tmp) + return false, "verify_failed", "Staged opaque storage data could not be verified." + end + + if previous ~= nil then + fs.write(bak, previous) + end + ok, err = fs.write(main, bytes) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace opaque storage data: " .. tostring(err) + end + local verified = readOpaqueAt(fs, main) + if verified == nil or verified ~= bytes then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement opaque storage data could not be verified." + end + + fs.write(bak, bytes) + remove(fs, tmp) + return true +end + +function Storage:readBytes(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return failure("type_mismatch", "This storage key contains table data; use read instead.") + end + + local bytes = readOpaqueAt(fs, main) + if bytes ~= nil then + return bytes + end + bytes = readOpaqueAt(fs, tmp) + if bytes == nil then + bytes = readOpaqueAt(fs, bak) + end + if bytes == nil then + return nil, "not_found", "No valid opaque value exists for this key." + end + + ensureParent(fs, main) + if fs.write(main, bytes) then + fs.write(bak, bytes) + end + remove(fs, tmp) + return bytes +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then + return nil, main, codeOrBak + end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out, seen = {}, {} + + local function add(logical) + if not seen[logical] then + seen[logical] = true + out[#out + 1] = logical + end + end + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then + return + end + if info.type == "file" then + local suffix = path:sub(-4) + if suffix == ".lua" or suffix == ".bin" then + add(logical:sub(1, -5)) + end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + add(prefix) + elseif fs.getInfo(start .. ".bin") then + add(prefix) + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" + if + not ( + fs.getInfo(main) + or fs.getInfo(bak) + or fs.getInfo(tmp) + or fs.getInfo(byteMain) + or fs.getInfo(byteBak) + or fs.getInfo(byteTmp) + ) + then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + remove(fs, byteMain) + remove(fs, byteBak) + remove(fs, byteTmp) + return true +end + +return Storage diff --git a/tests/engine/api3_playthrough_storage.lua b/tests/engine/api3_playthrough_storage.lua new file mode 100644 index 000000000..1b3a3e2e5 --- /dev/null +++ b/tests/engine/api3_playthrough_storage.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/playthrough_storage/fixture.lua") +local Cases = dofile("tests/playthrough_storage/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version) + Cases.run(f, version) + dofile("tests/playthrough_storage/lifecycle.lua").run(f, version) + f.close() +end +-- Drive the actual native save/slot lifecycle, not synthetic storage handles. +for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do + local f = Fixture.new(".", "gen2", version) + local S, e, fs, files = f.Save, f.e, f.fs, f.files + local function new() + return { save = S.newGame({ version = version }) } + end + local vanilla = new() + T.eq(vanilla.save.meta.playthroughId, nil, "no-mod New Game has no storage identity") + T.check(S.save(vanilla.save), "vanilla progress saves") + local loaded = assert(S.load(version)) + T.eq(loaded.meta.playthroughId, nil, "unused vanilla load remains identity-free") + local progress = {} + for path, bytes in pairs(files) do + if path:match("^save[^/]*%.lua$") then + progress[path] = bytes + end + end + local first = { save = loaded } + local id = assert(e.context(first)).playthroughId + T.check(type(id) == "string" and id ~= "", "legacy storage receives opaque identity") + for path, bytes in pairs(progress) do + T.eq(files[path], bytes, "legacy allocation never rewrites progress") + end + T.check(e.write(first, "progress/value", { n = 12 }), "legacy mod state writes") + S.resetSlotState() + local resumed = { save = assert(S.load(version)) } + T.eq(e.context(resumed).playthroughId, id, "legacy promotion/restart retains identity") + T.eq(e.read(resumed, "progress/value").n, 12, "legacy state survives slot migration") + T.check(S.save(resumed.save, {}), "identified progress saves") + S.resetSlotState() + resumed.save = assert(S.load(version)) + T.eq(resumed.save.meta.playthroughId, id, "normal save stamps and reloads identity") + local boot = new() + local ghost = e.context(boot).playthroughId + T.check(ghost ~= id, "boot/new-game skeleton has separate identity") + boot.stack = { states = { { screenId = "TitleState" } } } + local title = assert(e.selected(boot)) + T.eq(title:context().playthroughId, id, "init-time storage cannot replace title's real save") + T.eq(title:read("progress/value").n, 12, "title reads selected durable mod state") + T.eq(type(title:context().normalSavedAt), "number", "title includes normal save chronology") + fs.fail = ".tmp" + T.eq(S.save(boot.save, {}), false, "failed normal save remains a failed save") + fs.fail = nil + T.eq(e.selected(boot):context().playthroughId, id, "failed normal save cannot replace title identity") + T.check(S.save(boot.save, {}), "new playthrough commits") + local nextBoot = new() + nextBoot.stack = boot.stack + T.eq(e.selected(nextBoot):context().playthroughId, ghost, "committed new game becomes title selection") + local sourceId = boot.save.meta.playthroughId + local sourceSlot = S.activeSlot(version) + local copySlot = assert(S.createSlot(version)) + T.check(S.writeSlot(version, copySlot, boot.save), "identified save imports to another slot") + T.eq(boot.save.meta.playthroughId, sourceId, "import never mutates source identity") + S.setActiveSlot(version, copySlot) + local imported = { save = assert(S.load(version)) } + T.check(e.context(imported).playthroughId ~= sourceId, "duplicate slot receives distinct storage scope") + T.eq( + S.buildMeta({}, imported.save.meta).playthroughId, + imported.save.meta.playthroughId, + "mod-set stamp retains identity" + ) + T.check(S.deleteSlot(version, copySlot), "identified slot deletes") + local opts = S.loadOptions(fs) + T.eq(opts.playthroughIds[version][copySlot], nil, "deleted slot loses routing binding") + S.setActiveSlot(version, sourceSlot) + S.resetSlotState() + T.eq(S.load(version).meta.playthroughId, sourceId, "original slot remains unchanged") + f.close() +end +-- Identity allocation must not change the next gameplay RNG result. +do + local f = Fixture.new(".", "gen2", "red") + local game = { save = f.Save.newGame({ version = "red" }) } + math.randomseed(318) + local expected = math.random() + math.randomseed(318) + f.e.context(game) + T.eq(math.random(), expected, "storage identity never consumes gameplay RNG") + f.close() +end +-- Reject an unavailable persistence backend before attempting identity I/O. +do + local f = Fixture.new(".", "gen2", "red") + local write = f.fs.write + f.fs.write = nil + local _, code = f.e.context({ save = { version = "red" } }) + T.eq(code, "storage_unavailable", "read-only backend cannot allocate a scope") + f.fs.write = write + f.fs.fail = ".tmp" + local game = { save = f.Save.newGame({ version = "red" }) } + local context = f.e.context(game) + T.eq(context, nil, "failed identity write cannot claim durable scope") + T.eq(game.save.meta.playthroughId, nil, "failed allocation leaves no committed identity") + f.close() +end +-- API 1/2 retain the original installation-wide cache semantics and bytes. +for _, api in ipairs({ 1, 2 }) do + local f = Fixture.new(".", "gen2", "red") + local Store = require("src.mods.Storage") + local legacy = Store.new("portable_storage", f.fs) + T.check(legacy:write({}, "old/value", { n = api }), "legacy cache writes") + T.eq(legacy:read({}, "old/value").n, api, "legacy cache survives") + local Json = require("src.link.Json") + local path = "mods/portable_storage/manifest.json" + local manifest = Json.decode(f.files[path]) + manifest.api = api + f.files[path] = Json.encode(manifest) + local loaded = T.sdk.loadMods( + { "mods/portable_storage" }, + { fs = f.fs, generation = 1, data = T.fixtures.fresh(), dev = false } + ) + T.eq(#loaded.errors, 0, "legacy SDK consumer loads") + T.eq( + loaded.loader.exports.portable_storage.read({}, "old/value").n, + api, + "legacy SDK uses unchanged cache namespace" + ) + local _, code = f.e.read(f.game("new_scope"), "old/value") + T.eq(code, "not_found", "portable storage does not guess legacy cache ownership") + T.eq(legacy:read({}, "old/value").n, api, "portable reads retain legacy cache bytes") + loaded.release() + f.close() +end +Version.set(old) +T.finish("API-3 portable storage and native playthrough lifecycle") diff --git a/tests/engine/api3_storage_transactions.lua b/tests/engine/api3_storage_transactions.lua new file mode 100644 index 000000000..e873f568e --- /dev/null +++ b/tests/engine/api3_storage_transactions.lua @@ -0,0 +1,312 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod storage") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Storage = require("src.mods.PlaythroughStorage") +local Version = require("src.core.Version") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",'):format(id, id) + .. '"entry":"main.lua","api":3,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + function fs.read(path) + return files[path] + end + function fs.write(path, body) + if fs.failTmp and path:sub(-4) == ".tmp" then + return false, "tmp denied" + end + if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then + return false, "main denied" + end + files[path] = body + return true + end + function fs.remove(path) + files[path] = nil + return true + end + function fs.createDirectory() + return true + end + function fs.getInfo(path) + if files[path] then + return { type = "file" } + end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + return { type = "directory" } + end + end + return nil + end + function fs.load(path) + if not files[path] then + return nil, "no file: " .. path + end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { + save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + }, + } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) + ["mods/alpha/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha = (loader.exports.alpha or {}).storage +local beta = (loader.exports.beta or {}).storage +T.check( + type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object" +) +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", +}, "context exposes stable engine/game/playthrough compatibility identity") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "stored payload roundtrips as data") +T.check( + loaded ~= payload and loaded.nested ~= payload.nested, + "read returns decoded data rather than the caller's live table" +) + +T.check( + type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function", + "mod.storage exposes opaque byte read/write methods" +) +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true" + local binaryOk, binaryCode, binaryMessage = alpha:writeBytes(current, "states/quick/blob", binary) + T.check(binaryOk == true, "opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage)) + local binaryLoaded, binaryReadCode = alpha:readBytes(current, "states/quick/blob") + T.eq(binaryLoaded, binary, "opaque bytes round-trip without text or Lua decoding") + T.eq(binaryReadCode, nil, "successful opaque byte read has no error") + T.eq(_G.MOD_STORAGE_EXECUTED, nil, "Lua-looking opaque bytes are never executed") + + local emptyOk = alpha:writeBytes(current, "binary/empty", "") + T.check(emptyOk == true, "empty opaque byte payloads are valid") + T.eq(alpha:readBytes(current, "binary/empty"), "", "empty opaque byte payloads round-trip") + + local badBytes, badBytesCode = alpha:writeBytes(current, "binary/bad-type", { byte = true }) + T.check(not badBytes and badBytesCode == "invalid_bytes", "non-string opaque payloads are rejected") + + local savedLimit = Storage.MAX_BYTES + Storage.MAX_BYTES = 4 + local tooLarge, tooLargeCode = alpha:writeBytes(current, "binary/too-large", "12345") + Storage.MAX_BYTES = savedLimit + T.check( + not tooLarge and tooLargeCode == "size_limit", + "opaque payloads over the per-key limit are rejected" + ) + + local tableConflict, tableConflictCode = alpha:writeBytes(current, "states/quick/q1", "table-key-conflict") + T.check( + not tableConflict and tableConflictCode == "type_conflict", + "bytes cannot replace a table record without deletion" + ) + local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob") + T.check( + wrongType == nil and wrongTypeCode == "type_mismatch", + "table reads identify byte records as the wrong storage type" + ) +end + +local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) +T.check(not bad and badCode == "encode_failed", "functions are rejected with a stable data-only error") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta") +T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") +T.check(alpha:write(current, "settings", { enabled = true }), "write settings") +local keys = alpha:list(current, "states/quick") +T.same( + keys, + { "states/quick/alpha", "states/quick/blob", "states/quick/q1", "states/quick/zeta" }, + "list returns sorted logical keys under the requested prefix" +) + +-- Mod, playthrough, and game namespaces cannot observe each other. +local missing, missingCode = beta:read(current, "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another mod cannot read the first mod's payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = beta:readBytes(current, "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another mod cannot read the first mod's opaque payload" + ) +end +missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the payload") +missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another game version cannot read the payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob") + T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the opaque payload") + missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another game version cannot read the opaque payload" + ) +end + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then + return path + end + end +end + +local function byteMainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then + return path + end + end +end + +local q1Main = mainFor("q1") +T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") +files[q1Main] = "not a serialized table" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +T.check(not ok and code == "write_failed", "staging failure is reported") +T.same(alpha:read(current, "replace"), { version = 1 }, "staging failure leaves the prior value readable") + +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"), "seed opaque recovery value") + local recoverMain = byteMainFor("binary/recover") + T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data") + files[recoverMain] = nil + T.eq( + alpha:readBytes(current, "binary/recover"), + "old-bytes", + "missing opaque main recovers the last verified backup" + ) + + T.check(alpha:writeBytes(current, "binary/replace", "version-1"), "seed opaque replacement value") + fs.failTmp = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-2") + fs.failTmp = false + T.check(not ok and code == "write_failed", "opaque staging failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque staging failure leaves the prior value readable" + ) + + fs.failMain = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-3") + fs.failMain = false + T.check(not ok and code == "write_failed", "opaque replacement failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque replacement failure leaves the prior value readable" + ) + + local byteConflict, byteConflictCode = alpha:write(current, "binary/replace", { version = 3 }) + T.check( + not byteConflict and byteConflictCode == "type_conflict", + "tables cannot replace a byte record without deletion" + ) + + T.check(alpha:writeBytes(current, "binary/delete", "delete-me"), "seed opaque delete target") + T.check(alpha:delete(current, "binary/delete") == true, "delete removes an opaque record") + missing, missingCode = alpha:readBytes(current, "binary/delete") + T.check(missing == nil and missingCode == "not_found", "deleted opaque key is unavailable") +end + +-- Delete is exact and idempotent-not-found is explicit. +T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") +T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil + +T.finish() diff --git a/tests/playthrough_storage/cases.lua b/tests/playthrough_storage/cases.lua new file mode 100644 index 000000000..fbb46d0ef --- /dev/null +++ b/tests/playthrough_storage/cases.lua @@ -0,0 +1,96 @@ +local M = {} +function M.run(f, version) + local T, e, rows = f.T, f.e, {} + local function eq(actual, expected, label) + T.eq(actual, expected, label) + rows[#rows + 1] = { case = label, value = actual } + end + local a, b = f.game("first"), f.game("second") + local context = assert(e.context(a)) + eq(context.gameVersion, version, "context version") + eq(context.playthroughId, "first", "context identity") + eq(type(context.engineVersion), "string", "context engine version") + eq(context.root, nil, "no private root") + eq(e.write(a, "state/value", { n = 7, nested = { ready = true } }), true, "write record") + local got = assert(e.read(a, "state/value")) + got.nested.ready = false + eq(e.read(a, "state/value").nested.ready, true, "decoded copy isolation") + local _, code = e.read(b, "state/value") + eq(code, "not_found", "playthrough isolation") + _, code = f.other.read(a, "state/value") + eq(code, "not_found", "mod isolation") + local otherVersion = f.game("first") + otherVersion.save.version = version == "red" and "blue" or "red" + _, code = e.read(otherVersion, "state/value") + eq(code, "not_found", "version isolation") + local bytes = "CACHE\0\255\128 return MALICIOUS = true" + eq(e.writeBytes(a, "state/bytes", bytes), true, "write bytes") + eq(e.readBytes(a, "state/bytes") == bytes, true, "opaque byte roundtrip") + _, code = e.read(a, "state/bytes") + eq(code, "type_mismatch", "record-byte read mismatch") + _, code = e.write(a, "state/bytes", {}) + eq(code, "type_conflict", "record cannot replace bytes") + _, code = e.writeBytes(a, "state/value", "x") + eq(code, "type_conflict", "bytes cannot replace record") + eq(e.writeBytes(a, "state/empty", ""), true, "empty bytes write") + eq(e.readBytes(a, "state/empty"), "", "empty bytes read") + for _, key in ipairs({ "", "../escape", "/absolute", "double//slash", "tail/", "a.b", "a\\b" }) do + local ok + ok, code = e.write(a, key, {}) + eq(ok, false, "reject key " .. key) + eq(code, "invalid_key", "key error " .. key) + end + local ok + ok, code = e.write(a, "bad/value", { callback = function() end }) + eq(ok, false, "reject executable data") + eq(code, "encode_failed", "data-only error") + eq( + table.concat(assert(e.list(a, "state")), ","), + "state/bytes,state/empty,state/value", + "sorted unique listing" + ) + eq(table.concat(assert(e.list(a, "state/value")), ","), "state/value", "exact-key prefix") + local main + for path in pairs(f.files) do + if path:match("/first/portable_storage/state/value%.lua$") then + main = path + end + end + assert(main, "native persisted record missing") + f.files[main] = "corrupt" + eq(e.read(a, "state/value").n, 7, "backup recovery") + f.fs.fail = ".tmp" + ok, code = e.write(a, "state/value", { n = 8 }) + eq(ok, false, "staging failure") + eq(code, "write_failed", "staging error") + f.fs.fail = ".lua" + ok, code = e.write(a, "state/value", { n = 9 }) + eq(ok, false, "replacement failure") + eq(code, "write_failed", "replacement error") + f.fs.fail = nil + eq(e.read(a, "state/value").n, 7, "failed write retains data") + a.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(a)) + eq(selected:context().playthroughId, "first", "selected context") + eq(selected:read("state/value").n, 7, "selected read") + local detached = selected:context() + detached.playthroughId = "second" + a.save.meta.playthroughId = "second" + eq(selected:read("state/value").n, 7, "selected facade cannot be rerouted") + _, code = selected:read(b, "state/value") + eq(code, "invalid_key", "selected rejects injected game") + _, code = e.selected(b) + eq(code, "not_at_title", "selected title guard") + local empty = f.game(nil) + empty.stack = a.stack + _, code = e.selected(empty) + eq(code, "no_selected_playthrough", "selected does not allocate") + eq(e.delete(f.game("first"), "state/value"), true, "delete record") + _, code = selected:read("state/value") + eq(code, "not_found", "delete removes recovery copies") + eq(e.delete(f.game("first"), "state/bytes"), true, "delete bytes") + _, code = e.readBytes(f.game("first"), "state/bytes") + eq(code, "not_found", "delete removes byte recovery") + return rows +end +return M diff --git a/tests/playthrough_storage/fixture.lua b/tests/playthrough_storage/fixture.lua new file mode 100644 index 000000000..5a15f187b --- /dev/null +++ b/tests/playthrough_storage/fixture.lua @@ -0,0 +1,67 @@ +local M = {} +function M.new(root, backend, version) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported native game: " .. tostring(version)) + Version.set(version) + local Save = require("src.core.SaveData") + local Json = require("src.link.Json") + local files = {} + for _, id in ipairs({ "portable_storage", "other_storage" }) do + local function read(name) + local f = assert(io.open(root .. "/examples/portable_storage/" .. name, "rb")) + local s = f:read("*a") + f:close() + return s + end + local manifest = Json.decode(read("manifest.json")) + manifest.id = id + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local fs = T.sdk.memfs(files) + local write = fs.write + fs.write = function(path, body) + if fs.fail and path:sub(-#fs.fail) == fs.fail then + return false, "intentional write denial" + end + return write(path, body) + end + fs.remove = function(path) + files[path] = nil + return true + end + fs.createDirectory = function() + return true + end + local oldFs, oldPortable = love.filesystem, Save.portableFs + love.filesystem, Save.portableFs = fs, function() + return fs + end + Save.resetSlotState() + local result = T.sdk.loadMods({ "mods/portable_storage", "mods/other_storage" }, { + fs = fs, + data = T.fixtures.fresh(), + generation = Version.generation(), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + return { + T = T, + fs = fs, + files = files, + Save = Save, + result = result, + e = result.loader.exports.portable_storage, + other = result.loader.exports.other_storage, + game = function(id) + return { save = { version = version, meta = { playthroughId = id } } } + end, + close = function() + result.release() + love.filesystem, Save.portableFs = oldFs, oldPortable + Save.resetSlotState() + end, + } +end +return M diff --git a/tests/playthrough_storage/lifecycle.lua b/tests/playthrough_storage/lifecycle.lua new file mode 100644 index 000000000..728f68b06 --- /dev/null +++ b/tests/playthrough_storage/lifecycle.lua @@ -0,0 +1,27 @@ +local M = {} +function M.run(f, version) + local T, e, S, rows = f.T, f.e, f.Save, {} + local function check(value, label) + T.eq(value, true, label) + rows[#rows + 1] = { case = label, value = value } + end + local a = { save = S.newGame({ version = version }) } + local first = assert(e.context(a)).playthroughId + check(e.write(a, "lifecycle/value", { n = 27 }), "lifecycle write") + check(S.save(a.save, {}), "native ordinary save") + S.resetSlotState() + local loaded = { save = assert(S.load(version)) } + check(e.context(loaded).playthroughId == first, "native reload preserves storage identity") + check(e.read(loaded, "lifecycle/value").n == 27, "native reload preserves mod record") + local b = { save = S.newGame({ version = version }) } + check(e.context(b).playthroughId ~= first, "new game receives separate namespace") + local value, code = e.read(b, "lifecycle/value") + check(value == nil and code == "not_found", "new game cannot inherit prior mod record") + loaded.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(loaded)) + check(selected:context().playthroughId == first, "title resolves selected identity") + check(selected:read("lifecycle/value").n == 27, "title resolves selected mod record") + check(type(selected:context().normalSavedAt) == "number", "title resolves ordinary-save chronology") + return rows +end +return M diff --git a/tests/playthrough_storage/worker.lua b/tests/playthrough_storage/worker.lua new file mode 100644 index 000000000..b39c7f088 --- /dev/null +++ b/tests/playthrough_storage/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local f = dofile(root .. "/tests/playthrough_storage/fixture.lua").new(root, backend, version) +local rows = dofile(root .. "/tests/playthrough_storage/cases.lua").run(f, version) +local lifecycle = dofile(root .. "/tests/playthrough_storage/lifecycle.lua").run(f, version) +for _, row in ipairs(lifecycle) do + rows[#rows + 1] = row +end +f.close() +print("STORAGE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable playthrough storage") diff --git a/tools/storage-contract-check/check.py b/tools/storage-contract-check/check.py new file mode 100644 index 000000000..144ef075e --- /dev/null +++ b/tools/storage-contract-check/check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Compare unchanged API-3 storage consumers through real SDK/save lifecycles.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "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(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in VERSIONS: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/playthrough_storage/worker.lua"), + str(gen2), backend, version], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("STORAGE_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("STORAGE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"storage mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching API-3 storage/lifecycle observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": 3, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_storage/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From aef565014e18051acc979aabac6641978b5e822d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:47:53 +0200 Subject: [PATCH 28/86] Preserve requested storage identities across stale option writes --- src/core/SaveData.lua | 6 ++++++ tests/engine/api3_playthrough_storage.lua | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 8fa8afde8..ffa666151 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -597,6 +597,12 @@ function SaveData.saveOptions(opts, fs, gen) -- write cannot clobber another mod's persisted keys. Every other -- option stays on the shallow path. local onDisk = readTable(fs, OPTIONS_FILENAME) + -- A pre-storage UI/settings snapshot has no identity map. Saving it must + -- not erase tool state allocated since that snapshot was taken. Engine + -- routing changes supply a table explicitly (including an empty table). + if onDisk and opts.playthroughIds == nil and type(onDisk.playthroughIds) == "table" then + opts.playthroughIds = onDisk.playthroughIds + end if onDisk and type(onDisk.modOptions) == "table" then local merged = {} for modId, bucket in pairs(onDisk.modOptions) do diff --git a/tests/engine/api3_playthrough_storage.lua b/tests/engine/api3_playthrough_storage.lua index 1b3a3e2e5..ee24e1524 100644 --- a/tests/engine/api3_playthrough_storage.lua +++ b/tests/engine/api3_playthrough_storage.lua @@ -35,6 +35,13 @@ for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do T.eq(files[path], bytes, "legacy allocation never rewrites progress") end T.check(e.write(first, "progress/value", { n = 12 }), "legacy mod state writes") + T.check(S.saveOptions(S.defaultOptions(), fs), "stale options snapshot writes") + local persisted = S.loadOptions(fs).playthroughIds + T.eq( + persisted and persisted[version] and persisted[version].legacy, + id, + "stale option writers cannot erase a requested storage identity" + ) S.resetSlotState() local resumed = { save = assert(S.load(version)) } T.eq(e.context(resumed).playthroughId, id, "legacy promotion/restart retains identity") @@ -72,6 +79,17 @@ for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do imported.save.meta.playthroughId, "mod-set stamp retains identity" ) + local external = S.loadOptions(fs) + external.saveSlots[version].active = sourceSlot + S.saveOptions(external, fs) + local localTitle = new() + localTitle.stack = boot.stack + T.eq( + e.selected(localTitle):context().playthroughId, + imported.save.meta.playthroughId, + "another launcher's selection cannot reroute this process's native slot" + ) + S.setActiveSlot(version, copySlot) T.check(S.deleteSlot(version, copySlot), "identified slot deletes") local opts = S.loadOptions(fs) T.eq(opts.playthroughIds[version][copySlot], nil, "deleted slot loses routing binding") From d7581ba7202773cd58945c00fb030fc1a35e6bb5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:53 +0200 Subject: [PATCH 29/86] Add native live-map lifecycle events and portable API-3 pixel placements --- .github/workflows/live-maps-contract.yml | 44 +++++ docs/api3-live-maps.md | 85 ++++++++++ docs/world-read-api.md | 5 +- examples/portable_live_maps/main.lua | 21 +++ examples/portable_live_maps/manifest.json | 1 + src/mods/Api3.lua | 8 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 1 + src/world/LiveMaps.lua | 35 ++++ src/world/OverworldController.lua | 11 +- tests/engine/live_maps_lifecycle.lua | 112 +++++++++++++ tests/live_maps/cases.lua | 41 +++++ tests/live_maps/fixture.lua | 189 ++++++++++++++++++++++ tests/live_maps/worker.lua | 7 + tools/live-maps-contract-check/check.py | 61 +++++++ 15 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/live-maps-contract.yml create mode 100644 docs/api3-live-maps.md create mode 100644 examples/portable_live_maps/main.lua create mode 100644 examples/portable_live_maps/manifest.json create mode 100644 src/world/LiveMaps.lua create mode 100644 tests/engine/live_maps_lifecycle.lua create mode 100644 tests/live_maps/cases.lua create mode 100644 tests/live_maps/fixture.lua create mode 100644 tests/live_maps/worker.lua create mode 100644 tools/live-maps-contract-check/check.py diff --git a/.github/workflows/live-maps-contract.yml b/.github/workflows/live-maps-contract.yml new file mode 100644 index 000000000..a1b8e83bb --- /dev/null +++ b/.github/workflows/live-maps-contract.yml @@ -0,0 +1,44 @@ +name: portable live-map lifecycle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-maps-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native lifecycle, pixel reader and legacy readers + run: | + luajit tests/engine/live_maps_lifecycle.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/world_read_api.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/live-maps-contract + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/live-maps-contract/api1-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/live-maps-contract/api2-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/live-maps-contract/api2-unified.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/live-maps-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: live-maps-contract + path: /tmp/live-maps-contract + if-no-files-found: ignore diff --git a/docs/api3-live-maps.md b/docs/api3-live-maps.md new file mode 100644 index 000000000..520ac1ac0 --- /dev/null +++ b/docs/api3-live-maps.md @@ -0,0 +1,85 @@ +# Portable live-map placements and lifecycle + +API 3 `mod.world:liveMaps()` returns detached placements with the Gen1Recomp +contract. The active map is first, followed by the native loaded-neighbor order: + +```lua +{ + { mapId = "ROOT", ox = 0, oy = 0, active = true }, + { mapId = "EAST", ox = 320, oy = -32, active = false }, +} +``` + +`ox`/`oy` are **world pixels**, relative to the active map. Negative offsets, +native ordering and repeated placements are retained. The query reads maps +already held by the world: it does not load maps, spawn NPCs, rebuild neighbors, +consume RNG or fire events. No game/world returns the existing refusal result. +Changing the returned records cannot change native residency or placement. +The shared native Gen2 overworld supports this API-3 reader on all Gen1–5 hosts. +Other API-3 world methods keep their own generation/availability limits. + +API 1/2 retain the enriched `WorldReads.liveMaps` records documented in +`world-read-api.md`: `x`/`y` are walk cells, with geometry metadata and duplicate +placement collapsing. API 3 previously inherited that Gen2 shape; this corrects +its pixel-field mismatch with Gen1. API-3 consumers must use `ox`/`oy` rather +than infer offsets from the legacy cell fields. + +## Native lifecycle events + +The shared overworld dispatches these around its actual connected-map ghost +rebuild: + +- `world.live_maps_preparing`: maps have been loaded and retained, before their + neighbor NPCs/ghosts are rebuilt. The internal preparation phase is active + during synchronous listeners. +- `world.live_maps_updated`: neighbor NPCs and cast invalidation have completed; + the preparation phase is over. + +Both payloads are `{ mapId = "ROOT", maps = }` in all +SDK formats. They contain fresh data records, not native Map handles. No return +value vetoes a rebuild. Listener failure follows the event bus's normal recovery +and does not stop native construction. Recursive rebuilds during preparation +suppress nested notifications and restore the outer preparation flag. Ordinary +rebuilds, including ones whose map IDs happen to stay the same, still notify. +When nobody subscribes, the native path constructs no event snapshots. + +```lua +local mod = ... +if mod:supports("events", "world.live_maps_updated") then + mod.events:on("world.live_maps_updated", function(payload) + for _, placement in ipairs(payload.maps) do + -- Refresh this mod's map-dependent state from mapId and pixel offsets. + end + end) +end +``` + +Gen1's pinned GB/Gold native paths provide these events. Its GBA field provides +the pixel reader but has no native producer for these event names. Capability +checks must therefore gate event-dependent behavior; registration alone does +not prove a producer exists. Gen2's shared native world provides both events +across all fifteen supported hosts. This does not provide a follower entity or +change the separate `world.follower.spawn` hook. + +## Verification + +The unchanged `portable_live_maps` consumer runs through both real SDKs and +native GB/Gold rebuild methods for Red, Blue, Yellow, Gold, Silver and Crystal, +in API 1 native, API 2 native/unified and API 3. The API-3 reader is also compared +with Gen1's real FireRed/Emerald world facade. Gen1 remains clean and pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`; its world producers are unchanged at +current dev `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +Gen2 additionally tests all host versions, real NPC ghost construction after a +preparing listener, legacy cell-reader availability during preparation, native +placement duplicates, safe missing-world reads, once/unsubscribe, entry rollback, +listener failures, nested rebuilds and the unsubscribed allocation-free path. +Fixtures replace asset-loading boundaries while retaining the native graph walk, +Map/NPC objects and rebuild/SDK/event logic. These are ROM-free contracts, not +GPU pixel or full-playthrough validation. + +```sh +luajit tests/engine/live_maps_lifecycle.lua +python3 tools/live-maps-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/live-maps.json +``` diff --git a/docs/world-read-api.md b/docs/world-read-api.md index 01b2a60d0..7fce046bc 100644 --- a/docs/world-read-api.md +++ b/docs/world-read-api.md @@ -108,7 +108,10 @@ 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 +## Loaded map snapshots (API 1/2) + +API 3 uses the portable pixel-placement contract in `api3-live-maps.md`. +The enriched records below remain the API-1/2 contract. `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 diff --git a/examples/portable_live_maps/main.lua b/examples/portable_live_maps/main.lua new file mode 100644 index 000000000..da0cfb322 --- /dev/null +++ b/examples/portable_live_maps/main.lua @@ -0,0 +1,21 @@ +local mod = ... +local events, stops = {}, {} +for _, phase in ipairs({ "preparing", "updated" }) do + local label = phase + local name = "world.live_maps_" .. phase + if not mod.supports or mod:supports("events", name) then + stops[#stops + 1] = mod.events:on(name, function(payload) + events[#events + 1] = { phase = label, mapId = payload.mapId, maps = payload.maps } + end) + end +end +mod.exports.api = mod +mod.exports.events = events +mod.exports.maps = function() + return mod.world:liveMaps() +end +mod.exports.stop = function() + for _, stop in ipairs(stops) do + stop() + end +end diff --git a/examples/portable_live_maps/manifest.json b/examples/portable_live_maps/manifest.json new file mode 100644 index 000000000..9e02dea12 --- /dev/null +++ b/examples/portable_live_maps/manifest.json @@ -0,0 +1 @@ +{"id": "portable_live_maps", "name": "Portable live-map lifecycle", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua", "affects_link": false} diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 984a886bf..fda5cd0d0 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -85,7 +85,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) return nativeWorld end local function dispatch(name, ...) - if not supportedGeneration or not SUPPORTED[name] then + if (not supportedGeneration and not (name == "liveMaps" and gen >= 1 and gen <= 5)) or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end local game = current() @@ -137,6 +137,11 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "queueScript" then return scripts:queueScript(...) end + if name == "liveMaps" then + local ow = native(game):overworld() + if not (ow and ow.map) then return nil, "no overworld" end + return require("src.world.LiveMaps").snapshot(ow) + end local raw = native(game) local result = pack(raw[name](raw, ...)) if name == "party" and type(result[1]) == "table" then @@ -194,6 +199,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) local supports = api.supports function api:supports(surface, name) if surface == "world" then + if name == "liveMaps" and gen >= 1 and gen <= 5 then return true end if supportedGeneration and SUPPORTED[name] then return true end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 52f2f930f..dc3fbfe5c 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -36,6 +36,7 @@ add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ + world.live_maps_preparing world.live_maps_updated 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 diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c28a85dc8..ba100053d 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -27,6 +27,7 @@ C.hooks = names([[ ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) C.events = names([[ + world.live_maps_preparing world.live_maps_updated 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 diff --git a/src/world/LiveMaps.lua b/src/world/LiveMaps.lua new file mode 100644 index 000000000..9c312c578 --- /dev/null +++ b/src/world/LiveMaps.lua @@ -0,0 +1,35 @@ +-- Detached residency snapshots. Offsets are world pixels, as in Gen1Recomp. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.snapshot(world) + local out = {} + if not (world and world.map) then + return out + end + out[1] = { mapId = world.map.id, ox = 0, oy = 0, active = true } + for _, neighbor in ipairs(world.neighbors or {}) do + out[#out + 1] = { + mapId = neighbor.map and neighbor.map.id or neighbor.id, + ox = neighbor.ox or (neighbor.cx or 0) * 16, + oy = neighbor.oy or (neighbor.cy or 0) * 16, + active = false, + } + end + return out +end +function M.preparing(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_preparing") then + return + end + local previous = world.preparingLiveMaps + world.preparingLiveMaps = true + Runtime.emit("world.live_maps_preparing", { mapId = world.map.id, maps = M.snapshot(world) }) + world.preparingLiveMaps = previous +end +function M.updated(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_updated") then + return + end + Runtime.emit("world.live_maps_updated", { mapId = world.map.id, maps = M.snapshot(world) }) +end +return M diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e143fb17a..104169687 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -20,6 +20,7 @@ local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") +local LiveMaps = require("src.world.LiveMaps") local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") @@ -2036,6 +2037,10 @@ function OverworldState:neighborReach() return rw, rh, vw, vh end +function OverworldState:liveMaps() + return LiveMaps.snapshot(self) +end + function OverworldState:rebuildNeighbors() local mapId = self.map.id self.neighbors = {} @@ -2052,11 +2057,13 @@ function OverworldState:rebuildNeighbors() hops, reachW, reachH)) do keep[n.id] = true local m = MapLoader.load(Game.data, n.id) - table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy }) + table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy, cx = n.ox / 16, cy = n.oy / 16 }) end -- bound resident memory: drop maps behind us that are neither current nor -- a drawn neighbor, releasing their window batch / border image / atlas MapLoader.trim(keep) + LiveMaps.preparing(self) + -- visual-only NPCs on connected maps (survey zoom): same spawn filter -- as a real map entry, but they never join self.entities -- no sight @@ -2086,7 +2093,6 @@ function OverworldState:rebuildNeighbors() -- the seam offset in CELLS. Everything in this engine steps on a 16px -- grid whatever the dataset's block size, and a connection offset is a -- whole number of blocks, so this is always exact. - nb.cx, nb.cy = nb.ox / 16, nb.oy / 16 for _, obj in ipairs(nb.map.def.objects or {}) do if objectVisible(Game.save, nb.map.id, obj) and not offMap(nb.map.def, obj) then local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj) @@ -2098,6 +2104,7 @@ function OverworldState:rebuildNeighbors() end end self.cast = nil + LiveMaps.updated(self) end -- ONE CAST, IN EVERY LOADED MAP'S OWN CELLS. diff --git a/tests/engine/live_maps_lifecycle.lua b/tests/engine/live_maps_lifecycle.lua new file mode 100644 index 000000000..077832730 --- /dev/null +++ b/tests/engine/live_maps_lifecycle.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local LiveMaps = require("src.world.LiveMaps") +local Fixture = dofile("tests/live_maps/fixture.lua") +local Cases = dofile("tests/live_maps/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + f.commonEvents = true -- Gen2's real shared overworld produces events on every host. + local world, e = f.world, f.e + T.eq(e.api:supports("events", "world.live_maps_preparing"), true, "native preparing capability") + T.eq(e.api:supports("events", "world.live_maps_updated"), true, "native updated capability") + Cases.run(f) + if mode[1] < 3 then + local maps = e.maps() + T.eq(maps[2].x, 2 * f.pixel / 16, "legacy reader retains cell coordinates") + T.eq(maps[2].ox, nil, "legacy reader shape is preserved") + else + T.eq(e.api:supports("world", "liveMaps"), true, "API-3 reader supports native host") + local neighbors = world.neighbors + world.neighbors = { neighbors[1], neighbors[1], { map = neighbors[1].map, ox = -16, oy = 32 } } + local placements = e.maps() + T.eq(#placements, 4, "API-3 retains native placement order and duplicates") + T.eq(placements[4].ox, -16, "same map at another offset is retained") + world.neighbors = neighbors + f.game.world, f.game.overworld = nil, nil + local missing, why = e.maps() + T.eq(missing, nil, "API-3 query without a world is safe") + T.eq(why, "no overworld", "API-3 missing-world reason") + f.game.world, f.game.overworld = world, world + end + local once = 0 + e.api.events:once("world.live_maps_updated", function() + once = once + 1 + end) + f.rebuild() + f.rebuild() + T.eq(once, 1, "once unsubscribes from native event") + local calls = 0 + local off = e.api.events:on("world.live_maps_preparing", function() + calls = calls + 1 + T.eq(world.preparingLiveMaps, true, "preparation callback owns preparation phase") + local legacy = require("src.mods.WorldReads").liveMaps(world) + T.eq(legacy[2].x, 2 * f.pixel / 16, "legacy offsets are ready during preparation") + f.game.data.maps.EAST.objects = { + { + index = 1, + name = "LIVE_GHOST", + sprite = "SPRITE_FIX_NPC", + movement = "STAY", + range = "NONE", + x = 0, + y = 0, + }, + } + end) + f.rebuild() + T.eq(calls, 1, "preparing runs once before native ghost construction") + T.eq(#world.ghosts, 1, "preparation changes reach actual ghost builder") + T.eq(world.ghosts[1].npc.id, "EAST_obj_1", "native NPC was constructed") + off() + local before = #e.events + local failure = e.api.events:on("world.live_maps_preparing", function() + error("intentional listener failure") + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "failed listener does not prevent either lifecycle notification") + T.eq(world.preparingLiveMaps, nil, "failed listener leaves no preparation flag") + failure() + before = #e.events + local nested = e.api.events:on("world.live_maps_preparing", function() + f.rebuild() + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "nested rebuild does not recursively notify preparation") + T.eq(world.preparingLiveMaps, nil, "nested rebuild restores preparation flag") + nested() + before = #e.events + e.stop() + e.stop() + f.rebuild() + T.eq(#e.events, before, "consumer unsubscribe stops native delivery") + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry failure is recorded") + failed.rebuild() + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + for _, entry in ipairs(Runtime.events.listeners[name] or {}) do + T.check(entry.owner ~= "portable_live_maps", "entry rollback removes owned listener") + end + end + failed.close() + end +end +-- No-listener rebuilds never allocate a snapshot; data-only reads never load maps. +do + local f = Fixture.new(".", "gen2", "red", 3, "unified") + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + local snapshot = LiveMaps.snapshot + LiveMaps.snapshot = function() + error("unsubscribed snapshot allocation") + end + local ok = pcall(f.rebuild) + LiveMaps.snapshot = snapshot + T.eq(ok, true, "native hot path skips snapshots without listeners") + f.close() +end +Version.set(old) +T.finish("portable live-map events and API-3 pixel placements") diff --git a/tests/live_maps/cases.lua b/tests/live_maps/cases.lua new file mode 100644 index 000000000..8b27c2020 --- /dev/null +++ b/tests/live_maps/cases.lua @@ -0,0 +1,41 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + f.rebuild() + if f.commonEvents then + T.eq(#e.events, 2, "native rebuild emits one pair") + T.eq(e.events[1].phase, "preparing", "preparing arrives first") + T.eq(e.events[2].phase, "updated", "updated arrives last") + T.eq(f.phase[1].preparing, true, "native preparation flag is set") + T.eq(f.phase[1].ghosts, 1, "preparing precedes ghost rebuild") + T.eq(f.phase[2].preparing, false, "updated observes settled preparation") + T.eq(f.phase[2].ghosts, 0, "updated follows ghost rebuild") + for _, event in ipairs(e.events) do + T.eq(event.mapId, "ROOT", "event identifies active map") + T.eq(#event.maps, 3, "event includes active and connected maps") + T.eq(event.maps[1].mapId, "ROOT", "active placement first") + T.eq(event.maps[1].active, true, "active flag") + T.eq(event.maps[2].ox, 2 * f.pixel, "east offset is pixels") + T.eq(event.maps[2].oy, -f.pixel, "negative offset preserved") + rows[#rows + 1] = event + end + e.events[1].maps[1].mapId = "changed-copy" + T.eq(f.world.map.id, "ROOT", "event mutation cannot change native map") + e.events[1].maps[1].mapId = "ROOT" + end + if f.api >= 3 then + local before = f.loaderCalls() + local maps = assert(e.maps()) + T.eq(#maps, 3, "API-3 query reports loaded maps") + T.eq(maps[1].ox, 0, "API-3 active x is pixels") + T.eq(maps[2].ox, 2 * f.pixel, "API-3 neighbor x is pixels") + T.eq(maps[2].oy, -f.pixel, "API-3 neighbor y is pixels") + T.eq(maps[2].x, nil, "API-3 does not substitute cell coordinates") + T.eq(f.loaderCalls(), before, "query never loads a map") + maps[2].ox = 999 + T.eq(e.maps()[2].ox, 2 * f.pixel, "query returns detached placements") + rows[#rows + 1] = { query = e.maps() } + end + return rows +end +return M diff --git a/tests/live_maps/fixture.lua b/tests/live_maps/fixture.lua new file mode 100644 index 000000000..a8d58abb9 --- /dev/null +++ b/tests/live_maps/fixture.lua @@ -0,0 +1,189 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported fixture version") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local pixel = gen >= 3 and 16 or 32 + local defs = {} + for _, id in ipairs({ "ROOT", "EAST", "NORTH" }) do + defs[id] = { + id = id, + width = 2, + height = 2, + blockPx = pixel, + tileset = "FIX_OUT", + blocks = { 0, 0, 0, 0 }, + borderBlock = 0, + objects = {}, + connections = {}, + } + end + defs.ROOT.connections.east = { map = "EAST", offset = -1 } + defs.EAST.connections.north = { map = "NORTH", offset = -3 } + defs.NORTH.connections.south = { map = "ROOT", offset = 0 } + data.maps = defs + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = + { blockCells = pixel / 16, blockTiles = pixel / 8, blocks = {}, walkable = {}, id = "FIX_OUT" } + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_live_maps/" .. name, "rb")) + files["mods/portable_live_maps/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/portable_live_maps/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_live_maps/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_live_maps/main.lua"] = files["mods/portable_live_maps/main.lua"] + .. '\nerror("intentional entry failure")' + end + local result = T.sdk.loadMods({ "mods/portable_live_maps" }, { + fs = T.sdk.memfs(files), + generation = gen, + data = data, + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function bind(fn, name, value) + for i = 1, 40 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + restores[#restores + 1] = function() + debug.setupvalue(fn, i, old) + end + return + end + end + error("missing native binding: " .. name) + end + local game = { + data = data, + save = { version = version, options = {}, flags = {}, party = {} }, + renderer = { + worldViewSize = function() + return 160, 144 + end, + }, + } + local maps = {} + local Map = require("src.world.Map") + for id, def in pairs(defs) do + maps[id] = Map.new(def, data.tilesets.FIX_OUT) + end + local world, rebuild, source, loaderCalls = nil, nil, nil, 0 + if backend == "gen1" and gen == 3 then + local Field, Runtime, Map3 = + require("src.core.game3.field"), require("src.core.game3.runtime"), require("src.core.game3.map") + game.phase, game.session = "field", { map = "ROOT" } + patch(Field, "running", true) + patch(Runtime, "getSession", function() + return game.session + end) + patch(Map3, "current", "ROOT") + source = { + { id = "ROOT", ox = 0, oy = 0 }, + { id = "EAST", ox = 2 * pixel, oy = -pixel }, + { id = "NORTH", ox = -pixel, oy = -3 * pixel }, + } + patch(Map3, "world", source) + world = { map = maps.ROOT, neighbors = {} } + rebuild = function() end -- Gen1 GBA has no producer for these events. + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + map = maps.ROOT, + maps = defs, + tilesets = data.tilesets, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + pixelZoomScale = function() + return 1 + end, + imageFor = function() + return {} + end, + }, { __index = World }) + rebuild = function() + world:rebuildNeighbors() + world:rebuildPeople() + end + else + local World, Loader = require("src.world.OverworldController"), require("src.world.MapLoader") + world = setmetatable({ + game = game, + map = maps.ROOT, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + }, { __index = World }) + bind(World.rebuildNeighbors, "Game", game) + patch(Loader, "load", function(_, id) + loaderCalls = loaderCalls + 1 + return assert(maps[id]) + end) + patch(Loader, "trim", function() end) + rebuild = function() + world:rebuildNeighbors() + end + end + game.world, game.overworld = world, world + result.loader.game = game + local phase = {} + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + local label = name + result.loader.events:on(name, function() + phase[#phase + 1] = + { name = label, preparing = world.preparingLiveMaps == true, ghosts = #world.ghosts } + end, 100) + end + return { + T = T, + game = game, + world = world, + result = result, + e = result.loader.exports.portable_live_maps, + api = api, + gen = gen, + backend = backend, + pixel = pixel, + phase = phase, + rebuild = rebuild, + commonEvents = gen <= 2, + source = source, + patch = patch, + loaderCalls = function() + return loaderCalls + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/live_maps/worker.lua b/tests/live_maps/worker.lua new file mode 100644 index 000000000..bb9fc7e68 --- /dev/null +++ b/tests/live_maps/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/live_maps/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/live_maps/cases.lua").run(f) +f.close() +print("LIVE_MAPS_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable live-map lifecycle") diff --git a/tools/live-maps-contract-check/check.py b/tools/live-maps-contract-check/check.py new file mode 100644 index 000000000..1b7ddb3c6 --- /dev/null +++ b/tools/live-maps-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare native live-map events and the unchanged API-3 pixel reader.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + if a.api >= 3: + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/live_maps/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("LIVE_MAPS_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("LIVE_MAPS_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "events+reader" if a.api >= 3 and version not in ["firered", "emerald"] + else ("reader" if a.api >= 3 else "events"), + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"live maps mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native lifecycle/reader observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_live_maps/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5481fa2a463a24cf314c6ad60c8fb7df886fa495 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:29:39 +0200 Subject: [PATCH 30/86] Add portable native follower predicate and shared lifecycle contracts --- .github/workflows/follower-contract.yml | 44 +++++++ docs/api3-portability.md | 4 + docs/modding.md | 3 + docs/portable-follower.md | 78 ++++++++++++ examples/portable_follower/main.lua | 18 +++ examples/portable_follower/manifest.json | 1 + src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- src/world/OptionalFollower.lua | 143 +++++++++++++++++++++ src/world/PikachuFollower.lua | 21 +++- tests/engine/follower_spawn.lua | 99 +++++++++++++++ tests/follower/cases.lua | 133 ++++++++++++++++++++ tests/follower/fixture.lua | 153 +++++++++++++++++++++++ tests/follower/worker.lua | 14 +++ tools/follower-contract-check/check.py | 59 +++++++++ 15 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/follower-contract.yml create mode 100644 docs/portable-follower.md create mode 100644 examples/portable_follower/main.lua create mode 100644 examples/portable_follower/manifest.json create mode 100644 src/world/OptionalFollower.lua create mode 100644 tests/engine/follower_spawn.lua create mode 100644 tests/follower/cases.lua create mode 100644 tests/follower/fixture.lua create mode 100644 tests/follower/worker.lua create mode 100644 tools/follower-contract-check/check.py diff --git a/.github/workflows/follower-contract.yml b/.github/workflows/follower-contract.yml new file mode 100644 index 000000000..8b2fd95c4 --- /dev/null +++ b/.github/workflows/follower-contract.yml @@ -0,0 +1,44 @@ +name: portable follower predicate contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: follower-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native followers and compatibility + run: | + luajit tests/engine/follower_spawn.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/follower-contract + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/follower-contract/api1-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/follower-contract/api2-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/follower-contract/api2-unified.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/follower-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: follower-contract + path: /tmp/follower-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index e324d5c36..7e56d41ba 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -143,3 +143,7 @@ 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. + +The [portable follower predicate](portable-follower.md) controls native companion +existence on Gen1–3 without replacing a private controller. Artwork remains a +separate concern; check `mod:supports("hooks", "world.follower.spawn")`. diff --git a/docs/modding.md b/docs/modding.md index 7c051609b..f9d14d0d9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -429,3 +429,6 @@ 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. + +See [portable follower predicate](portable-follower.md) for the native +`world.follower.spawn` lifecycle hook and its generation/artwork boundaries. diff --git a/docs/portable-follower.md b/docs/portable-follower.md new file mode 100644 index 000000000..3efebd839 --- /dev/null +++ b/docs/portable-follower.md @@ -0,0 +1,78 @@ +# Portable follower predicate + +`world.follower.spawn(next, game, world)` controls whether the native optional +companion exists. It receives the original opaque Game/world handles in every +SDK mode. Return `next(game, world)` to keep the host's native decision, or a +Lua truthy/falsey result to enable/disable it. In particular, `0` and `""` enable +it; `false` and `nil` disable it. This is a lifecycle predicate, not a once-only +spawn notification: do not depend on a callback count per frame. + +```lua +local mod = ... +if mod:supports("hooks", "world.follower.spawn") then + mod.hooks:wrap("world.follower.spawn", function(next, game, world) + if myOptions.companion then return true end + return next(game, world) + end) +end +``` + +The producer runs on native map entry and normal follower updates. A denial +removes the companion from its native update/draw lists; a later allowance +recreates it. Removing the wrapper restores the native predicate. Wrapper +errors retain the normal hook bus recovery rules, including keeping a +completed downstream decision after a post-`next` failure. API 3 repeated +`next` evaluates the downstream chain once per invocation. + +## Native scope + +- Gen 1 uses the existing Pikachu controller. The unchanged Yellow default + still checks the starter event, healthy party Pikachu, biking/surfing and + available artwork. Other GB games default to no follower. Existing scripted + Yellow scenes retain their own update ownership. +- Gen 2 and GBA default to no companion. An allowance creates a passable native + NPC which follows committed player steps; the ordinary world NPC loop owns + its interpolation and rendering. Gold-family mid-map spawns start behind + the player when walkable; map loads and GBA spawns start under the player. + Connection carry preserves the NPC and buffered trail. GBA same-map long + teleports reset the companion to the player. These optional companions do + not enter Yellow's emotion/dialogue controller. +- DS reports the hook unavailable. Scripted escorts and HGSS's walking Pokemon + keep their native controllers; this hook does not take ownership of them. + +Artwork and actor internals are not normalized by this predicate. Gen2Recomped +uses `SPRITE_PIKACHU` when present, otherwise the player's sheet (then the native +placeholder fallback); each companion owns a renderer. The fallback never +writes a registry alias. The pinned Gen1 GBA controller instead leaves its +optional renderer to the mod. An artwork mod that imports or replaces private +controllers still needs a public artwork/actor contract before it is portable. +Yellow's existing native eligibility and scene mechanics also differ between +the engines; this PR preserves their defaults rather than changing them. + +## Validation + +The unchanged `examples/portable_follower` entry and manifest are loaded through +both real SDKs. Shared tests drive the real GB, Gold and GBA follower modules +and native NPC interpolation, with fixture assets/session data only. No fake +follower or hand-called Runtime hook substitutes for the native controllers. +The reference is a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The checker covers Red/Blue/Yellow/Gold/Silver/Crystal/FireRed/Emerald in API 1 +native, API 2 native/unified and API 3. Gen1 API 2 has no optional unified mode; +that comparison exercises Gen2's opt-in format against Gen1's native format. +Cases include native defaults, original handles, allow/deny, every interpolated +frame of a four-direction walk, map entry, GB/Gold connection carry/rebase, GBA +teleport, Lua truthiness, wrapper failure, removal, API-3 repeated `next`, and +failed-entry cleanup. These are controller/SDK contracts, not identical artwork, +Yellow scene parity or full-playthrough validation. + +Gen2 unit tests additionally check every host version, DS exclusions, unrelated +NPC preservation, fallback rendering ownership, cast invalidation and legacy +SDK behavior. + +```sh +luajit tests/engine/follower_spawn.lua +python3 tools/follower-contract-check/check.py --gen1 /path/to/clean/gen1 \ + --api 3 --format unified --report /tmp/follower-contract.json +``` diff --git a/examples/portable_follower/main.lua b/examples/portable_follower/main.lua new file mode 100644 index 000000000..b3c8f6ef9 --- /dev/null +++ b/examples/portable_follower/main.lua @@ -0,0 +1,18 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "world.follower.spawn") then + stop = mod.hooks:wrap("world.follower.spawn", function(next, game, world) + state.calls, state.game, state.world = state.calls + 1, game, world + if state.mode == "native" then + return next(game, world) + end + return state.mode == "allow" + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_follower/manifest.json b/examples/portable_follower/manifest.json new file mode 100644 index 000000000..8d7e6f494 --- /dev/null +++ b/examples/portable_follower/manifest.json @@ -0,0 +1 @@ +{"id":"portable_follower","name":"Portable follower predicate","version":"1.0.0","api":3,"games":["all"],"entry":"main.lua","affects_link":false} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index dc3fbfe5c..946d8f973 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -33,6 +33,7 @@ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_men add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") +add("hooks", "123", "world.follower.spawn") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ba100053d..4414a5f31 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -24,7 +24,7 @@ C.hooks = names([[ music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle 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.talk world.tod zoom.range + ui.title_menu.items warp.destination world.follower.spawn world.talk world.tod zoom.range ]]) C.events = names([[ world.live_maps_preparing world.live_maps_updated @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["world.follower.spawn"] = { false, false }, ["ui.party.grid_navigation"] = { false }, ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, diff --git a/src/world/OptionalFollower.lua b/src/world/OptionalFollower.lua new file mode 100644 index 000000000..c89bf6329 --- /dev/null +++ b/src/world/OptionalFollower.lua @@ -0,0 +1,143 @@ +-- Opt-in GB Color/GBA companion. The public predicate selects whether it +-- exists; the native NPC update/draw lists own movement and rendering. +local NPC = require("src.world.NPC") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +local function nativeDefault() + return false +end +local function current(world) + for i, npc in ipairs(world.npcs or {}) do + if npc.optionalFollower then + return npc, i + end + end +end +local function remove(world) + local npc, at = current(world) + if not npc then + return + end + table.remove(world.npcs, at) + for i, entity in ipairs(world.entities or {}) do + if entity == npc then + table.remove(world.entities, i) + break + end + end + world.follower, world.cast = nil, nil +end +local function spawn(game, world, opts, viaMapLoad) + local keep = opts and (opts.keepFollower or opts.keepPikachu) + if keep then + table.insert(world.npcs, keep) + table.insert(world.entities, keep) + world.follower, world.cast = keep, nil + return + end + local p, map = world.player, world.map + local x, y = p.cellX, p.cellY + if not viaMapLoad and Version.generation() == 2 then + local d = DELTA[p.facing] or DELTA.down + if map:inBounds(x - d[1], y - d[2]) and map:isWalkableCell(x - d[1], y - d[2]) then + x, y = x - d[1], y - d[2] + end + end + -- A missing optional sheet must not mutate the content registry. Give the + -- NPC its own renderer over the player definition (then NPC's placeholder + -- fallback if neither sheet exists), rather than sharing a renderer. + local data = game.data + if not (data.sprites and data.sprites.SPRITE_PIKACHU) then + local def = p.spriteDef or (p.sprite and p.sprite.def) + if def then + data = setmetatable({ + sprites = setmetatable({ SPRITE_PIKACHU = def }, { __index = data.sprites }), + }, { __index = data }) + end + end + local npc = NPC.new(data, map.id, { + index = 250, + name = "FOLLOWER", + sprite = "SPRITE_PIKACHU", + movement = "STAY", + range = "NONE", + x = x, + y = y, + }) + npc.optionalFollower, npc.follower, npc.pikachuFollower, npc.passable = true, true, true, true + npc.facing, npc.elevation = p.facing or "down", p.elevation + table.insert(world.npcs, npc) + table.insert(world.entities, npc) + world.follower, world.cast = npc, nil + world.followerTrail = { x = p.cellX, y = p.cellY } + world.pikachuTrail = world.followerTrail +end + +function F.onMapEntered(game, world, opts, viaMapLoad) + if not (world and world.map and world.player) then + return + end + remove(world) + if Runtime.call("world.follower.spawn", nativeDefault, game, world) then + spawn(game, world, opts, viaMapLoad) + end +end + +function F.update(game, world) + if not (world and world.map and world.player) then + return + end + -- Lua truthiness is the Gen1 predicate contract, including 0 and "". + if not Runtime.call("world.follower.spawn", nativeDefault, game, world) then + remove(world) + return + end + local npc, p = current(world), world.player + if not npc then + spawn(game, world) + return + end + local trail = world.followerTrail + if not trail then + trail = { x = p.cellX, y = p.cellY } + world.followerTrail, world.pikachuTrail = trail, trail + end + if Version.generation() == 3 and math.abs(p.cellX - trail.x) + math.abs(p.cellY - trail.y) > 6 then + remove(world) + spawn(game, world) + return + end + local x, y = p.targetX or p.cellX, p.targetY or p.cellY + if x ~= trail.x or y ~= trail.y then + npc.goalX, npc.goalY = trail.x, trail.y + trail.x, trail.y = x, y + end + npc.elevation = p.elevation + if npc.moving or not npc.goalX then + return + end + local dx, dy = npc.goalX - npc.cellX, npc.goalY - npc.cellY + local distance = math.abs(dx) + math.abs(dy) + if distance == 0 then + npc.goalX, npc.goalY = nil, nil + return + end + if distance > 6 then + npc.cellX, npc.cellY = npc.goalX, npc.goalY + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.goalX, npc.goalY = nil, nil + return + end + npc.facing = dx > 0 and "right" or dx < 0 and "left" or dy > 0 and "down" or "up" + local d = DELTA[npc.facing] + npc.stepDir, npc.targetX, npc.targetY = npc.facing, npc.cellX + d[1], npc.cellY + d[2] + local frames = p.stepFramesCur or p.stepFrames or 16 + npc.stepFrames = distance > 1 and math.max(1, math.floor(frames / 2)) or frames + npc.progress, npc.moving = 0, true + -- The world's normal NPC loop precedes this controller, so advance frame 1. + npc:update(world.map, world.entities) +end +return F diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b6..100cc8ccb 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -17,6 +17,7 @@ local Collision = require("src.world.Collision") local GameVersion = require("src.core.GameVersion") +local ModRuntime = require("src.mods.Runtime") local PikachuFollower = {} @@ -166,6 +167,7 @@ local function remove(ow) for j, e in ipairs(ow.entities or {}) do if e == npc then table.remove(ow.entities, j) break end end + ow.cast = nil end -- spawn cell: directly behind the player's facing when that cell is @@ -189,11 +191,16 @@ function PikachuFollower.current(ow) end function PikachuFollower.onMapEntered(game, ow, opts) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").onMapEntered(game, ow, opts, true) + end + if generation ~= 1 then return end -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil remove(ow) - if not shouldSpawn(game, ow) then return end + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: -- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4 -- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText @@ -205,6 +212,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) if keep then table.insert(ow.npcs, keep) table.insert(ow.entities, keep) + ow.cast = nil return end local x, y = spawnCell(ow) @@ -212,6 +220,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) table.insert(ow.npcs, npc) -- entities is the draw list; passable keeps it out of collision table.insert(ow.entities, npc) + ow.cast = nil ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY } end @@ -395,14 +404,19 @@ end -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").update(game, ow) + end + if generation ~= 1 then return end if ow.pikaHop then return end -- the counter hop owns the follower (#417) if ow.pikachuBillsScene then return end local npc = findFollower(ow) if not npc then - if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end + if ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then PikachuFollower.onMapEntered(game, ow) end return end - if not shouldSpawn(game, ow) then + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then remove(ow) return end @@ -681,6 +695,7 @@ local function bubbleIndex(game, name) end function PikachuFollower.talk(game, ow, npc, done) + if GameVersion.generation() ~= 1 then return false end -- pikachu_follow.asm steps the follower on the player's own walk clock, -- so it is never mid-tile while the player stands and can always be -- addressed; this port's follow is a frame late, so land the step here diff --git a/tests/engine/follower_spawn.lua b/tests/engine/follower_spawn.lua new file mode 100644 index 000000000..23bc6a17d --- /dev/null +++ b/tests/engine/follower_spawn.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/follower/fixture.lua"), dofile("tests/follower/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local gen, e = f.gen, f.e + T.eq(e.api:supports("hooks", "world.follower.spawn"), gen <= 3, "honest native generation capability") + if gen <= 3 then + Cases.run(f) + -- Preserve unrelated actors, native map seam identity and in-flight + -- interpolation, including the trail's rebase alias. + local off = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + local bystander = { id = "native-object" } + f.world.npcs[#f.world.npcs + 1] = bystander + f.world.entities[#f.world.entities + 1] = bystander + f.tick() + local npc = f.current() + local trail = f.world.pikachuTrail + local tx, ty = trail.x, trail.y + f.world.cast = { "stale" } + f.enter({ keepPikachu = npc }) + T.eq(f.current(), npc, "connection retains the actual companion") + T.eq(f.world.pikachuTrail, trail, "connection retains buffered trail") + T.eq(f.world.cast, nil, "map entry invalidates entity cast") + f.controller.rebase(f.world, -3, 2) + T.eq(f.world.pikachuTrail.x, tx - 3, "rebase mutates trail x in place") + T.eq(f.world.pikachuTrail.y, ty + 2, "rebase mutates trail y in place") + T.eq(npc.px, npc.cellX * 16, "rebased native pixel x agrees with cell") + if gen >= 2 then + T.eq(f.world.followerTrail, trail, "optional companion shares rebase alias") + T.eq( + f.controller.talk(f.game, f.world, npc), + false, + "optional companion cannot enter Yellow dialogue" + ) + end + off() + local deny = e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.world.npcs[1], bystander, "denial preserves unrelated native NPC") + T.eq(f.world.entities[2], bystander, "denial preserves unrelated draw entry") + deny() + if gen >= 2 then + -- Fallback artwork must not add a content alias or share the player's + -- mutable renderer. This is an asset seam, not an alternative mod API. + f.game.data.sprites.SPRITE_PIKACHU = nil + local def = f.player.spriteDef + f.player.spriteDef = nil + f.player.sprite = require("src.render.SpriteRenderer").new(def, "fixture-player") + local allow = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + f.enter() + T.eq(f.current().sprite.def, def, "missing optional sheet uses player definition") + T.neq(f.current().sprite, f.player.sprite, "follower owns a separate renderer") + T.eq(f.game.data.sprites.SPRITE_PIKACHU, nil, "fallback never mutates content registry") + if gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "same-map GBA teleport resets companion x") + T.eq(f.current().cellY, 20, "same-map GBA teleport resets companion y") + end + allow() + end + else + local escort = { id = "scripted-escort", gen4Follower = true } + f.world.npcs, f.world.entities = { escort }, { f.player, escort } + local calls = 0 + local off = e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end) + f.enter() + f.tick() + T.eq(calls, 0, "DS has no promised follower producer") + T.eq(f.world.npcs[1], escort, "DS native escort is untouched") + off() + end + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry rollback is reported") + failed.tick() + T.eq(failed.current() ~= nil, version == "yellow", "failed mod leaves native follower default") + for _, entry in ipairs(Runtime.hooks.chains["world.follower.spawn"] or {}) do + T.check(entry.owner ~= "portable_follower", "entry rollback removes owned hook") + end + failed.close() + end +end +Version.set(old) +T.finish("portable native follower predicate") diff --git a/tests/follower/cases.lua b/tests/follower/cases.lua new file mode 100644 index 000000000..b5bbd9310 --- /dev/null +++ b/tests/follower/cases.lua @@ -0,0 +1,133 @@ +local M = {} +function M.run(f) + local T, state, rows = f.T, f.e.state, {} + local function observe(label) + local n = f.current() + rows[#rows + 1] = { label = label, present = n ~= nil } + end + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "native default remains Yellow-only") + observe("native") + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "public predicate removes companion") + state.mode = "allow" + f.tick() + local npc = f.current() + T.check(npc ~= nil, "public predicate spawns actual native companion") + T.eq(npc.passable, true, "native follower never blocks player") + T.eq(state.game, f.game, "hook receives original opaque Game") + T.eq(state.world.player, f.player, "hook receives native player") + T.eq(state.world.map.id, f.world.map.id, "hook receives active map") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(state.world, f.world, "opaque world identity is preserved") + T.eq(f.world.npcs[#f.world.npcs], npc, "native update list owns follower") + T.eq(f.world.entities[#f.world.entities], npc, "native draw list owns follower") + end + observe("allow") + -- A moving player commits before the NPC controller. Check each native + -- interpolated frame, not a hand-called Runtime hook or a fake follower. + for _, dir in ipairs({ { "right", 1, 0 }, { "down", 0, 1 }, { "left", -1, 0 }, { "up", 0, -1 } }) do + local p = f.player + p.facing, p.targetX, p.targetY, p.moving = dir[1], p.cellX + dir[2], p.cellY + dir[3], true + for frame = 1, 4 do + f.tick() + npc = f.current() + T.check(npc ~= nil, "native companion survives movement") + rows[#rows + 1] = { dir = dir[1], frame = frame, x = npc.px, y = npc.py, moving = npc.moving == true } + end + p.cellX, p.cellY, p.px, p.py = p.targetX, p.targetY, p.targetX * 16, p.targetY * 16 + p.targetX, p.targetY, p.moving = nil, nil, false + end + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "denial removes follower during native update") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(#f.world.npcs, 0, "removal clears update list") + T.eq(#f.world.entities, 1, "removal retains player draw entry") + end + observe("deny") + state.mode = "allow" + f.enter() + T.check(f.current() ~= nil, "native map entry honors predicate") + observe("map entry") + if f.gen <= 2 then + local keep = f.current() + local x, y = keep.px, keep.py + f.enter({ keepPikachu = keep }) + T.eq(f.current(), keep, "native connection keeps companion identity") + f.controller.rebase(f.world, -2, 1) + T.eq(keep.px, x - 32, "native connection rebases companion x") + T.eq(keep.py, y + 16, "native connection rebases companion y") + rows[#rows + 1] = { label = "connection", dx = keep.px - x, dy = keep.py - y } + elseif f.gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "GBA same-map teleport resets companion x") + T.eq(f.current().cellY, 20, "GBA same-map teleport resets companion y") + rows[#rows + 1] = { label = "teleport", x = f.current().px, y = f.current().py } + end + local off = f.e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.current(), nil, "higher-priority wrapper controls native lifecycle") + off() + f.tick() + T.check(f.current() ~= nil, "unsubscribe restores consumer") + f.e.stop() + f.e.stop() + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "unsubscribe restores native predicate") + observe("unsubscribed") + for _, value in ipairs({ false, true, 0, "", {} }) do + local restore = f.e.api.hooks:wrap("world.follower.spawn", function() + return value + end, 100) + f.tick() + T.eq(f.current() ~= nil, not not value, "native predicate uses Lua truthiness") + observe("truthiness:" .. type(value) .. ":" .. tostring(not not value)) + restore() + end + local nilResult = f.e.api.hooks:wrap("world.follower.spawn", function() + return nil + end, 100) + f.tick() + T.eq(f.current(), nil, "nil predicate result removes follower") + observe("nil") + nilResult() + for _, after in ipairs({ false, true }) do + local failing = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + if after then + next(game, world) + end + error("intentional wrapper failure") + end, 100) + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "wrapper failure preserves downstream native default") + observe("failure:" .. tostring(after)) + failing() + end + if f.api >= 3 then + local calls = 0 + local count = f.e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end, 50) + local replay = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + local before = calls + local a, b = next(game, world), next(game, world) + T.eq(a, true, "first next returns downstream predicate") + T.eq(b, a, "API-3 repeated next preserves result") + T.eq(calls, before + 1, "API-3 repeated next calls downstream once") + return a + end, 100) + f.tick() + T.check(f.current() ~= nil, "replayed next still drives native spawning") + replay() + count() + observe("replay") + end + return rows +end +return M diff --git a/tests/follower/fixture.lua b/tests/follower/fixture.lua new file mode 100644 index 000000000..3e9587938 --- /dev/null +++ b/tests/follower/fixture.lua @@ -0,0 +1,153 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = data.tilesets.FIX_OUT or T.fixtures.fresh().tilesets.FIX_OUT + data.sprites = data.sprites or {} + local sheet = T.fixtures.fresh().sprites.SPRITE_FIX_NPC + data.sprites.SPRITE_PIKACHU = sheet + data.gen2Sprites = data.gen2Sprites or {} + data.gen2Sprites.SPRITE_PIKACHU = sheet + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local file = assert(io.open(root .. "/examples/portable_follower/" .. name, "rb")) + files["mods/portable_follower/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_follower/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_follower/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_follower/main.lua"] = files["mods/portable_follower/main.lua"] + .. '\nerror("entry rollback probe")' + end + local result = T.sdk.loadMods( + { "mods/portable_follower" }, + { fs = T.sdk.memfs(files), data = data, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + version = version, + options = {}, + flags = { EVENT_GOT_STARTER = true, EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true }, + player = { id = 7, name = "PROBE" }, + party = { { species = "PIKACHU", hp = 10, otId = 7, ot = "PROBE" } }, + }, + } + local def = { + id = "FIX_TOWN", + width = 12, + height = 12, + tileset = "FIX_OUT", + blocks = {}, + objects = {}, + connections = {}, + } + for i = 1, 144 do + def.blocks[i] = 1 + end + data.tilesets.FIX_OUT.walkable = { 0, 1, 2 } + data.tilesets.FIX_OUT._walkableSet = nil + if backend == "gen1" and gen == 2 then + data.tilesets.FIX_OUT.collision = { {}, { 0, 0, 0, 0 } } + end + -- Gen1 GBA's real follower reads the Field session map ID, not a GB Map. + local map = backend == "gen1" and gen == 3 and { id = def.id } + or require("src.world.Map").new(def, data.tilesets.FIX_OUT) + local player = { + cellX = 4, + cellY = 4, + px = 64, + py = 64, + facing = "down", + stepFrames = 4, + stepFramesCur = 4, + moving = false, + spriteDef = sheet, + elevation = 3, + } + local world = { map = map, player = player, npcs = {}, entities = { player }, sprites = data.gen2Sprites } + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local controller + if backend == "gen1" and gen == 3 then + controller = require("src.world.game3.Follower") + local Field = require("src.core.game3.field") + player = require("src.core.game3.player") + for name, value in pairs(world.player) do + patch(player, name, value) + end + for _, name in ipairs({ "targetX", "targetY", "surfing" }) do + patch(player, name, nil) + end + world.player = player + patch(Field, "running", true) + patch(Field, "getSession", function() + return { map = world.map.id } + end) + controller.reset() + elseif backend == "gen1" and gen == 2 then + controller = require("src.world.gen2.Follower") + else + controller = require("src.world.PikachuFollower") + end + game.world, game.overworld, result.loader.game = world, world, game + local function current() + return controller.current(world) + end + local function tick() + local npc = current() + if npc and npc.moving and not (backend == "gen1" and gen == 3) then + npc:update(world.map, world.entities) + end + controller.update(game, world) + end + return { + T = T, + e = result.loader.exports.portable_follower, + result = result, + game = game, + world = world, + player = player, + controller = controller, + current = current, + tick = tick, + gen = gen, + version = version, + backend = backend, + api = api, + patch = patch, + enter = function(opts) + if backend == "gen1" and gen == 3 then + controller.onMapEntered() + tick() + else + controller.onMapEntered(game, world, opts, gen == 2) + end + end, + close = function() + if backend == "gen1" and gen == 3 then + controller.reset() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/follower/worker.lua b/tests/follower/worker.lua new file mode 100644 index 000000000..f1710d4cc --- /dev/null +++ b/tests/follower/worker.lua @@ -0,0 +1,14 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/follower/cases.lua").run(f) +f.close() +local failed = + dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format, true) +failed.T.check(#failed.result.errors > 0, "entry failure is recorded") +failed.tick() +failed.T.eq(failed.current() ~= nil, version == "yellow", "entry rollback restores native predicate") +rows[#rows + 1] = { label = "rollback", present = failed.current() ~= nil } +failed.close() +print("FOLLOWER_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native follower") diff --git a/tools/follower-contract-check/check.py b/tools/follower-contract-check/check.py new file mode 100644 index 000000000..a7b181f72 --- /dev/null +++ b/tools/follower-contract-check/check.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Compare the same public follower consumer through both native controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/follower/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("FOLLOWER_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("FOLLOWER_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native predicate/lifecycle/movement", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"follower mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native follower observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_follower/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 0520e65b0a53e12c078891679b69721be63f73b6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:49:36 +0200 Subject: [PATCH 31/86] Add native GB item-use dispatch and shared SDK contracts --- .github/workflows/item-use-contract.yml | 44 +++++++ docs/api3-portability.md | 3 + docs/modding.md | 3 + docs/portable-item-use.md | 89 +++++++++++++ examples/portable_item_use/main.lua | 23 ++++ examples/portable_item_use/manifest.json | 1 + src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 11 +- src/ui/BagMenu.lua | 13 +- tests/engine/item_use_dispatch.lua | 50 +++++++ tests/engine/mod_supports.lua | 7 +- tests/item_use/cases.lua | 133 +++++++++++++++++++ tests/item_use/fixture.lua | 160 +++++++++++++++++++++++ tests/item_use/worker.lua | 26 ++++ tools/item-use-contract-check/check.py | 58 ++++++++ 15 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/item-use-contract.yml create mode 100644 docs/portable-item-use.md create mode 100644 examples/portable_item_use/main.lua create mode 100644 examples/portable_item_use/manifest.json create mode 100644 tests/engine/item_use_dispatch.lua create mode 100644 tests/item_use/cases.lua create mode 100644 tests/item_use/fixture.lua create mode 100644 tests/item_use/worker.lua create mode 100644 tools/item-use-contract-check/check.py diff --git a/.github/workflows/item-use-contract.yml b/.github/workflows/item-use-contract.yml new file mode 100644 index 000000000..ce495f510 --- /dev/null +++ b/.github/workflows/item-use-contract.yml @@ -0,0 +1,44 @@ +name: portable GB item dispatch contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: item-use-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native GB item flow and compatibility + run: | + luajit tests/engine/item_use_dispatch.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/item-use-contract + python3 tools/item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/item-use-contract/api1-native.json + python3 tools/item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/item-use-contract/api2-native.json + python3 tools/item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/item-use-contract/api2-unified.json + python3 tools/item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/item-use-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: item-use-contract + path: /tmp/item-use-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 7e56d41ba..29b5106c7 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -147,3 +147,6 @@ comparison pass by changing only one engine's mod fixture. The [portable follower predicate](portable-follower.md) controls native companion existence on Gen1–3 without replacing a private controller. Artwork remains a separate concern; check `mod:supports("hooks", "world.follower.spawn")`. + +[Portable GB item dispatch](portable-item-use.md) documents the `item.use` +producer and the separate Gold/GBA/DS boundaries. diff --git a/docs/modding.md b/docs/modding.md index f9d14d0d9..647420d27 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -432,3 +432,6 @@ for the manifest guard, generation coverage, symbolic IDs and failure behavior. See [portable follower predicate](portable-follower.md) for the native `world.follower.spawn` lifecycle hook and its generation/artwork boundaries. + +[Portable GB item dispatch](portable-item-use.md) documents the `item.use` +producer and the separate Gold/GBA/DS boundaries. diff --git a/docs/portable-item-use.md b/docs/portable-item-use.md new file mode 100644 index 000000000..6267c7562 --- /dev/null +++ b/docs/portable-item-use.md @@ -0,0 +1,89 @@ +# Portable GB item dispatch + +`item.use(next, game, battle, itemId, target, list, moveIndex, picker)` wraps +the native GB effect **and** UI dispatch after target/move selection. It is +available for Red, Blue and Yellow. The same mod entry can block or replace +an item action without overwriting BagMenu or ItemEffects. + +```lua +local mod = ... +if mod:supports("hooks", "item.use") then + mod.hooks:wrap("item.use", function(next, game, battle, item, target, list, move, picker) + if myOptions.blockItems then return end + return next(game, battle, item, target, list, move, picker) + end) +end +``` + +Returning without `next` skips native effects, consumption, result text and +follow-up UI from this dispatcher. Earlier target-selection and machine +announcement UI already ran. Native target cancellation, toss/give/sell and +trainer-AI item effects are outside this seam. Ordinary failure/no-effect, +balls, stones, machines, medicine and key-item branches share the dispatch. +Native GB use has no result tuple; this is not an item-effect result filter. + +Game and the selected target are passed through unchanged. Battle and menu +arguments are engine-specific opaque values: forward them to `next` rather +than inspecting/replacing their internals. Unified/API-3 callback adapters +may expose bound views for UI arguments, so native methods/identities on +those values are not a shared contract. A missing battle, target or move +retains its positional nil; the trailing picker is preserved too. + +API 1/2 native retain native item IDs. API 2 unified and API 3 use canonical +item IDs in argument 3; `next` resolves those through the current loader's +content before native dispatch. Changing that ID selects the replacement's +native effect and inventory key. Each other argument retains its role; an +item ID is not a Pokemon record. The opaque battle handle in Gen2Recomped is +not copied into a Pokemon snapshot. The pinned Gen1 API-3 adapter historically +views this slot differently; portable consumers must treat it as opaque. + +Wrapper errors retain hook-bus recovery, including keeping downstream effects +when a wrapper throws after `next`. API 3 repeated `next` calls return the +cached downstream result without repeating consumption or UI. Unsubscribe +and failed-entry rollback restore normal native behavior. + +## Generation boundaries and next step + +The pinned Gen1 source has three different situations: + +- GB wraps the full BagMenu use-on-target/UI dispatcher, as above. +- Gold has no native `item.use` producer. +- GBA wraps a field-use function with `(game, nil, itemId, partySlot, bag)` and + an `(ok, kind, text)` result tuple; its battle-item path is separate. + +Gen2Recomped therefore advertises this first producer for **GB only**. GBA's +field tuple needs its own native adapter and shared tests in the next PR. +Recognizing/accepting the API-3 name does not make that producer available. +Gold, GBA and DS keep their existing item flows and report unavailable here. +This boundary avoids routing a GB UI signature into a mod expecting GBA field +results. It is an intermediate step toward shared item APIs, not complete +cross-generation item-hook coverage. + +## Validation + +`examples/portable_item_use` has no private engine imports. The checker loads +that unchanged entry through both real SDKs and drives real bag, party and +move menus using native input. ItemEffects is not replaced with a stub and +the test does not manually call Runtime's hook dispatcher. The ROM-free +fixture supplies data, Game, input and native StateStack instances. + +Checks cover field/battle HP, status and move medicine, no-effect, selected +move forwarding, blocking untargeted branches and machine/stone dispatch, +canonical replacement IDs, target cancellation, wrapper failures, unsubscribe, +no-mod behavior, entry rollback and API-3 repeat/hole/return-arity rules. Gen2 +also verifies native-ID aliases and non-GB exclusions. This proves the tested +SDK/dispatch boundaries, not full item mechanic or UI pixel parity between +the engines. + +Use a clean Gen1 checkout pinned at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`: + +```sh +luajit tests/engine/item_use_dispatch.lua +python3 tools/item-use-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/item-use-contract.json +``` + +API 1 native, API 2 native, API 2 unified and API 3 are compared for Red, Blue +and Yellow. Gen1 API 2 has no optional unified mode; the corresponding run +compares Gen2's opt-in adapter with Gen1's native callback contract. diff --git a/examples/portable_item_use/main.lua b/examples/portable_item_use/main.lua new file mode 100644 index 000000000..4a1a0dcbb --- /dev/null +++ b/examples/portable_item_use/main.lua @@ -0,0 +1,23 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "item.use") then + stop = mod.hooks:wrap("item.use", function(next, game, battle, item, target, list, moveIndex, picker) + state.calls = state.calls + 1 + state.game, state.battle, state.item, state.target = game, battle, item, target + state.list, state.moveIndex, state.picker = list, moveIndex, picker + if state.mode == "block" then + return + end + if state.mode == "redirect" then + item = state.redirect + end + return next(game, battle, item, target, list, moveIndex, picker) + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_item_use/manifest.json b/examples/portable_item_use/manifest.json new file mode 100644 index 000000000..e17d8427d --- /dev/null +++ b/examples/portable_item_use/manifest.json @@ -0,0 +1 @@ +{"id":"portable_item_use","name":"Portable GB item dispatch","version":"1.0.0","api":3,"games":["gen1"],"entry":"main.lua","affects_link":false} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 946d8f973..6f825475e 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -34,6 +34,7 @@ add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") +add("hooks", "1", "item.use") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 4414a5f31..b36712e45 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -20,7 +20,7 @@ C.hooks = names([[ battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility - input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + input.key input.gamepad input.wheel input.step intro.oak_speech.build item.use link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["item.use"] = { false, false, "itemId", false, "context", false, "context" }, ["world.follower.spawn"] = { false, false }, ["ui.party.grid_navigation"] = { false }, ["battle.move_grid_navigation"] = { false }, @@ -153,8 +154,8 @@ function C.install(loader, api, owner, generation) 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) + if role == "items" or role == "moves" or role == "itemId" then + v = boundary.read(role == "itemId" and "items" or role, v) elseif role then v = boundary.view(v, role) end @@ -167,8 +168,8 @@ function C.install(loader, api, owner, generation) 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]) + if role == "items" or role == "moves" or role == "itemId" then + v = boundary.native(role == "itemId" and "items" or role, v, original[i]) elseif role then v = boundary.restore(v, role) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4020375b6..0c89fbd19 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -11,6 +11,7 @@ local BagMenu = {} local Bag = require("src.inventory.Bag") local Strings = require("src.core.Strings") +local Runtime = require("src.mods.Runtime") -- The money line under the bag, with the cartridge's own sign -- Kanto's -- U+00A5 is the JAPANESE YEN KANJI in Platinum's font. See @@ -172,7 +173,7 @@ BagMenu.refreshCount = refreshCount -- the stack, so every exit that prints has to close it afterwards. For -- every other item the picker popped itself first and closePicker's identity -- check makes it a no-op (#252). -local function useOn(game, battle, id, target, list, moveIndex, picker) +local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- HGSS: the party screen's own msg_0300 line for the item (src/ui/Gen4PartyText.lua) local PartyText = require("src.ui.Gen4PartyText") local g4Before = (not battle and target) and PartyText.snapshot(game, target) or nil @@ -745,6 +746,16 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) showMessages(game, payload, closePicker) -- failed end +local function useOn(game, battle, id, target, list, moveIndex, picker) + -- Gen1's GB seam owns the entire effect/UI dispatch after target selection. + -- Its GBA field producer has a different tuple contract; do not advertise + -- this UI-shaped call as a producer for that generation. + if require("src.core.GameVersion").generation() == 1 then + return Runtime.call("item.use", vanillaUseOn, game, battle, id, target, list, moveIndex, picker) + end + return vanillaUseOn(game, battle, id, target, list, moveIndex, picker) +end + local function pickTargetAndUse(game, battle, id, list) -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon diff --git a/tests/engine/item_use_dispatch.lua b/tests/engine/item_use_dispatch.lua new file mode 100644 index 000000000..cafa68711 --- /dev/null +++ b/tests/engine/item_use_dispatch.lua @@ -0,0 +1,50 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/item_use/fixture.lua"), dofile("tests/item_use/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local gen, api = Version.generation(), f.e.api + T.eq(api:supports("hooks", "item.use"), gen == 1, "GB producer availability is honest") + if gen == 1 then + Cases.run(f) + else + -- Registration is legal in API 3, but the GB-shaped producer must + -- never run on GBA's different field tuple contract or DS screens. + local calls = 0 + local off = api.hooks:wrap("item.use", function() + calls = calls + 1 + end) + f.begin("REPEL", false) + T.eq(calls, 0, "unavailable host never dispatches GB item signature") + T.eq(f.count("REPEL"), 2, "unsupported hosts retain native item flow") + off() + end + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], false, true) + T.check(#failed.result.errors > 0, "failed entry is recorded") + for _, entry in ipairs(Runtime.hooks.chains["item.use"] or {}) do + T.check(entry.owner ~= "portable_item_use", "rollback removes owned item wrapper") + end + failed.close() + end +end +for _, mode in ipairs({ { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", "red", mode[1], mode[2], false, false, true) + f.e.state.mode = "block" + f.begin("ITEM_777", false) + f.target() + T.eq(f.e.state.item, "POTION", "native alias is canonical at the shared item-ID slot") + T.eq(f.count("ITEM_777"), 3, "canonical block leaves native inventory unchanged") + f.e.state.mode = "native" + f.begin("ITEM_777", false) + f.target() + T.eq(f.count("ITEM_777"), 2, "next restores native alias before item use") + T.eq(f.game.save.inventory.POTION, nil, "next never invents a canonical inventory key") + f.close() +end +Version.set(old) +T.finish("portable native GB item dispatch") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 3fb17e290..0ac6dcbf7 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -77,8 +77,11 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': additive native flight interface is reported when installed') T.check(yes(api, 'hooks', 'core.logic_speed'), version .. ': native speed hook is advertised') - T.check(no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), - version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(GameVersion.generation(version) == 1 and yes(api, 'hooks', 'item.use') + or GameVersion.generation(version) ~= 1 and no(api, 'hooks', 'item.use'), + version .. ': item-use producer is advertised only for its native GB contract') + T.check(no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 events 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 diff --git a/tests/item_use/cases.lua b/tests/item_use/cases.lua new file mode 100644 index 000000000..513b7d379 --- /dev/null +++ b/tests/item_use/cases.lua @@ -0,0 +1,133 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local mon = f.game.save.party[1] + local function observe(label, id) + rows[#rows + 1] = + { label = label, count = f.count(id), hp = mon.hp, status = mon.status or false, item = e.state.item } + end + for _, inBattle in ipairs({ false, true }) do + for _, id in ipairs({ "POTION", "ANTIDOTE", "ETHER" }) do + e.state.mode = "block" + local beforePicker = e.state.calls + f.begin(id, inBattle) + T.eq(e.state.calls, beforePicker, "target selection itself has no effect") + local hp, count, status = mon.hp, f.count(id), mon.status + local calls = e.state.calls + f.target() + if id == "ETHER" then + f.move() + end + T.eq(e.state.calls, calls + 1, "selected native item dispatch reaches public hook once") + T.eq(mon.hp, hp, "blocking prevents HP effect") + T.eq(mon.status, status, "blocking prevents status effect") + T.eq(f.count(id), count, "blocking preserves inventory") + T.eq(e.state.game, f.game, "original opaque Game is preserved") + T.eq(e.state.target, mon, "native target identity is preserved") + T.eq(e.state.item, id, "public canonical item ID") + T.eq(e.state.battle ~= nil, inBattle, "battle presence is preserved") + if id == "ETHER" then + T.eq(e.state.moveIndex, 1, "chosen move index is forwarded") + end + observe("blocked:" .. tostring(inBattle), id) + e.state.mode = "native" + f.begin(id, inBattle) + f.target() + if id == "ETHER" then + f.move() + end + T.eq(f.count(id), 2, "delegating consumes native item once") + if id == "POTION" then + T.eq(mon.hp, hp + 20, "native medicine heals") + end + if id == "ANTIDOTE" then + T.eq(mon.status, nil, "native status medicine cures") + end + if id == "ETHER" then + T.eq(mon.moves[1].pp, 10, "native move medicine restores chosen move") + end + observe("delegated:" .. tostring(inBattle), id) + end + end + for _, id in ipairs({ "REPEL", "INVALID", "BICYCLE", "OLD_ROD", "POKE_BALL", "POKE_DOLL", "X_ATTACK" }) do + e.state.mode = "block" + local before = e.state.calls + f.begin(id, id == "POKE_BALL" or id == "POKE_DOLL" or id == "X_ATTACK") + T.eq(e.state.calls, before + 1, "whole-dispatch hook includes untargeted branch " .. id) + T.eq(f.count(id), 3, "whole-dispatch refusal prevents native consume " .. id) + T.eq(f.game.save.onBike, nil, "blocked bicycle cannot mount") + observe("blocked:branch", id) + end + for _, id in ipairs({ "TM_01", "MOON_STONE" }) do + e.state.mode = "block" + f.begin(id, false) + local before = e.state.calls + local species, moves = mon.species, #mon.moves + f.target() + T.eq(e.state.calls, before + 1, "machine/stone reaches native whole-dispatch hook") + T.eq(f.count(id), 3, "blocked machine/stone is not consumed") + T.eq(mon.species, species, "blocked stone cannot evolve target") + T.eq(#mon.moves, moves, "blocked machine cannot teach a move") + observe("blocked:targeted branch", id) + end + f.begin("POTION", false) + local beforeCancel = e.state.calls + f.press("b") + T.eq(e.state.calls, beforeCancel, "cancelled target selection never dispatches item use") + T.eq(f.count("POTION"), 3, "cancelled target selection preserves inventory") + -- A native no-effect decision remains inside the wrapper's reach. + e.state.mode = "native" + f.begin("POTION", false) + mon.hp = mon.stats.hp + f.target() + T.eq(f.count("POTION"), 3, "native no-effect medicine is not consumed") + observe("no effect", "POTION") + e.state.mode, e.state.redirect = "redirect", "SUPER_POTION" + f.begin("POTION", false) + require("src.inventory.Bag").add(f.game.save, "SUPER_POTION", 3, f.game.data) + f.target() + T.eq(mon.hp, mon.stats.hp, "replacement ID reaches native effect dispatcher") + T.eq(f.count("SUPER_POTION"), 2, "replacement consumes the selected replacement item") + T.eq(f.count("POTION"), 3, "replacement retains original inventory") + observe("redirect", "SUPER_POTION") + e.state.mode = "native" + for _, after in ipairs({ false, true }) do + local stop = e.api.hooks:wrap("item.use", function(next, ...) + if after then + next(...) + end + error("intentional item wrapper failure") + end, 100) + f.begin("POTION", false) + local before = e.state.calls + f.target() + T.eq(e.state.calls, before + 1, "wrapper failure calls downstream once") + T.eq(f.count("POTION"), 2, "wrapper failure cannot double-consume") + observe("failure:" .. tostring(after), "POTION") + stop() + end + if f.api >= 3 then + local stop = e.api.hooks:wrap("item.use", function(next, ...) + local before = e.state.calls + T.eq(select("#", ...), 7, "native dispatch retains holes and trailing picker") + T.eq(select("#", next(...)), 0, "GB UI dispatch has no result tuple") + T.eq(select("#", next(...)), 0, "repeated next keeps empty return arity") + T.eq(e.state.calls, before + 1, "API-3 repeated next evaluates downstream once") + end, 100) + f.begin("POTION", false) + f.target() + T.eq(f.count("POTION"), 2, "API-3 repeated next consumes once") + observe("replay", "POTION") + stop() + end + e.stop() + e.stop() + local before = e.state.calls + f.begin("POTION", false) + f.target() + T.eq(e.state.calls, before, "unsubscribe removes native item wrapper") + T.eq(f.count("POTION"), 2, "unsubscribe restores native item use") + observe("unsubscribed", "POTION") + return rows +end +return M diff --git a/tests/item_use/fixture.lua b/tests/item_use/fixture.lua new file mode 100644 index 000000000..efbb42017 --- /dev/null +++ b/tests/item_use/fixture.lua @@ -0,0 +1,160 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing, nativeAlias) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local data = T.fixtures.fresh() + local defs = { + POTION = { name = "POTION" }, + SUPER_POTION = { name = "SUPER POTION" }, + ANTIDOTE = { name = "ANTIDOTE" }, + ETHER = { name = "ETHER" }, + REPEL = { name = "REPEL" }, + POKE_BALL = { name = "POKe BALL", ball = "POKE_BALL" }, + POKE_DOLL = { name = "POKe DOLL" }, + X_ATTACK = { name = "X ATTACK" }, + OLD_ROD = { name = "OLD ROD", keyItem = true }, + BICYCLE = { name = "BICYCLE", keyItem = true }, + MOON_STONE = { name = "MOON STONE" }, + TM_01 = { name = "TM01", machine = { kind = "TM", move = "FIX_CUT", number = 1 } }, + INVALID = { name = "INVALID" }, + } + local index = 50 + for id, def in pairs(defs) do + def.id, def.index, def.price, def.tossable = id, index, 100, true + index = index + 1 + data.items[id] = def + end + if nativeAlias then + data.items.ITEM_777 = data.items.POTION + data.items.ITEM_777.id, data.items.ITEM_777.key = "ITEM_777", "POTION" + data.items.POTION = nil + end + data.text = data.text or {} + data.text._GotOnBicycleText1, data.text._GotOnBicycleText2 = "ON", "BIKE" + data.text._GotOffBicycleText1, data.text._GotOffBicycleText2 = "OFF", "BIKE" + data.field.bikeRiding = { maps = { "FIX_TOWN" }, tilesets = { "FIX_OUT" } } + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local file = assert(io.open(root .. "/examples/portable_item_use/" .. name, "rb")) + files["mods/portable_item_use/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_item_use/manifest.json"]) + manifest.api, manifest.callback_format = api, format + manifest.games = { "all" } + files["mods/portable_item_use/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_item_use/main.lua"] = files["mods/portable_item_use/main.lua"] + .. '\nerror("intentional item entry failure")' + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/portable_item_use" }, + { data = data, fs = T.sdk.memfs(files), dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { data = data, save = require("src.core.SaveData").newGame(), sessionStartedAt = 1 } + game.save.version, game.save.player.name = version, "PROBE" + game.input = setmetatable({}, { __index = require("src.core.Input") }) + game.input:init() + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 12) } + game.save.party[1].nickname = "PROBE" + require("src.render.Font").load(data) + result.loader.game = game + local world = { isOverworld = true, map = { id = "FIX_TOWN", def = { tileset = "FIX_OUT" } }, player = {} } + game.world, game.overworld = world, world + local f = { + T = T, + game = game, + result = result, + e = result.loader.exports.portable_item_use, + api = api, + backend = backend, + version = version, + world = world, + } + function f.press(key) + game.input.pressed = { [key] = true } + local top = assert(game.stack:top(), "no native menu") + top:update(1 / 60) + game.input.pressed = {} + end + function f.begin(id, inBattle) + game.save.inventory, game.save.bagOrder = {}, {} + require("src.inventory.Bag").add(game.save, id, 3, data) + local mon = game.save.party[1] + mon.hp, mon.status = mon.stats.hp - 25, nil + if id == "ANTIDOTE" then + mon.status = "PSN" + end + if id == "ETHER" then + mon.moves[1].pp = 0 + end + local battle + if inBattle then + battle = require("src.battle.BattleState").newWild(game, "FIXMON_C", 10) + battle:syncSides() + battle.phase, battle.frame, battle.queue = "menu", 0, {} + end + f.battle = battle + game.stack.states = { battle or world } + local list = require("src.ui.BagMenu").new(game, { battle = battle }) + game.stack:push(list) + f.list = list + for i, row in ipairs(list.items) do + if row.value == id then + list.index = i + break + end + end + f.press("a") + local option = game.stack:top() + if + option ~= list + and option.items + and option.items[1] + and type(option.items[1].onSelect) == "function" + then + f.press("a") + end + return list + end + function f.target() + -- Machines print/ask before opening the party picker. Advance those + -- native screens with real input rather than invoking their callbacks. + for _ = 1, 1000 do + if type(game.stack:top().onSwitch) == "function" then + break + end + f.press("a") + end + local menu = assert(game.stack:top(), "missing party picker") + assert(type(menu.onSwitch) == "function", "native party picker expected") + f.picker = menu + f.press("a") + end + function f.move() + local TextBox = require("src.render.TextBox") + for _ = 1, 500 do + if getmetatable(game.stack:top()) ~= TextBox then + break + end + f.press("a") + end + local menu = assert(game.stack:top()) + assert(type(menu.onChoose) == "function", "native move picker expected") + f.press("a") + end + function f.count(id) + return game.save.inventory[id] or 0 + end + function f.close() + result.release() + end + return f +end +return M diff --git a/tests/item_use/worker.lua b/tests/item_use/worker.lua new file mode 100644 index 000000000..dfca9e2de --- /dev/null +++ b/tests/item_use/worker.lua @@ -0,0 +1,26 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/item_use/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/item_use/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local native = dofile(root .. "/tests/item_use/fixture.lua").new( + root, + backend, + version, + tonumber(api), + format, + not failing, + failing + ) + if failing then + native.T.check(#native.result.errors > 0, "entry rollback is recorded") + end + native.begin("POTION", false) + native.target() + native.T.eq(native.count("POTION"), 2, "no mod/rollback retain actual native item effect") + rows[#rows + 1] = { label = failing and "rollback" or "no mod", count = native.count("POTION") } + native.close() +end +print("ITEM_USE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable GB item dispatch") diff --git a/tools/item-use-contract-check/check.py b/tools/item-use-contract-check/check.py new file mode 100644 index 000000000..d92071253 --- /dev/null +++ b/tools/item-use-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Compare the unchanged GB item-use consumer through both native bag flows.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/item_use/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("ITEM_USE_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("ITEM_USE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native GB UI/effect dispatch", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"item dispatch mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native GB item observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_item_use/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 4257caf6f245a1d67b466e5708ed80389e28ca3b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:39:17 +0200 Subject: [PATCH 32/86] Add native GBA field item-use results and shared contracts --- .github/workflows/gba-item-use-contract.yml | 45 +++ docs/api3-portability.md | 3 +- docs/modding.md | 6 +- docs/portable-gba-item-use.md | 82 +++++ docs/portable-item-use.md | 12 +- examples/portable_gba_item_use/main.lua | 35 ++ examples/portable_gba_item_use/manifest.json | 1 + src/inventory/Gen3FieldUse.lua | 320 +++++++++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/Loader.lua | 2 + src/ui/BagMenu.lua | 36 +- src/world/Encounter.lua | 11 +- src/world/OverworldController.lua | 1 + tests/engine/gba_field_item_use.lua | 118 +++++++ tests/engine/item_use_dispatch.lua | 15 +- tests/engine/mod_supports.lua | 6 +- tests/gba_item_use/cases.lua | 179 ++++++++++ tests/gba_item_use/fixture.lua | 351 +++++++++++++++++++ tests/gba_item_use/worker.lua | 28 ++ tools/gba-item-use-contract-check/check.py | 58 +++ 20 files changed, 1284 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/gba-item-use-contract.yml create mode 100644 docs/portable-gba-item-use.md create mode 100644 examples/portable_gba_item_use/main.lua create mode 100644 examples/portable_gba_item_use/manifest.json create mode 100644 src/inventory/Gen3FieldUse.lua create mode 100644 tests/engine/gba_field_item_use.lua create mode 100644 tests/gba_item_use/cases.lua create mode 100644 tests/gba_item_use/fixture.lua create mode 100644 tests/gba_item_use/worker.lua create mode 100644 tools/gba-item-use-contract-check/check.py diff --git a/.github/workflows/gba-item-use-contract.yml b/.github/workflows/gba-item-use-contract.yml new file mode 100644 index 000000000..ec7f88f63 --- /dev/null +++ b/.github/workflows/gba-item-use-contract.yml @@ -0,0 +1,45 @@ +name: portable GBA field item contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gba-item-use-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native GBA field flow and compatibility + run: | + luajit tests/engine/gba_field_item_use.lua + luajit tests/engine/item_use_dispatch.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/gba-item-use-contract + python3 tools/gba-item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/gba-item-use-contract/api1-native.json + python3 tools/gba-item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/gba-item-use-contract/api2-native.json + python3 tools/gba-item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/gba-item-use-contract/api2-unified.json + python3 tools/gba-item-use-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/gba-item-use-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gba-item-use-contract + path: /tmp/gba-item-use-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 29b5106c7..f2c46959f 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -149,4 +149,5 @@ existence on Gen1–3 without replacing a private controller. Artwork remains a separate concern; check `mod:supports("hooks", "world.follower.spawn")`. [Portable GB item dispatch](portable-item-use.md) documents the `item.use` -producer and the separate Gold/GBA/DS boundaries. +producer; [GBA field results](portable-gba-item-use.md) document the distinct +GBA contract and the remaining Gold/DS boundaries. diff --git a/docs/modding.md b/docs/modding.md index 647420d27..7df80f938 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -59,7 +59,8 @@ them. Availability does not imply the current game state permits the action: return `nil, "map carries no elevation"`. Callback availability follows Gen2Recomped's actual implementation, rather -than Gen1Recomp's newer catalog. For example, `item.use` and `egg.hatched` have no native call sites here; the hatch event here is +than Gen1Recomp's newer catalog. For example, `item.use` has native producers on GB and GBA with distinct +contracts; `egg.hatched` has no native producer here. The hatch event here is `pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on Gen 1–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. @@ -434,4 +435,5 @@ See [portable follower predicate](portable-follower.md) for the native `world.follower.spawn` lifecycle hook and its generation/artwork boundaries. [Portable GB item dispatch](portable-item-use.md) documents the `item.use` -producer and the separate Gold/GBA/DS boundaries. +producer; [GBA field results](portable-gba-item-use.md) document the distinct +GBA contract and the remaining Gold/DS boundaries. diff --git a/docs/portable-gba-item-use.md b/docs/portable-gba-item-use.md new file mode 100644 index 000000000..9a9c9f69b --- /dev/null +++ b/docs/portable-gba-item-use.md @@ -0,0 +1,82 @@ +# Portable GBA field item results + +On Gen 3, `item.use(next, game, nil, itemId, partySlot, bag)` wraps native +field use after the bag's target/move selection. `next` returns exactly +`ok, kind, text`, including a trailing nil text. FireRed/LeafGreen and +Ruby/Sapphire/Emerald use this producer. It is distinct from the +[GB UI dispatcher](portable-item-use.md); Gold and DS report unavailable. + +```lua +local mod = ... +if mod.generation == 3 and mod:supports("hooks", "item.use") then + mod.hooks:wrap("item.use", function(next, game, battle, item, slot, bag) + local ok, kind, text = next(game, battle, item, slot, bag) + if ok and kind == "heal" then text = "Your custom healing message." end + return ok, kind, text + end) +end +``` + +`partySlot` is a one-based integer, not a Pokemon record. Game and bag are +opaque native handles. Forward them; neither engine promises the other's +private object layout. The battle argument is a positional nil. Move selection +is captured by the native producer and is not an extra hook argument. +Changing item or slot when calling `next` selects the corresponding native +item effect and target. Replacing the other arguments does not replace the +captured native Game, inventory or move selection. + +API 1/2 native use native item IDs. API 2 unified and API 3 use canonical IDs +in argument 3 and resolve replacements through the current loader's content. +Pristine item identity is captured before name patches, including legacy mods; +renaming a bike's display name does not change its native field semantics. + +Returning without `next` skips effects and consumption. A wrapper may return +`false, "blocked", "Your refusal."`, or a successful custom tuple after doing +its own work. A replacement tuple does not itself create native effects. +Native effects and consumption happen inside `next`; replacing its text or +throwing afterward does not undo them. API 3 caches repeated `next` calls. +Unsubscribe and failed-entry rollback remove the wrapper. + +The native bag presents the final text after the hook returns. HP animation, +move learning and other native continuations remain owned by the bag. Nil text +suppresses the result message. A full move set returns `false, "full", text` +and retains the native move-learning operand rather than treating replacement +text as a move ID; a TM is consumed only on successful learning. Successful +bike/fishing/escape actions close the native bag. Medicine consumes once; +key items and flutes remain reusable. White/black flute state participates in +the native encounter modifier and clears when the native map changes. + +This aligns the public callback boundary and the tested item effects. It does +not make every cartridge mechanic, native message or screen identical. The +host retains its own fishing/escape rules and native world services. Custom +`item_effects` registration, additional native key-item handlers, RS Enigma +Berry behavior and battle-item interception need separate native adapters. +TM Case/Berry Pouch menu paths that bypass this field producer remain outside +this seam, as in the reference. No DS item producer is advertised. + +## Validation + +`examples/portable_gba_item_use` is unchanged between the engines and has no +private imports. The ROM-free harness uses both actual SDKs and their native +field dispatchers, with data/session fixtures rather than mocked item effects. +It compares medicine/status/revive/PP effects, failures, Repel, flutes, bikes, +TM results, canonical item replacement, replacement party slot, trailing nil, +wrapper failures, cached next, unsubscribe and rollback. Gen2 tests also drive +the actual GBA bag and party picker through native input and inspect the final +native text after HP animation. Native display text differs between engines; +the comparison checks result kinds, effects, counts and text presence rather +than claiming identical cartridge text. + +```sh +luajit tests/engine/gba_field_item_use.lua +python3 tools/gba-item-use-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/gba-item-use.json +``` + +The external reference is pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Each FireRed/Emerald and +API 1 native/API 2 native/API 2 unified/API 3 comparison runs in separate +LuaJIT processes. Gen1 API 2 has no unified opt-in; that mode compares Gen2's +adapter against the reference's native contract. These are bounded contract +tests, not a full playthrough or UI pixel-parity claim. diff --git a/docs/portable-item-use.md b/docs/portable-item-use.md index 6267c7562..0f38b79d7 100644 --- a/docs/portable-item-use.md +++ b/docs/portable-item-use.md @@ -51,13 +51,11 @@ The pinned Gen1 source has three different situations: - GBA wraps a field-use function with `(game, nil, itemId, partySlot, bag)` and an `(ok, kind, text)` result tuple; its battle-item path is separate. -Gen2Recomped therefore advertises this first producer for **GB only**. GBA's -field tuple needs its own native adapter and shared tests in the next PR. -Recognizing/accepting the API-3 name does not make that producer available. -Gold, GBA and DS keep their existing item flows and report unavailable here. -This boundary avoids routing a GB UI signature into a mod expecting GBA field -results. It is an intermediate step toward shared item APIs, not complete -cross-generation item-hook coverage. +Gen2Recomped exposes both native contracts: GB's UI dispatcher above and +[GBA's field-result adapter](portable-gba-item-use.md). Check the host generation +before interpreting the callback arguments. Gold and DS report unavailable; +GBA battle items keep their separate native path. Recognizing the API-3 name +alone does not make a producer available. ## Validation diff --git a/examples/portable_gba_item_use/main.lua b/examples/portable_gba_item_use/main.lua new file mode 100644 index 000000000..8f4744364 --- /dev/null +++ b/examples/portable_gba_item_use/main.lua @@ -0,0 +1,35 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "item.use") then + stop = mod.hooks:wrap("item.use", function(next, ...) + local game, battle, id, slot, bag = ... + state.calls, state.argCount = state.calls + 1, select("#", ...) + state.game, state.battle, state.item, state.slot, state.bag = game, battle, id, slot, bag + if state.mode == "block" then + return false, "blocked", "PORTABLE BLOCK" + end + if state.mode == "redirect" then + id = state.redirect + end + local result = pack(next(game, battle, id, slot, bag)) + state.returnCount, state.ok, state.kind, state.text = result.n, result[1], result[2], result[3] + if state.mode == "message" then + return result[1], result[2], "PORTABLE TEXT" + end + if state.mode == "silent" then + return result[1], result[2], nil + end + return unpack(result, 1, result.n) + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_gba_item_use/manifest.json b/examples/portable_gba_item_use/manifest.json new file mode 100644 index 000000000..a41f24926 --- /dev/null +++ b/examples/portable_gba_item_use/manifest.json @@ -0,0 +1 @@ +{"id":"portable_gba_item_use","name":"Portable GBA field item results","version":"1.0.0","api":3,"games":["gen3"],"entry":"main.lua","affects_link":false} diff --git a/src/inventory/Gen3FieldUse.lua b/src/inventory/Gen3FieldUse.lua new file mode 100644 index 000000000..af63b50db --- /dev/null +++ b/src/inventory/Gen3FieldUse.lua @@ -0,0 +1,320 @@ +-- GBA field-use boundary. Effects/consumption precede the public result; +-- presentation is returned separately so post-next wrappers can replace text +-- without re-running an effect or committing the native UI too early. +local Runtime = require("src.mods.Runtime") +local Effects = require("src.inventory.ItemEffects") +local Bag = require("src.inventory.Bag") +local Strings = require("src.core.Strings") +local F = {} + +local function keyFor(data, id, stable) + local def = data.items and data.items[id] + if def and def.key then + return def.key + end + if stable and stable[id] then + return stable[id].id + end + local key = Effects.alias(id, def) + if type(key) == "string" and not key:match("^ITEM_%d+$") then + return key + end + local name = def and (def.translationSourceName or def.name) + return type(name) == "string" and name:upper():gsub("[^%w]+", "_") or tostring(id) +end + +function F.kind(data, id, stable) + local def = data.items and data.items[id] + if not def then + return "unknown" + end + if def.effect then + return "mod" + end + local key, effect = keyFor(data, id, stable), Effects.gen3RecordFor(id, data) or {} + if def.machine then + return "tm" + end + if effect.levelUp or key == "RARE_CANDY" then + return "level" + end + if effect.evolve or key:find("STONE", 1, true) then + return "evo" + end + if effect.ev then + return "vitamin" + end + if effect.pp or effect.ppUp or effect.ppMax then + return "pp" + end + if effect.revive or effect.sacredAsh or key == "REVIVE" or key == "MAX_REVIVE" then + return "revive" + end + if effect.heal or Effects.healsHP(id) then + return "heal" + end + if effect.cures or effect.cureAll or key == "ANTIDOTE" or key == "FULL_HEAL" then + return "status" + end + if key:find("REPEL", 1, true) then + return "repel" + end + if key:find("BIKE", 1, true) or key == "BICYCLE" then + return "bike" + end + if key:match("_ROD$") then + return "rod" + end + local kinds = { + ESCAPE_ROPE = "escape", + TOWN_MAP = "map", + COIN_CASE = "coin_case", + POWDER_JAR = "powder_jar", + ITEMFINDER = "itemfinder", + VS_SEEKER = "vs_seeker", + POKE_FLUTE = "flute", + BLACK_FLUTE = "black_white_flute", + WHITE_FLUTE = "black_white_flute", + TM_CASE = "tm_case", + BERRY_POUCH = "berry_pouch", + POKEBLOCK_CASE = "pokeblock_case", + TEACHY_TV = "teachy_tv", + } + if kinds[key] then + return kinds[key] + end + if Effects.isBall(id, def) or key:match("^X_") or key == "POKE_DOLL" then + return "battle" + end + return "none" +end + +local function refusal(game) + return Strings("This isn't the time to use that!") +end +local function textOf(payload) + return type(payload) == "table" and table.concat(payload, "\f") or nil +end + +local function evaluate(game, id, slot, moveIndex) + local stable = game.mods and game.mods._unifiedIdBase and game.mods._unifiedIdBase.items + local kind, data, save = F.kind(game.data, id, stable), game.data, game.save + local def, key, ow = data.items[id], keyFor(data, id, stable), game.overworld + local p = { id = id, target = slot and save.party and save.party[slot], kind = kind } + if not def then + return false, "unknown", Strings("Unknown item."), p + end + if + require("src.core.GameVersion").get() == "emerald" + and (key == "POKE_FLUTE" or key == "TM_CASE" or key == "BERRY_POUCH") + then + return false, "none", refusal(game), p + end + local targeted = { + heal = true, + status = true, + revive = true, + pp = true, + vitamin = true, + level = true, + evo = true, + tm = true, + } + if targeted[kind] or (Effects.needsTarget(id, def) and not Effects.isBall(id, def)) then + if not save.party or #save.party == 0 then + return false, "noparty", Strings("There is no POKeMON."), p + end + if not slot then + return false, "need_slot", Strings("Select a POKeMON."), p + end + if not p.target then + return false, "noparty", Strings("There is no POKeMON."), p + end + end + local effect = Effects.gen3RecordFor(id, data) or {} + if kind == "pp" and not moveIndex and (effect.pp == "one" or effect.ppUp or effect.ppMax) then + return false, "need_move", nil, p + end + if kind == "repel" and (tonumber(save.repelSteps) or 0) > 0 then + return false, "repel", Strings("The REPEL's effect still lingers!"), p + end + if kind == "coin_case" or kind == "powder_jar" then + p.result = "kept" + return true, + kind, + kind == "coin_case" and Strings("Your Coins: %d", save.coins or 0) or Strings( + "Powder: %d", + save.berryPowder or 0 + ), + p + end + if kind == "battle" then + return false, kind, refusal(game), p + end + if kind == "tm" then + local move = def.machine and def.machine.move + local moveDef = move and data.moves and data.moves[move] + if not moveDef then + return false, "invalid", Strings("It won't have any effect."), p + end + local species, compatible = data.pokemon[p.target.species], false + for _, allowed in ipairs(species and species.tmhm or {}) do + if allowed == move then + compatible = true + break + end + end + if not compatible then + return false, "incompatible", Strings("That POKeMON can't learn this move."), p + end + for _, known in ipairs(p.target.moves or {}) do + if known.id == move then + return false, "knows", Strings("That move is already known."), p + end + end + if #p.target.moves >= 4 then + p.result, p.payload = def.machine.kind == "HM" and "learnkept" or "learn", move + p.payloadIsData = true + return false, "full", Strings("Choose a move to forget."), p + end + table.insert(p.target.moves, { id = move, pp = moveDef.pp }) + if def.machine.kind ~= "HM" then + Bag.remove(save, id, 1) + p.consumed = true + end + require("src.pokemon.Gen2Friendship").change(p.target, "LEARNMOVE") + p.result = "kept" + local name = p.target.nickname or species.name + return true, "tm", Strings("%s learned %s!", name, moveDef.name), p + end + if kind == "bike" then + local player = ow and ow.player + if save.forcedBike then + return false, kind, Strings("You can't dismount your BIKE here."), p + end + if + not (ow and ow.map and ow.bikeAllowed and ow:bikeAllowed(ow.map.id)) + or ( + player + and ( + player.surfing + or player.underwater + or (ow.map.runningBlockedAt and ow.map:runningBlockedAt(player.cellX, player.cellY)) + ) + ) + then + return false, kind, refusal(game), p + end + save.onBike = not save.onBike + save.bikeKind = save.onBike and (key == "ACRO_BIKE" and "acro" or key == "MACH_BIKE" and "mach" or nil) + or nil + require("src.core.Music").playMap(data, ow.map.id, save.onBike) + p.result, p.closeBag = "kept", true + return true, kind, nil, p + end + if kind == "rod" then + local player, map = ow and ow.player, ow and ow.map + local x, y + if player and player.facingCell then + x, y = player:facingCell() + end + if not (map and x and map:inBounds(x, y) and map:isWaterCell(x, y)) then + return false, kind, refusal(game), p + end + p.result, p.closeBag, p.action = "kept", true, function() + ow:goFishing(id) + end + return true, kind, nil, p + end + if kind == "escape" then + local point = ow and ow.escapePoint and ow:escapePoint() + if not (ow and ow.map and ow.map.def.allowEscaping == true and point) then + return false, kind, refusal(game), p + end + Bag.remove(save, id, 1) + p.consumed, p.closeBag, p.result = true, true, "kept" + p.action = function() + ow:beginTeleportOut(nil, { escape = true }) + end + return true, kind, Strings("%s used %s!", save.player.name, def.name), p + end + if kind == "black_white_flute" then + save.gen3Flute = key == "WHITE_FLUTE" and "white" or "black" + p.result = "kept" + return true, kind, Strings("%s used %s!", save.player.name, def.name), p + end + if kind == "flute" then + local woke = false + for _, mon in ipairs(save.party or {}) do + if mon.status == "SLP" and not mon.isEgg then + mon.status, mon.sleep, woke = nil, nil, true + end + end + if woke then + require("src.core.Sound").play(data, "Pokeflute") + end + p.result = "kept" + return true, kind, Strings("Played the POKe FLUTE."), p + end + local result, payload, extra = Effects.use(data, save, id, p.target, nil, moveIndex, ow) + p.result, p.payload, p.extra = result, payload, extra + if result == "consumed" then + Bag.remove(save, id, 1) + p.consumed = true + end + local ok = result ~= "failed" + if kind == "unknown" or kind == "none" then + local mapped = { + townmap = "map", + pokeblock_case = "pokeblock_case", + teachy_tv = "teachy_tv", + itemfinder = "itemfinder", + vs_seeker = "vs_seeker", + learn = "tm", + learnkept = "tm", + } + kind = mapped[result] or kind + end + if not ok and p.target then + kind = "noeffect" + end + return ok, kind, textOf(payload), p +end + +function F.call(game, id, slot, moveIndex) + local prepared, nativeOk, nativeKind, nativeText + local function native(_, _, item, targetSlot) + nativeOk, nativeKind, nativeText, prepared = evaluate(game, item, targetSlot, moveIndex) + return nativeOk, nativeKind, nativeText + end + -- Like the source producer, Game/slot/bag are engine handles; only the + -- replacement item and party slot are accepted by the captured native use. + local ok, kind, text = Runtime.call("item.use", native, game, nil, id, slot, game.save.inventory) + if prepared and ok == nativeOk and kind == nativeKind then + prepared.textChanged = text ~= nativeText + if not prepared.payloadIsData then + if prepared.textChanged then + prepared.payload = type(text) == "string" and { text } or nil + end + if prepared.payload == nil and type(text) == "string" then + prepared.payload = { text } + end + end + prepared.text = text + return ok, kind, text, prepared + end + local closes = { bike = true, rod = true, escape = true, on_field = true, teachy_tv = true } + return ok, + kind, + text, + { + result = "failed", + payload = type(text) == "string" and { text } or nil, + text = text, + closeBag = ok and closes[kind] or nil, + custom = true, + id = id, + target = slot and game.save.party and game.save.party[slot], + } +end +return F diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 6f825475e..5b22343b4 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -34,7 +34,7 @@ add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") -add("hooks", "1", "item.use") +add("hooks", "13", "item.use") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4802d84aa..d14e195b5 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1413,6 +1413,8 @@ function Loader:load(data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) + -- Native GBA field semantics must survive presentation-name patches too. + if self:_generation() == 3 then require("src.mods.UnifiedIds").new(self):entries("items") end self:_loadState() self:_discover() -- A RENAMED MOD COLLECTS ITS OLD STATE, now and not before: adoption needs diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 0c89fbd19..1d53a5338 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -173,18 +173,29 @@ BagMenu.refreshCount = refreshCount -- the stack, so every exit that prints has to close it afterwards. For -- every other item the picker popped itself first and closePicker's identity -- check makes it a no-op (#252). -local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) +local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker, prepared) -- HGSS: the party screen's own msg_0300 line for the item (src/ui/Gen4PartyText.lua) local PartyText = require("src.ui.Gen4PartyText") local g4Before = (not battle and target) and PartyText.snapshot(game, target) or nil - local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, - battle, moveIndex, game.overworld) + local result, payload, extra + if prepared then + result, payload, extra = prepared.result, prepared.payload, prepared.extra + else + result, payload, extra = ItemEffects.use(game.data, game.save, id, target, + battle, moveIndex, game.overworld) + end if g4Before then payload = PartyText.itemUseMessages(game, id, target, g4Before, result, payload, moveIndex) end local function closePicker() if picker then picker:close() end end + if prepared and (prepared.closeBag or prepared.action) then + if prepared.closeBag and list and list.close then list:close() end + if prepared.action then prepared.action() end + if prepared.text then showMessages(game, { prepared.text }, closePicker) end + return + end -- BLACK / WHITE's own field uses: the bike, repels, the Dowsing MCHN ... -- (src/inventory/Gen5ItemUse.lua) if type(result) == "string" and result:sub(1, 3) == "bw_" then @@ -456,7 +467,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end end list:close() - teach() + if prepared and prepared.payloadIsData and prepared.textChanged then + showMessages(game, prepared.text and { prepared.text } or nil, teach) + else + teach() + end return end @@ -646,7 +661,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if result == "consumed" then - consume(game, id) + if not (prepared and prepared.consumed) then consume(game, id) end if extra and extra.evolveTo then list:close() local Evolution = require("src.pokemon.Evolution") @@ -748,11 +763,17 @@ end local function useOn(game, battle, id, target, list, moveIndex, picker) -- Gen1's GB seam owns the entire effect/UI dispatch after target selection. - -- Its GBA field producer has a different tuple contract; do not advertise - -- this UI-shaped call as a producer for that generation. + -- GBA instead prepares its native field triple before presenting the final + -- text, using its distinct slot/bag argument shape. if require("src.core.GameVersion").generation() == 1 then return Runtime.call("item.use", vanillaUseOn, game, battle, id, target, list, moveIndex, picker) end + if require("src.core.GameVersion").generation() == 3 and not battle then + local slot + for i, mon in ipairs(game.save.party or {}) do if mon == target then slot = i break end end + local _, _, _, prepared = require("src.inventory.Gen3FieldUse").call(game, id, slot, moveIndex) + return vanillaUseOn(game, nil, prepared.id, prepared.target, list, moveIndex, picker, prepared) + end return vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end @@ -763,6 +784,7 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local key = ItemEffects.alias(id, def) local wantsMove = key == "ETHER" or key == "MAX_ETHER" or key == "PP_UP" + or (key == "PP_MAX" and require("src.core.GameVersion").isGen3()) local opts = { pickOnly = true, -- HP medicine animates its bar with the picker still up (#252). Only diff --git a/src/world/Encounter.lua b/src/world/Encounter.lua index f9cd5686a..cc03a1521 100644 --- a/src/world/Encounter.lua +++ b/src/world/Encounter.lua @@ -132,10 +132,13 @@ end -- the party itself. function Encounter.leadRateMod(data, save, weather) local lead = save and save.party and save.party[1] - if not lead then return nil end - local def = data and data.pokemon and data.pokemon[lead.species] - local n, d = require("src.battle.Abilities").encounterRateMod(lead, def, - weather) + local def = lead and data and data.pokemon and data.pokemon[lead.species] + local n, d = 1, 1 + if lead then n, d = require("src.battle.Abilities").encounterRateMod(lead, def, weather) end + if require("src.core.GameVersion").isGen3() and save then + if save.gen3Flute == "white" then n, d = n * 3, d * 2 + elseif save.gen3Flute == "black" then d = d * 2 end + end if n == 1 and d == 1 then return nil end return { n, d } end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 104169687..dfcc054ca 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1165,6 +1165,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) end self.map = MapLoader.load(Game.data, mapId) if GameVersion.isGen3() then + if fromMapId and fromMapId ~= mapId then Game.save.gen3Flute = nil end require("src.world.Gen3SecretBase").restoreEntrance(Game.data, Game.save, self.map) end -- inside the Battle Pyramid the bag is the Pyramid's own diff --git a/tests/engine/gba_field_item_use.lua b/tests/engine/gba_field_item_use.lua new file mode 100644 index 000000000..01fa3dc65 --- /dev/null +++ b/tests/engine/gba_field_item_use.lua @@ -0,0 +1,118 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/gba_item_use/fixture.lua"), dofile("tests/gba_item_use/cases.lua") +local original = Version.get() +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq(f.e.api:supports("hooks", "item.use"), true, "native GBA field capability") + Cases.run(f) + local bike = f.game.data.items[f.id.MACH_BIKE] + bike.key, bike.name = nil, "RENAMED PRESENTATION" + local ok = f.use("MACH_BIKE") + T.eq(ok, true, "renamed bike retains pristine item semantics") + T.eq(f.biking(), true, "renamed bike mounts through native field dispatcher") + f.close() + for _, scenario in ipairs({ "block", "message", "redirect", "silent" }) do + f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + f.e.state.mode, f.e.state.redirect = scenario, f.canonicalIds and "SUPER_POTION" or f.id.SUPER_POTION + local bag, picker = f.nativePicker("POTION") + T.eq(f.e.state.calls, 0, "native GBA selection precedes field producer") + f.press("a") + T.eq(f.e.state.calls, 1, "native GBA target confirmation invokes field producer once") + T.eq(f.e.state.argCount, 5, "native GBA UI dispatch has field argument shape") + if scenario == "block" then + T.eq(f.mon.hp, 15, "native blocked UI leaves HP unchanged") + T.eq(f.count("POTION"), 3, "native blocked UI leaves inventory unchanged") + else + T.eq(f.mon.hp, scenario == "redirect" and 40 or 35, "native UI executes selected medicine") + T.eq(f.count(scenario == "redirect" and "SUPER_POTION" or "POTION"), 2, "native UI consumes once") + end + local TextBox = require("src.render.TextBox") + for _ = 1, 300 do + if getmetatable(f.game.stack:top()) == TextBox or f.game.stack:top() == bag then + break + end + f.press() + end + if scenario == "silent" then + T.eq(f.game.stack:top(), bag, "nil result text closes picker after native animation") + else + T.eq(getmetatable(f.game.stack:top()), TextBox, "native result text follows HP animation") + end + if scenario == "block" or scenario == "message" then + local box = f.game.stack:top() + local text = scenario == "block" and "PORTABLE BLOCK" or "PORTABLE TEXT" + T.same( + box.pages, + TextBox.paginate(text, box.maxCols, box.letterSpacing, box.maxPixels), + "native UI presents wrapper's final result text" + ) + end + T.eq(f.e.state.calls, 1, "native animation does not re-enter item hook") + f.close() + end + f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + f.fullMoves() + f.e.state.mode = "message" + f.nativePicker("TM_01") + f.press("a") + T.eq(f.count("TM_01"), 3, "full TM awaits successful native learning") + for _ = 1, 1200 do + local found + for _, screen in ipairs(f.game.stack.states) do + if getmetatable(screen) == require("src.ui.Gen3MoveLearnMenu") then + found = screen + break + end + end + if found then + T.eq(found.newMoveId, "FIX_CUT", "post-next text cannot overwrite native move operand") + break + end + f.press("a") + end + local found = false + for _, screen in ipairs(f.game.stack.states) do + if getmetatable(screen) == require("src.ui.Gen3MoveLearnMenu") then + found = true + end + end + T.eq(found, true, "full TM retains actual native learning screen") + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], false, true) + T.check(#failed.result.errors > 0, "entry failure is recorded") + for _, entry in ipairs(Runtime.hooks.chains["item.use"] or {}) do + T.check(entry.owner ~= "portable_gba_item_use", "entry rollback removes GBA item wrapper") + end + local ok, kind = failed.use("POTION", 1) + T.eq(ok, true, "failed entry restores native field effect") + T.eq(kind, "heal", "failed entry restores native field result kind") + T.eq(failed.count("POTION"), 2, "failed entry leaves native consumption once") + failed.close() + end +end +-- Flute state affects the actual native encounter modifier, including an +-- empty party, and never applies to GB/Gold/DS hosts. +local Encounter = require("src.world.Encounter") +for _, version in ipairs(Version.ORDER) do + Version.set(version) + local data, save = { pokemon = {} }, { party = {}, gen3Flute = "white" } + local value = Encounter.leadRateMod(data, save) + if Version.isGen3() then + T.same(value, { 3, 2 }, "white flute native encounter ratio") + else + T.eq(value, nil, "flute state cannot alter other generations") + end + save.gen3Flute = "black" + value = Encounter.leadRateMod(data, save) + if Version.isGen3() then + T.same(value, { 1, 2 }, "black flute native encounter ratio") + else + T.eq(value, nil, "black flute state cannot alter other generations") + end +end +Version.set(original) +T.finish("native GBA field item tuple and UI adapter") diff --git a/tests/engine/item_use_dispatch.lua b/tests/engine/item_use_dispatch.lua index cafa68711..f0a68a2b4 100644 --- a/tests/engine/item_use_dispatch.lua +++ b/tests/engine/item_use_dispatch.lua @@ -8,12 +8,23 @@ for _, version in ipairs(Version.ORDER) do for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) local gen, api = Version.generation(), f.e.api - T.eq(api:supports("hooks", "item.use"), gen == 1, "GB producer availability is honest") + T.eq(api:supports("hooks", "item.use"), gen == 1 or gen == 3, "native item producer availability is honest") if gen == 1 then Cases.run(f) + elseif gen == 3 then + local calls = 0 + local off = api.hooks:wrap("item.use", function(next, ...) + calls = calls + 1 + T.eq(select("#", ...), 5, "GBA producer uses field signature") + return next(...) + end, 100) + f.begin("REPEL", false) + T.eq(calls, 1, "GBA field use reaches native producer") + T.eq(f.count("REPEL"), 2, "GBA field use retains one native consumption") + off() else -- Registration is legal in API 3, but the GB-shaped producer must - -- never run on GBA's different field tuple contract or DS screens. + -- never run on Gold or DS screens. local calls = 0 local off = api.hooks:wrap("item.use", function() calls = calls + 1 diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 0ac6dcbf7..98972a584 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -77,9 +77,9 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': additive native flight interface is reported when installed') T.check(yes(api, 'hooks', 'core.logic_speed'), version .. ': native speed hook is advertised') - T.check(GameVersion.generation(version) == 1 and yes(api, 'hooks', 'item.use') - or GameVersion.generation(version) ~= 1 and no(api, 'hooks', 'item.use'), - version .. ': item-use producer is advertised only for its native GB contract') + local itemProducer = GameVersion.generation(version) == 1 or GameVersion.generation(version) == 3 + T.check(itemProducer and yes(api, 'hooks', 'item.use') or not itemProducer and no(api, 'hooks', 'item.use'), + version .. ': item-use availability follows native GB or GBA field producer') T.check(no(api, 'events', 'egg.hatched'), version .. ': absent Gen1Recomp API-3 events are not advertised') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), diff --git a/tests/gba_item_use/cases.lua b/tests/gba_item_use/cases.lua new file mode 100644 index 000000000..b360a8d36 --- /dev/null +++ b/tests/gba_item_use/cases.lua @@ -0,0 +1,179 @@ +local M = {} +function M.run(f) + local T, e, mon, rows = f.T, f.e, f.mon, {} + for _, key in ipairs({ "POTION", "AWAKENING", "REVIVE", "ETHER", "COIN_CASE" }) do + if key == "AWAKENING" then + mon.status = "SLP" + end + if key == "REVIVE" then + mon.hp = 0 + end + e.state.mode = "block" + local hp, count, pp = mon.hp, f.count(key), f.pp() + local ok, kind, text = f.use(key, key ~= "COIN_CASE" and 1 or nil, key == "ETHER" and 1 or nil) + T.eq(ok, false, "block refuses native field effect") + T.eq(kind, "blocked", "block preserves replacement kind") + T.eq(text, "PORTABLE BLOCK", "block preserves replacement text") + T.eq(mon.hp, hp, "block preserves HP") + T.eq(f.count(key), count, "block preserves bag") + T.eq(f.pp(), pp, "block preserves PP") + T.eq(e.state.argCount, 5, "GBA native field signature has five arguments") + T.eq(e.state.game, f.game, "original Game handle") + T.eq(e.state.battle, nil, "field battle hole is preserved") + T.eq(e.state.slot, key ~= "COIN_CASE" and 1 or nil, "party slot is primitive") + e.state.mode = "native" + ok, kind, text = f.use(key, key ~= "COIN_CASE" and 1 or nil, key == "ETHER" and 1 or nil) + T.eq(ok, true, "native field effect succeeds") + T.eq(e.state.returnCount, 3, "native field result has three values") + T.eq( + f.count(key), + count - (key == "COIN_CASE" and 0 or 1), + "native field consumes once or keeps key item" + ) + rows[#rows + 1] = { + item = key, + ok = ok, + kind = kind, + hp = mon.hp, + pp = f.pp(), + count = f.count(key), + textType = type(text), + } + end + local function observe(label, key, slot, move) + local ok, kind, text = f.use(key, slot, move) + rows[#rows + 1] = { + label = label, + ok = ok == true, + kind = kind, + textType = type(text), + hp = mon.hp, + pp = f.pp(), + count = f.count(key), + } + return ok, kind, text + end + mon.hp = 40 + local before = f.count("POTION") + local ok, kind = observe("no effect", "POTION", 1) + T.eq(ok, false, "native no-effect is false") + T.eq(kind, "noeffect", "native no-effect kind") + T.eq(f.count("POTION"), before, "native no-effect does not consume") + ok, kind = observe("missing slot", "POTION") + T.eq(ok, false, "missing target slot refused") + T.eq(kind, "need_slot", "missing slot kind") + ok, kind = observe("invalid slot", "POTION", 99) + T.eq(ok, false, "invalid target slot refused") + T.eq(kind, "noparty", "invalid slot kind") + ok, kind = observe("missing move", "ETHER", 1) + T.eq(ok, false, "missing move index refused") + T.eq(kind, "need_move", "missing move kind") + f.fill() + observe("repel", "REPEL") + local counter = f.count("REPEL") + ok, kind = observe("repel active", "REPEL") + T.eq(ok, false, "active Repel is not replaced") + T.eq(kind, "repel", "active Repel refusal retains field kind") + T.eq(f.count("REPEL"), counter, "active Repel refusal does not consume") + mon.hp = 15 + e.state.mode = "message" + local text + ok, kind, text = observe("replace text", "POTION", 1) + T.eq(text, "PORTABLE TEXT", "post-next text replacement reaches caller") + T.eq(f.count("POTION"), 2, "text replacement consumes once") + e.state.mode, e.state.redirect = "redirect", f.canonicalIds and "SUPER_POTION" or f.id.SUPER_POTION + mon.hp = 15 + observe("redirect", "POTION", 1) + T.eq(f.count("POTION"), 2, "redirect keeps original item") + T.eq(f.count("SUPER_POTION"), 2, "redirect consumes replacement item") + T.eq(mon.hp, 40, "redirect runs replacement native medicine") + e.state.mode = "native" + for _, key in ipairs({ "OLD_ROD", "ESCAPE_ROPE", "UNKNOWN" }) do + ok = observe("native refusal:" .. key, key) + T.eq(ok, false, "native field preflight rejects unavailable action") + T.eq(f.count(key), 3, "native field refusal does not consume") + end + e.state.mode = "silent" + mon.hp = 15 + local text + ok, kind, text = observe("silent result", "POTION", 1) + T.eq(text, nil, "post-next nil text is preserved") + T.eq(f.count("POTION"), 1, "silent result consumes once") + e.state.mode = "native" + local off = e.api.hooks:wrap("item.use", function(next, game, battle, id, _, bag) + return next(game, battle, id, 2, bag) + end, 100) + f.fill() + mon.hp, f.second.hp = 15, 5 + observe("replacement slot", "POTION", 1) + T.eq(mon.hp, 15, "replacement slot retains original target") + T.eq(f.second.hp, 25, "replacement slot reaches native second party mon") + T.eq(e.state.slot, 2, "replacement slot remains an integer") + off() + observe("white flute", "WHITE_FLUTE") + T.eq(f.flute(), "white", "white flute enables native state") + observe("black flute", "BLACK_FLUTE") + T.eq(f.flute(), "black", "black flute replaces white native state") + T.eq(f.count("WHITE_FLUTE"), 3, "white flute is reusable") + T.eq(f.count("BLACK_FLUTE"), 3, "black flute is reusable") + observe("bike mount", "MACH_BIKE") + T.eq(f.biking(), true, "native bicycle mounts") + observe("bike dismount", "MACH_BIKE") + T.eq(f.biking(), false, "native bicycle dismounts") + observe("TM teach", "TM_01", 1) + T.eq(#mon.moves, 2, "native TM teach changes party moves") + T.eq(f.count("TM_01"), 2, "native TM teach consumes once") + ok, kind = observe("TM known", "TM_01", 1) + T.eq(ok, false, "known TM refuses") + T.eq(kind, "knows", "known TM reason survives field boundary") + T.eq(f.count("TM_01"), 2, "known TM refusal does not consume") + f.fullMoves() + ok, kind = observe("TM full", "TM_01", 1) + T.eq(ok, false, "full move set requires native learning continuation") + T.eq(kind, "full", "full move set preserves field result kind") + T.eq(#mon.moves, 4, "full move set is not replaced before selection") + T.eq(f.count("TM_01"), 2, "pending TM does not consume") + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("item.use", function(next, ...) + if after then + next(...) + end + error("intentional GBA item wrapper failure") + end, 100) + f.fill() + mon.hp = 15 + local before = e.state.calls + observe("failure:" .. tostring(after), "POTION", 1) + T.eq(e.state.calls, before + 1, "wrapper failure runs downstream once") + T.eq(f.count("POTION"), 2, "wrapper failure cannot double-consume") + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("item.use", function(next, ...) + local before = e.state.calls + local function pack(...) + return { n = select("#", ...), ... } + end + local a, b = pack(next(...)), pack(next(...)) + T.eq(a.n, 3, "first next returns GBA triple") + T.same(a, b, "repeated next retains GBA result") + T.eq(e.state.calls, before + 1, "repeated next reaches downstream once") + return unpack(a, 1, a.n) + end, 100) + f.fill() + mon.hp = 15 + observe("replay", "POTION", 1) + T.eq(f.count("POTION"), 2, "replayed GBA next consumes once") + off() + end + e.stop() + e.stop() + f.fill() + mon.hp = 15 + local beforeCalls = e.state.calls + observe("unsubscribed", "POTION", 1) + T.eq(e.state.calls, beforeCalls, "unsubscribe stops native GBA callbacks") + T.eq(f.count("POTION"), 2, "unsubscribe restores ordinary native use") + return rows +end +return M diff --git a/tests/gba_item_use/fixture.lua b/tests/gba_item_use/fixture.lua new file mode 100644 index 000000000..abc5d61fb --- /dev/null +++ b/tests/gba_item_use/fixture.lua @@ -0,0 +1,351 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local rows = { + { 13, "POTION", "heal", { heal = true, amount = 20 }, {} }, + { 22, "SUPER_POTION", "heal", { heal = true, amount = 50 }, {} }, + { 17, "AWAKENING", "status", { cures = { "SLP" } }, { [4] = 32 } }, + { 24, "REVIVE", "revive", { revive = true, amount = "half" }, {} }, + { 34, "ETHER", "pp", { pp = "one", amount = 10 }, { [5] = 24, [7] = 10 } }, + { 86, "REPEL", "repel", nil, nil }, + { 260, "COIN_CASE", "coin_case", nil, nil }, + { 360, "BICYCLE", "bike", nil, nil }, + { 259, "MACH_BIKE", "bike", nil, nil }, + { 85, "ESCAPE_ROPE", "escape", nil, nil }, + { 262, "OLD_ROD", "rod", nil, nil }, + { 43, "WHITE_FLUTE", "black_white_flute", nil, nil }, + { 42, "BLACK_FLUTE", "black_white_flute", nil, nil }, + { 289, "TM_01", "tm", nil, nil }, + { 999, "UNKNOWN", "none", nil, nil }, + } + local data = backend == "gen1" and T.sdk.gen3Data() or T.fixtures.fresh() + local nativeIds, pack = {}, { items = {} } + data.items, data.constants = data.items or {}, data.constants or {} + data.constants.gen3ItemEffects = {} + for _, row in ipairs(rows) do + local number, key, kind, effect, bytes = unpack(row) + local id = backend == "gen1" and number or ("ITEM_%03d"):format(number) + nativeIds[key] = id + if backend == "gen1" then + data.gen3Items._byId[number] = { name = key:gsub("_", " "), pocket = "ITEMS", price = 100 } + else + data.items[id] = + { id = id, name = key:gsub("_", " "), key = key, index = number, price = 100, pocket = "ITEM" } + if key == "TM_01" then + data.items[id].machine = { kind = "TM", number = 1, move = "FIX_CUT" } + end + if effect then + data.constants.gen3ItemEffects[id] = effect + end + end + local bytesRow = {} + for i = 1, 7 do + bytesRow[i] = (bytes or {})[i] or 0 + end + pack.items[number] = + { name = key:gsub("_", " "), pocket = "ITEMS", fieldUse = kind, price = 100, effect = bytesRow } + end + data.maps.FIX_FIELD = { + id = "FIX_FIELD", + name = "FIX FIELD", + width = 4, + height = 4, + bikingAllowed = 1, + allowCycling = true, + allowEscaping = false, + } + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local file = assert(io.open(root .. "/examples/portable_gba_item_use/" .. name, "rb")) + files["mods/portable_gba_item_use/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_gba_item_use/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_gba_item_use/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_gba_item_use/main.lua"] = files["mods/portable_gba_item_use/main.lua"] + .. '\nerror("intentional GBA item entry failure")' + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/portable_gba_item_use" }, + { data = data, fs = T.sdk.memfs(files), dev = false, generation = 3 } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + version = version, + options = {}, + player = { name = "PROBE" }, + party = {}, + inventory = {}, + flags = {}, + }, + } + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local mon + local bag, use, count + if backend == "gen1" then + local Items = require("src.core.game3.items_data") + for _, key in ipairs({ "_pack", "_byId", "_byName" }) do + patch(Items, key, Items[key]) + end + Items.installPack(pack) + local Pokemon = require("src.core.game3.pokemon") + patch(Pokemon, "_battleMoves", { [33] = { pp = 35 }, [52] = { pp = 25 } }) + patch(Pokemon, "_tmhm", { machines = { [0] = 52 }, learnsets = { [4] = { lo = 1, hi = 0 } } }) + patch(Pokemon, "_moveNames", { [33] = "TACKLE", [52] = "EMBER" }) + patch(Pokemon, "_speciesMeta", data.gen3Pokemon._speciesMeta) + local Runtime = require("src.core.game3.runtime") + patch(Runtime, "_game", game) + local text = {} + for _, key in ipairs({ + "gText_PkmnWokeUp2", + "gText_PkmnHPRestoredByVar2", + "gText_PkmnWokeUp", + "gText_PkmnReturnedToNormal", + "gText_PkmnRecoveredFromPoison", + "gText_PkmnBurnHealed", + "gText_PkmnWasDefrosted", + "gText_PkmnCuredOfParalysis", + "gText_PPWasRestored", + "gText_WontHaveEffect", + "gText_CoinCase", + "gText_PlayerUsedVar2", + "gText_RepelEffectsLingered", + "gText_CantDismountBike", + "gText_OakForbidsUseOfItemHere", + "gText_DadsAdvice", + "gText_PkmnLearnedMove3", + "gText_PkmnAlreadyKnows", + "gText_PkmnCantLearnMove", + "gText_PkmnNeedsToReplaceMove", + "gText_MoveNotLearned", + "gText_UsedVar2WildLured", + "gText_UsedVar2WildRepelled", + "gText_OakThisIsntTheTime", + "gText_ThereIsNoPokemon", + }) do + text[key] = { { t = "text", s = key }, { t = "eos" } } + end + patch(require("src.core.game3.scripting.space"), "bundle", { text = text, events = {} }) + mon = { + species = 4, + nickname = "PROBE", + level = 12, + hp = 15, + maxHp = 40, + friendship = 70, + moves = { 33 }, + pp = { 0 }, + } + game.session = { + version = version, + name = "PROBE", + map = "FIX_FIELD", + party = { mon }, + coins = 27, + flags = {}, + vars = {}, + } + local player = require("src.core.game3.player") + for name, value in pairs({ + cellX = 2, + cellY = 2, + px = 32, + py = 32, + facing = "down", + moving = false, + surfing = false, + underwater = false, + biking = false, + }) do + patch(player, name, value) + end + game.save.party = game.session.party + local Bag = require("src.core.game3.bag") + bag = Bag.new() + game.session.bag = bag + use = function(id, slot, move) + return require("src.core.game3.item_use").useField(game.session, bag, id, slot, move) + end + count = function(id) + return Bag.get(bag, id) + end + else + mon = { + species = "FIXMON_A", + nickname = "PROBE", + level = 12, + hp = 15, + stats = { hp = 40 }, + moves = { { id = "FIX_TACKLE", pp = 0 } }, + } + game.save.party = { mon } + game.save.coins = 27 + local World = require("src.world.OverworldController") + local bound = false + for i = 1, 40 do + local name, old = debug.getupvalue(World.bikeAllowed, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(World.bikeAllowed, i, game) + restores[#restores + 1] = function() + debug.setupvalue(World.bikeAllowed, i, old) + end + bound = true + break + end + end + assert(bound, "missing native world Game binding") + local def = data.maps.FIX_FIELD + def.tileset, def.blockPx, def.blocks = "FIX_OUT", 16, {} + for i = 1, 16 do + def.blocks[i] = 1 + end + local player = setmetatable( + { cellX = 2, cellY = 2, facing = "down" }, + { __index = require("src.world.Player") } + ) + game.overworld = setmetatable( + { map = require("src.world.Map").new(def, data.tilesets.FIX_OUT), player = player }, + { __index = World } + ) + bag = game.save.inventory + use = function(id, slot, move) + return require("src.inventory.Gen3FieldUse").call(game, id, slot, move) + end + count = function(id) + return bag[id] or 0 + end + end + result.loader.game = game + game.mods = result.loader + local second = require("src.mods.Merge").deepCopy(mon) + second.hp, second.nickname = 5, "SECOND" + game.save.party[2] = second + local function fill() + if backend == "gen1" then + local Bag = require("src.core.game3.bag") + Bag.clear(bag) + for _, id in pairs(nativeIds) do + Bag.add(bag, id, 3) + end + else + for _, id in pairs(nativeIds) do + bag[id] = 3 + end + end + end + fill() + local function press(key) + game.input.pressed = key and { [key] = true } or {} + assert(game.stack:top()):update(1 / 60) + game.input.pressed = {} + end + local function nativePicker(key) + assert(backend == "gen2", "Gen2 native UI fixture") + local Data = require("src.core.Data") + for _, name in ipairs({ "items", "constants", "pokemon", "moves", "audio" }) do + patch(Data, name, data[name]) + end + require("src.render.Font").load(data) + game.input = setmetatable({}, { __index = require("src.core.Input") }) + game.input:init() + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + game.stack.states = { game.overworld } + local menu = require("src.ui.Gen3BagMenu").new(game, {}) + game.stack:push(menu) + for i, row in ipairs(menu.rows) do + if row.id == nativeIds[key] then + menu.index = i + break + end + end + for _ = 1, 1200 do + if getmetatable(game.stack:top()) == require("src.ui.Gen3PartyMenu") then + break + end + press("a") + end + assert( + getmetatable(game.stack:top()) == require("src.ui.Gen3PartyMenu"), + "native GBA party picker expected" + ) + return menu, game.stack:top() + end + local function fullMoves() + if backend == "gen1" then + mon.moves, mon.pp = { 33, 10, 45, 98 }, { 35, 35, 40, 30 } + else + data.moves.FIX_EXTRA = { name = "EXTRA", type = "NORMAL", pp = 30, power = 40, accuracy = 100 } + mon.moves = {} + for _, id in ipairs({ "FIX_TACKLE", "FIX_SCRATCH", "FIX_EMBERISH", "FIX_EXTRA" }) do + mon.moves[#mon.moves + 1] = { id = id, pp = data.moves[id].pp } + end + end + end + return { + fullMoves = fullMoves, + T = T, + e = result.loader.exports.portable_gba_item_use, + game = game, + mon = mon, + bag = bag, + result = result, + use = function(key, slot, move) + return use(nativeIds[key] or key, slot, move) + end, + id = nativeIds, + count = function(key) + return count(nativeIds[key] or key) + end, + fill = fill, + patch = patch, + backend = backend, + api = api, + version = version, + canonicalIds = api >= 3 or (backend == "gen2" and api == 2 and format == "unified"), + second = second, + nativePicker = nativePicker, + press = press, + flute = function() + if backend ~= "gen1" then + return game.save.gen3Flute + end + local flags = require("src.core.game3.field_moves").SYS_FLAGS + return game.session.flags[flags.WHITE_FLUTE_ACTIVE] and "white" + or game.session.flags[flags.BLACK_FLUTE_ACTIVE] and "black" + or nil + end, + biking = function() + return backend == "gen1" and game.session.biking == true + or backend == "gen2" and game.save.onBike == true + end, + pp = function() + return backend == "gen1" and mon.pp[1] or mon.moves[1].pp + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/gba_item_use/worker.lua b/tests/gba_item_use/worker.lua new file mode 100644 index 000000000..bc7acde9b --- /dev/null +++ b/tests/gba_item_use/worker.lua @@ -0,0 +1,28 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/gba_item_use/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/gba_item_use/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local native = dofile(root .. "/tests/gba_item_use/fixture.lua").new( + root, + backend, + version, + tonumber(api), + format, + not failing, + failing + ) + if failing then + native.T.check(#native.result.errors > 0, "entry rollback is recorded") + end + local ok, kind = native.use("POTION", 1) + native.T.eq(ok, true, "no mod/rollback preserves native GBA effect") + native.T.eq(kind, "heal", "no mod/rollback preserves native GBA kind") + native.T.eq(native.count("POTION"), 2, "no mod/rollback consumes native item once") + rows[#rows + 1] = + { label = failing and "rollback" or "no mod", count = native.count("POTION"), hp = native.mon.hp } + native.close() +end +print("GBA_ITEM_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable GBA field items") diff --git a/tools/gba-item-use-contract-check/check.py b/tools/gba-item-use-contract-check/check.py new file mode 100644 index 000000000..66beb6cbb --- /dev/null +++ b/tools/gba-item-use-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Compare the unchanged GBA field-item consumer through both native effect dispatchers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/gba_item_use/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("GBA_ITEM_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("GBA_ITEM_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native GBA field result dispatch", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"item dispatch mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native GBA item observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_gba_item_use/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From d2c31afd7204bace559a78e7dcf37710439d5cd7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:42:10 +0200 Subject: [PATCH 33/86] Keep held Everstone outside evolution-item preflight --- src/inventory/Gen3FieldUse.lua | 2 +- tests/gba_item_use/cases.lua | 2 +- tests/gba_item_use/fixture.lua | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/inventory/Gen3FieldUse.lua b/src/inventory/Gen3FieldUse.lua index af63b50db..20f18f2b7 100644 --- a/src/inventory/Gen3FieldUse.lua +++ b/src/inventory/Gen3FieldUse.lua @@ -38,7 +38,7 @@ function F.kind(data, id, stable) if effect.levelUp or key == "RARE_CANDY" then return "level" end - if effect.evolve or key:find("STONE", 1, true) then + if effect.evolve or Effects.isStone(key) then return "evo" end if effect.ev then diff --git a/tests/gba_item_use/cases.lua b/tests/gba_item_use/cases.lua index b360a8d36..a11c9914e 100644 --- a/tests/gba_item_use/cases.lua +++ b/tests/gba_item_use/cases.lua @@ -88,7 +88,7 @@ function M.run(f) T.eq(f.count("SUPER_POTION"), 2, "redirect consumes replacement item") T.eq(mon.hp, 40, "redirect runs replacement native medicine") e.state.mode = "native" - for _, key in ipairs({ "OLD_ROD", "ESCAPE_ROPE", "UNKNOWN" }) do + for _, key in ipairs({ "OLD_ROD", "ESCAPE_ROPE", "EVERSTONE", "UNKNOWN" }) do ok = observe("native refusal:" .. key, key) T.eq(ok, false, "native field preflight rejects unavailable action") T.eq(f.count(key), 3, "native field refusal does not consume") diff --git a/tests/gba_item_use/fixture.lua b/tests/gba_item_use/fixture.lua index abc5d61fb..62da09139 100644 --- a/tests/gba_item_use/fixture.lua +++ b/tests/gba_item_use/fixture.lua @@ -18,6 +18,7 @@ function M.new(root, backend, version, api, format, noMod, failing) { 43, "WHITE_FLUTE", "black_white_flute", nil, nil }, { 42, "BLACK_FLUTE", "black_white_flute", nil, nil }, { 289, "TM_01", "tm", nil, nil }, + { 195, "EVERSTONE", "none", nil, nil }, { 999, "UNKNOWN", "none", nil, nil }, } local data = backend == "gen1" and T.sdk.gen3Data() or T.fixtures.fresh() From a37bd94c845da5d450632abf4d0dcbc6cc63bf2d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:52:02 +0200 Subject: [PATCH 34/86] Correct documented GBA native host coverage --- docs/portable-gba-item-use.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/portable-gba-item-use.md b/docs/portable-gba-item-use.md index 9a9c9f69b..ed64ce460 100644 --- a/docs/portable-gba-item-use.md +++ b/docs/portable-gba-item-use.md @@ -2,8 +2,9 @@ On Gen 3, `item.use(next, game, nil, itemId, partySlot, bag)` wraps native field use after the bag's target/move selection. `next` returns exactly -`ok, kind, text`, including a trailing nil text. FireRed/LeafGreen and -Ruby/Sapphire/Emerald use this producer. It is distinct from the +`ok, kind, text`, including a trailing nil text. FireRed and Emerald use this producer in Gen2Recomped. Gen1Recomp also +registers LeafGreen, Ruby and Sapphire; those ROM profiles are not registered +in Gen2Recomped and are not covered by this adapter. It is distinct from the [GB UI dispatcher](portable-item-use.md); Gold and DS report unavailable. ```lua From 1c35b4a5174680104ee85d9b3bc5d40bab33fd38 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:54:23 +0200 Subject: [PATCH 35/86] Exclude the GBA consumer from no-mod fixture filesystems --- tests/gba_item_use/fixture.lua | 3 +++ tests/gba_item_use/worker.lua | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/gba_item_use/fixture.lua b/tests/gba_item_use/fixture.lua index 62da09139..166ff067a 100644 --- a/tests/gba_item_use/fixture.lua +++ b/tests/gba_item_use/fixture.lua @@ -70,6 +70,9 @@ function M.new(root, backend, version, api, format, noMod, failing) files["mods/portable_gba_item_use/main.lua"] = files["mods/portable_gba_item_use/main.lua"] .. '\nerror("intentional GBA item entry failure")' end + if noMod then + files["mods/portable_gba_item_use/manifest.json"], files["mods/portable_gba_item_use/main.lua"] = nil, nil + end local result = T.sdk.loadMods( noMod and {} or { "mods/portable_gba_item_use" }, { data = data, fs = T.sdk.memfs(files), dev = false, generation = 3 } diff --git a/tests/gba_item_use/worker.lua b/tests/gba_item_use/worker.lua index bc7acde9b..9f1e15f8b 100644 --- a/tests/gba_item_use/worker.lua +++ b/tests/gba_item_use/worker.lua @@ -13,6 +13,9 @@ for _, failing in ipairs({ false, true }) do not failing, failing ) + if not failing then + native.T.eq(native.result.loader.exports.portable_gba_item_use, nil, "no-mod filesystem excludes consumer") + end if failing then native.T.check(#native.result.errors > 0, "entry rollback is recorded") end From 61616f61e2c5b4dd698ed9413d487ed5983e7c2c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:56:38 +0200 Subject: [PATCH 36/86] Add portable native encounter-table preview transforms --- .../workflows/encounter-table-contract.yml | 45 +++++ docs/api3-portability.md | 3 + docs/modding.md | 3 + docs/portable-encounter-table.md | 75 +++++++ docs/world-overview.md | 17 +- examples/portable_encounter_table/main.lua | 42 ++++ .../portable_encounter_table/manifest.json | 1 + src/mods/Api3.lua | 16 ++ src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- src/mods/WorldOverview.lua | 25 ++- tests/encounter_table/cases.lua | 105 ++++++++++ tests/encounter_table/fixture.lua | 188 ++++++++++++++++++ tests/encounter_table/worker.lua | 40 ++++ tests/engine/encounter_table_preview.lua | 78 ++++++++ tests/engine/world_overview_api.lua | 2 +- tools/encounter-table-contract-check/check.py | 58 ++++++ 17 files changed, 687 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/encounter-table-contract.yml create mode 100644 docs/portable-encounter-table.md create mode 100644 examples/portable_encounter_table/main.lua create mode 100644 examples/portable_encounter_table/manifest.json create mode 100644 tests/encounter_table/cases.lua create mode 100644 tests/encounter_table/fixture.lua create mode 100644 tests/encounter_table/worker.lua create mode 100644 tests/engine/encounter_table_preview.lua create mode 100644 tools/encounter-table-contract-check/check.py diff --git a/.github/workflows/encounter-table-contract.yml b/.github/workflows/encounter-table-contract.yml new file mode 100644 index 000000000..3d9ecfa77 --- /dev/null +++ b/.github/workflows/encounter-table-contract.yml @@ -0,0 +1,45 @@ +name: portable encounter preview contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: encounter-table-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native preview producers and compatibility + run: | + luajit tests/engine/encounter_table_preview.lua + luajit tests/engine/world_overview_api.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/encounter-table-contract + python3 tools/encounter-table-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/encounter-table-contract/api1-native.json + python3 tools/encounter-table-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/encounter-table-contract/api2-native.json + python3 tools/encounter-table-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/encounter-table-contract/api2-unified.json + python3 tools/encounter-table-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/encounter-table-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: encounter-table-contract + path: /tmp/encounter-table-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f2c46959f..e599a99fe 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -151,3 +151,6 @@ separate concern; check `mod:supports("hooks", "world.follower.spawn")`. [Portable GB item dispatch](portable-item-use.md) documents the `item.use` producer; [GBA field results](portable-gba-item-use.md) document the distinct GBA contract and the remaining Gold/DS boundaries. + +[Portable encounter previews](portable-encounter-table.md) document +`encounter.table` and its preview-only scope, SDK modes and native boundaries. diff --git a/docs/modding.md b/docs/modding.md index 7df80f938..3acc0cdba 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -437,3 +437,6 @@ See [portable follower predicate](portable-follower.md) for the native [Portable GB item dispatch](portable-item-use.md) documents the `item.use` producer; [GBA field results](portable-gba-item-use.md) document the distinct GBA contract and the remaining Gold/DS boundaries. + +[Portable encounter previews](portable-encounter-table.md) document +`encounter.table` and its preview-only scope, SDK modes and native boundaries. diff --git a/docs/portable-encounter-table.md b/docs/portable-encounter-table.md new file mode 100644 index 000000000..90ffdf6bb --- /dev/null +++ b/docs/portable-encounter-table.md @@ -0,0 +1,75 @@ +# Portable encounter preview transforms + +`encounter.table(next, dist, ctx)` transforms the distribution returned by +`mod.world:effectiveEncounters(mapId, terrain, opts)`. It is available on +Gen1–3 hosts. DS retains its existing query and reports this hook unavailable. + +```lua +local mod = ... +if mod:supports("hooks", "encounter.table") then + mod.hooks:wrap("encounter.table", function(next, dist, ctx) + local result = next(dist, ctx) + -- Report a custom distribution to this explicit-map preview. + if ctx.preview and myOptions.showCustomTable then return { MEW = 7 } end + return result + end) +end +``` + +This is a **preview-only** contract, matching Gen1Recomp's existing native +producers. It does not change actual wild encounters. Their existing +`encounter.roll` and `encounter.species` seams remain separate. A future live +table-transform contract would need producers and tests on both engines. + +`dist` maps public species IDs to raw weights. Weights are not percentages; +the native span is 256 on GB and 100 on Gold/GBA. Duplicate species are +aggregated before the hook. `ctx` contains the queried `mapId`, `terrain` and +`preview = true`. The GBA producer additionally forwards `opts`; GB/Gold do +not. There is no Game, overworld or RNG handle in the context. Map IDs retain +their dataset-specific identity. + +A table return replaces the preview distribution; an empty table is a valid +replacement. Nil or any non-table result preserves the producer's detached +map. `next` returns a distribution, not the whole `{chance, dist}` record. +The native rate `chance` is independent and cannot be replaced by this hook. +A known map without a native table still calls the hook with `{}` and zero +chance. GBA includes native slot weights at zero rate; GB/Gold return empty +weights at zero rate, matching their respective reference producers. + +The producer creates fresh distributions per query; changing callback weights +cannot persist into the imported encounter tables. The engine itself does not +roll RNG, load a map or change the save. Installed callbacks execute mod code +and can have their own side effects. Wrapper errors retain normal hook-bus +recovery; API 3 repeated `next` calls reuse downstream results. Unsubscribe and +failed-entry rollback remove the callback. + +API 3 aliases `indoor` to `grass` before the native callback. Its only query +option is `daytime = "MORN" | "DAY" | "NITE" | "DARK"`, supported on Gen 2; +`DARK` selects night. Other generations accept nil/empty options. Invalid +API-3 options are rejected before callback dispatch. Legacy Gen2Recomped time +aliases and its additional legacy indoor query remain available; the pinned +Gen1 legacy Gold facade accepts grass/water, so portable legacy consumers use +those terrain names. Gen1 API 2 has no unified opt-in mode. + +The shared ROM-free suite runs the unchanged public example through both +real SDKs and `mod.world` facades on Red/Blue/Yellow, Gold/Silver/Crystal, +FireRed/Emerald in four SDK modes. It covers base land/water/indoor queries, +time selection, replacement and in-place changes, empty/zero-rate tables, +non-table fallback, errors before/after next, unsubscribe, rollback and genuine +no-mod filesystems. Native tests also verify DS exclusion, preserved native +rolls and unchanged native data/save state. No fake Runtime dispatch or +replacement WorldAPI is used. + +```sh +luajit tests/engine/encounter_table_preview.lua +python3 tools/encounter-table-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/encounter-table.json +``` + +The reference is pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Gen1 also has LeafGreen, Ruby and +Sapphire profiles; Gen2Recomped currently does not register those versions. +The fixture refuses unregistered versions instead of silently testing Red. +These checks prove the public preview boundary, not full playthroughs, +identical native RNG algorithms or all encounter-mechanic parity. diff --git a/docs/world-overview.md b/docs/world-overview.md index e3c494197..3ca405cc2 100644 --- a/docs/world-overview.md +++ b/docs/world-overview.md @@ -2,7 +2,9 @@ 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. +load maps, consume RNG, refresh objects, or change the save. On Gen1–3, +`effectiveEncounters` now composes installed `encounter.table` callbacks; those +callbacks can have their own side effects. 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. @@ -75,8 +77,8 @@ 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 +space remain empty outcomes, just as in the native chooser. Zero-rate GB/Gold tables +return an empty distribution; GBA retains its native slot weights at zero chance. 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. @@ -91,10 +93,11 @@ 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. +`encounter.roll`, `encounter.species`, or `world.tod`. On Gen1–3 it now runs +[the public `encounter.table` preview transform](portable-encounter-table.md), +matching Gen1Recomp's preview-only producer. This hook does not transform live +rolls; adding a shared live producer remains separate work. API 3 uses the +reference's stricter option and indoor-alias rules described there. HGSS's position-dependent Safari Zone returns `nil, "position-dependent Safari encounters unavailable"`; its native resolver diff --git a/examples/portable_encounter_table/main.lua b/examples/portable_encounter_table/main.lua new file mode 100644 index 000000000..035f2e9aa --- /dev/null +++ b/examples/portable_encounter_table/main.lua @@ -0,0 +1,42 @@ +local mod = ... +local state = { mode = "pass", calls = 0 } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "encounter.table") then + stop = mod.hooks:wrap("encounter.table", function(next, dist, ctx) + state.calls = state.calls + 1 + state.preview, state.mapId, state.terrain = ctx.preview, ctx.mapId, ctx.terrain + state.opts = ctx.opts + if state.mode == "replace" then + return { MEW = 7 } + end + if state.mode == "empty" then + return {} + end + if state.mode == "nil" then + return nil + end + if state.mode == "scalar" then + return false + end + if state.mode == "mutate" then + for id in pairs(dist) do + dist[id] = nil + end + dist.MEW = 13 + end + local result = next(dist, ctx) + if state.mode == "post" then + result.MEW = 11 + end + return result + end) +end +mod.exports.preview = function(mapId, terrain, opts) + return mod.world:effectiveEncounters(mapId, terrain, opts) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_encounter_table/manifest.json b/examples/portable_encounter_table/manifest.json new file mode 100644 index 000000000..5c5702bee --- /dev/null +++ b/examples/portable_encounter_table/manifest.json @@ -0,0 +1 @@ +{"id":"portable_encounter_table","name":"Portable encounter previews","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","affects_link":false} diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index fda5cd0d0..d977a8418 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -88,6 +88,22 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if (not supportedGeneration and not (name == "liveMaps" and gen >= 1 and gen <= 5)) or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "effectiveEncounters" then + local mapId, terrain, opts = ... + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then return nil, "invalid map id" end + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then return nil, "invalid terrain" end + if opts ~= nil and type(opts) ~= "table" then return nil, "invalid encounter options" end + for key, value in pairs(opts or {}) do + if key ~= "daytime" or (value ~= "MORN" and value ~= "DAY" and value ~= "NITE" and value ~= "DARK") then + return nil, "invalid encounter option" + end + if gen ~= 2 then return nil, "unsupported encounter daytime" end + end + local game = current() + if not game then return nil, "no game" end + -- API 3 aliases indoor before native dispatch, as the reference facade does. + return native(game):effectiveEncounters(mapId, terrain == "indoor" and "grass" or terrain, opts) + end local game = current() if not game then return nil, "no game" diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 5b22343b4..58d6425da 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -35,6 +35,7 @@ add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") +add("hooks", "123", "encounter.table") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index b36712e45..031c564f5 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -19,7 +19,7 @@ C.hooks = names([[ battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update - encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + encounter.table encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build item.use link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["encounter.table"] = { "context", "context" }, ["item.use"] = { false, false, "itemId", false, "context", false, "context" }, ["world.follower.spawn"] = { false, false }, ["ui.party.grid_navigation"] = { false }, diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua index 491963d48..7049ffb5b 100644 --- a/src/mods/WorldOverview.lua +++ b/src/mods/WorldOverview.lua @@ -1,9 +1,22 @@ --- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, --- or object visibility refreshes here: each can change the live simulation. +-- Observational world summaries. The encounter.table preview callback is the +-- sole mod-dispatch seam here; do not roll, load maps or refresh visibility. local Ids = require("src.mods.UnifiedIds") local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") local Overview = {} +local function finish(chance, dist, mapId, terrain, opts) + -- The reference seam belongs to the explicit-map preview, not live rolls. + -- DS has no reference producer; retain its existing native preview. + if GameVersion.generation() <= 3 and Runtime.wantsHook("encounter.table") then + local ctx = { mapId = mapId, terrain = terrain, preview = true } + if GameVersion.isGen3() then ctx.opts = opts end + local transformed = Runtime.call("encounter.table", function(d) return d end, dist, ctx) + if type(transformed) == "table" then dist = transformed end + end + return { chance = chance, dist = dist } +end + function Overview.context(loader) Ids.new(loader):entries("pokemon") return function() @@ -41,7 +54,7 @@ local function dataIds(data) }) end -local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE", DARK = "NITE" } local hours = { MORNING = 6, DAY = 12, NITE = 22 } function Overview.encounters(game, ow, context, mapId, terrain, opts) @@ -104,7 +117,7 @@ function Overview.encounters(game, ow, context, mapId, terrain, opts) slots = Encounter.atTime(slots, daytime) end if not slots then - return { chance = 0, dist = {} } + return finish(0, {}, mapId, terrain, opts) end local maximum = tonumber(slots.rateMax) or 256 local rate = tonumber(slots.rate) or 0 @@ -138,7 +151,7 @@ function Overview.encounters(game, ow, context, mapId, terrain, opts) 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 + if (rate > 0 or GameVersion.isGen3()) 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 @@ -147,7 +160,7 @@ function Overview.encounters(game, ow, context, mapId, terrain, opts) 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 } + return finish(math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist, mapId, terrain, opts) end return Overview diff --git a/tests/encounter_table/cases.lua b/tests/encounter_table/cases.lua new file mode 100644 index 000000000..165cb5b10 --- /dev/null +++ b/tests/encounter_table/cases.lua @@ -0,0 +1,105 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function query(label, map, terrain, opts) + local out, why = + e.preview(map or "PROBE", terrain or "grass", opts or (f.generation == 2 and { daytime = "DAY" } or {})) + T.check(out ~= nil, why or "preview succeeds") + rows[#rows + 1] = { label = label, chance = out.chance, dist = out.dist } + return out + end + local before = require("src.link.Json").encode(f.game.save) + for _, terrain in + ipairs(f.generation == 2 and f.api < 3 and { "grass", "water" } or { "grass", "water", "indoor" }) + do + local calls = e.state.calls + local out = query("base:" .. terrain, nil, terrain) + T.eq(e.state.calls, calls + 1, "public query invokes preview seam once") + T.eq(e.state.preview, true, "context marks preview") + T.eq(e.state.mapId, "PROBE", "context has explicit queried map") + T.eq( + e.state.terrain, + f.api >= 3 and terrain == "indoor" and "grass" or terrain, + "context retains requested terrain" + ) + T.eq(out.chance, f.generation == 3 and 1 / 9 or 20 / 256, "independent native rate") + if f.generation == 3 then + T.eq(type(e.state.opts), "table", "GBA forwards query options") + else + T.eq(e.state.opts, nil, "GB/Gold context retains reference option boundary") + end + end + local baseline = query("baseline") + for _, mode in ipairs({ "replace", "empty", "mutate", "post", "nil", "scalar" }) do + e.state.mode = mode + local out = query(mode) + T.eq(out.chance, baseline.chance, "table transform cannot replace independent chance") + if mode == "replace" then + T.same(out.dist, { MEW = 7 }, "replacement distribution returned") + elseif mode == "empty" then + T.same(out.dist, {}, "empty distribution is accepted") + elseif mode == "mutate" then + T.same(out.dist, { MEW = 13 }, "mutated next arguments returned") + elseif mode == "post" then + T.eq(out.dist.MEW, 11, "downstream result can be transformed") + else + T.same(out.dist, baseline.dist, "non-table return falls back to detached distribution") + end + end + e.state.mode = "pass" + T.same(query("fresh").dist, baseline.dist, "prior callback writes cannot persist into native tables") + if f.generation == 2 then + local night = query("night", nil, "grass", { daytime = "NITE" }) + T.same(night.dist, { MEW = 100 }, "native Gold night table feeds hook") + T.eq(night.chance, 40 / 256, "native Gold night rate remains independent") + T.same(query("dark", nil, "grass", { daytime = "DARK" }), night, "Gold DARK is night") + end + query("empty map", "EMPTY") + T.eq(e.state.mapId, "EMPTY", "empty native table still invokes preview hook") + e.state.mode = "replace" + local empty = query("empty replace", "EMPTY") + T.eq(empty.chance, 0, "empty native map retains zero chance") + T.same(empty.dist, { MEW = 7 }, "empty native map can expose custom preview weights") + e.state.mode = "pass" + local rate = f.land.rate + f.setRate(0) + local zero = query("zero rate") + T.eq(zero.chance, 0, "zero native chance survives hook") + T.same( + zero.dist, + f.generation == 3 and baseline.dist or {}, + "zero-rate distribution follows native generation" + ) + f.setRate(rate) + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("encounter.table", function(next, ...) + if after then + next(...) + end + error("intentional preview wrapper failure") + end, 100) + local calls = e.state.calls + T.same(query("error:" .. tostring(after)), baseline, "wrapper error recovers native preview") + T.eq(e.state.calls, calls + 1, "error recovery invokes downstream once") + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("encounter.table", function(next, dist, ctx) + local a, b = next(dist, ctx), next(dist, ctx) + T.same(a, b, "repeated next retains distribution") + return a + end, 100) + local calls = e.state.calls + query("replay") + T.eq(e.state.calls, calls + 1, "API-3 repeated next dispatches downstream once") + off() + end + e.stop() + e.stop() + local calls = e.state.calls + T.same(query("unsubscribed"), baseline, "unsubscribe restores native preview") + T.eq(e.state.calls, calls, "unsubscribe retires callback") + T.eq(require("src.link.Json").encode(f.game.save), before, "pure consumer leaves save untouched") + return rows +end +return M diff --git a/tests/encounter_table/fixture.lua b/tests/encounter_table/fixture.lua new file mode 100644 index 000000000..5a663e071 --- /dev/null +++ b/tests/encounter_table/fixture.lua @@ -0,0 +1,188 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported native version: " .. tostring(version)) + Version.set(version) + local gen = Version.generation() + local data + if backend == "gen1" and gen == 3 then + data = T.sdk.gen3Data() + else + data = { + pokemon = {}, + moves = {}, + items = {}, + maps = {}, + encounters = {}, + trainers = {}, + audio = {}, + type_chart = { types = { NORMAL = {} }, matchups = {} }, + } + for _, id in ipairs({ "MEW", "PIDGEY", "CHARMANDER" }) do + local stats = { hp = 40, attack = 40, defense = 40, speed = 40, special = 40 } + local mon = { + id = id, + name = id, + dex = id == "MEW" and 151 or id == "PIDGEY" and 16 or 4, + baseStats = stats, + types = { "NORMAL" }, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + if gen > 1 then + stats.spatk, stats.spdef = 40, 40 + end + if backend == "gen1" and gen == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 40, 40 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + end + data.pokemon[id] = mon + end + if backend == "gen1" and gen == 2 then + data.pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + data.moves.POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = backend == "gen1" and gen == 2 and "EFFECT_NORMAL_HIT" or "NO_ADDITIONAL_EFFECT", + } + end + data.maps.PROBE, data.maps.EMPTY = + { id = "PROBE", width = 2, height = 2 }, { id = "EMPTY", width = 2, height = 2 } + local grassWeights = gen == 1 and { 51, 51, 39, 25, 25, 25, 13, 13, 9, 5 } + or gen == 2 and { 30, 30, 20, 10, 5, 4, 1 } + or { 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 } + local waterWeights = gen == 1 and grassWeights or gen == 2 and { 60, 30, 10 } or { 60, 30, 5, 4, 1 } + local function tableFor(weights, night) + local slots, buckets, total = {}, {}, 0 + for i, weight in ipairs(weights) do + local species = i % 2 == 0 and "CHARMANDER" or "PIDGEY" + if night then + species = "MEW" + end + slots[i] = { species = species, level = 5, minLevel = 5, maxLevel = 5 } + if backend == "gen1" and gen == 3 then + slots[i].species = species == "PIDGEY" and 16 or species == "CHARMANDER" and 4 or 151 + end + total = total + weight + buckets[i] = total + end + return { + rate = 20, + slots = slots, + buckets = buckets, + bucketSpan = total, + rateMax = gen == 3 and 180 or 256, + } + end + local land, water, night = tableFor(grassWeights), tableFor(waterWeights), tableFor(grassWeights, true) + if backend == "gen1" and gen == 2 then + data.gen2Maps = data.maps + data.encounters = { + grass = { + PROBE = { + rates = { MORN = 20, DAY = 20, NITE = 40 }, + slots = { MORN = land.slots, DAY = land.slots, NITE = night.slots }, + }, + }, + water = { PROBE = water }, + } + elseif backend == "gen1" and gen == 3 then + data.gen3Encounters = { PROBE = { land = land, water = water } } + else + data.encounters.PROBE = { grass = land, water = water } + if gen == 2 then + land.byTime = { morn = tableFor(grassWeights), day = tableFor(grassWeights), nite = night } + night.rate = 40 + end + end + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local f = assert(io.open(root .. "/examples/portable_encounter_table/" .. name, "rb")) + files["mods/portable_encounter_table/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/portable_encounter_table/manifest.json"]) + manifest.api, manifest.callback_format = api, format + -- The reference API-1/2 Gold loader requires its legacy compatibility flag. + manifest.gen2compat = api < 3 and true or nil + files["mods/portable_encounter_table/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_encounter_table/main.lua"] = files["mods/portable_encounter_table/main.lua"] + .. '\nerror("intentional encounter preview entry failure")' + end + local control = require("src.mods.Merge").deepCopy(manifest) + control.id, control.name = "preview_control", "Preview control" + files["mods/preview_control/manifest.json"] = Json.encode(control) + files["mods/preview_control/main.lua"] = "local m=...; m.exports.api=m" + if noMod then + files["mods/portable_encounter_table/manifest.json"], files["mods/portable_encounter_table/main.lua"] = + nil, nil + end + local result = T.sdk.loadMods( + noMod and { "mods/preview_control" } or { "mods/portable_encounter_table", "mods/preview_control" }, + { data = data, fs = T.sdk.memfs(files), dev = false, generation = gen, version = version } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = + { data = data, save = { party = {}, flags = {}, options = {}, swarmMaps = {} }, stack = { states = {} } } + result.loader.game = game + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + if backend == "gen1" and gen == 3 then + local E, P = require("src.core.game3.encounters"), require("src.core.game3.pokemon") + patch(E, "_tables", data.gen3Encounters) + patch(E, "_loaded", true) + patch(P, "_names", data.gen3Pokemon._names) + end + return { + T = T, + e = result.loader.exports.portable_encounter_table, + game = game, + result = result, + generation = gen, + backend = backend, + api = api, + land = land, + nativeApi = result.loader.exports.preview_control.api, + setRate = function(value) + if gen == 2 and backend == "gen1" then + data.encounters.grass.PROBE.rates.DAY = value + elseif backend == "gen1" and gen == 3 then + data.gen3Encounters.PROBE.land.rate = value + else + data.encounters.PROBE.grass.rate = value + end + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/encounter_table/worker.lua b/tests/encounter_table/worker.lua new file mode 100644 index 000000000..407e368b6 --- /dev/null +++ b/tests/encounter_table/worker.lua @@ -0,0 +1,40 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/encounter_table/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local old = math.random +local oldLove = love and love.math and love.math.random +math.random = function() + error("preview consumed RNG") +end +if oldLove then + love.math.random = math.random +end +local rows = dofile(root .. "/tests/encounter_table/cases.lua").run(f) +math.random = old +if oldLove then + love.math.random = oldLove +end +f.close() +-- Failed entry must remove the real public subscription. +for _, failing in ipairs({ false, true }) do + local control = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + control.T.check(#control.result.errors > 0, "entry error recorded") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["encounter.table"] or {}) do + control.T.check(link.owner ~= "portable_encounter_table", "no-mod/rollback has no owned callback") + end + local baseline = assert( + control.nativeApi.world:effectiveEncounters( + "PROBE", + "grass", + control.generation == 2 and { daytime = "DAY" } or {} + ) + ) + rows[#rows + 1] = + { label = failing and "rollback" or "no mod", chance = baseline.chance, dist = baseline.dist } + control.close() +end +print("ENCOUNTER_TABLE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native encounter previews") diff --git a/tests/engine/encounter_table_preview.lua b/tests/engine/encounter_table_preview.lua new file mode 100644 index 000000000..8ba86dc96 --- /dev/null +++ b/tests/engine/encounter_table_preview.lua @@ -0,0 +1,78 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture, Cases = dofile("tests/encounter_table/fixture.lua"), dofile("tests/encounter_table/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local api = f.e.api + T.eq(api:supports("hooks", "encounter.table"), f.generation <= 3, "honest native preview availability") + local beforeTables = require("src.link.Json").encode(f.game.data.encounters) + if f.generation <= 3 then + Cases.run(f) + local dispatched = 0 + local off = api.hooks:wrap("encounter.table", function() + dispatched = dispatched + 1 + return { MEW = 1 } + end) + if mode[1] >= 3 then + local calls = f.e.state.calls + for _, bad in ipairs({ { daytime = "night" }, { unknown = true } }) do + T.eq(f.e.preview("PROBE", "grass", bad), nil, "API-3 invalid options refused") + end + if f.generation ~= 2 then + T.eq(f.e.preview("PROBE", "grass", { daytime = "DAY" }), nil, "API-3 time selection is Gold-only") + end + T.eq(dispatched, 0, "invalid API-3 query cannot invoke active preview hook") + end + -- Ordinary native rolls never acquire the preview-only table callback. + f.e.state.mode = "replace" + local calls = f.e.state.calls + require("src.world.Encounter").load(f.game.data) + local def = f.game.data.encounters.PROBE + local result = require("src.world.Encounter").roll(def, function(lo) + return lo + end) + T.check(result ~= nil and result.species ~= "MEW", "native chooser retains actual cartridge slots") + T.eq(dispatched, 0, "native roll does not execute active preview seam") + off() + else + local calls = 0 + local off = api.hooks:wrap("encounter.table", function() + calls = calls + 1 + return { MEW = 1 } + end) + local out = require("src.world.WorldAPI").new(f.game, "probe"):effectiveEncounters("PROBE", "grass") + T.check(out.dist.PIDGEY ~= nil, "DS retains native distribution") + T.eq(calls, 0, "DS never dispatches unavailable preview seam") + off() + end + T.eq( + require("src.link.Json").encode(f.game.data.encounters), + beforeTables, + "query leaves native dataset unchanged" + ) + f.close() + if Version.generation() <= 3 then + local empty = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + local before = require("src.link.Json").encode(empty.game.save) + local out = assert( + empty.nativeApi.world:effectiveEncounters( + "PROBE", + "grass", + empty.generation == 2 and { daytime = "DAY" } or {} + ) + ) + T.check(out.dist.PIDGEY ~= nil, "no-mod native preview") + T.eq(require("src.link.Json").encode(empty.game.save), before, "no-mod query leaves save unchanged") + empty.close() + end + end +end +-- Unregistered profiles must not silently run Red fixtures and masquerade as GBA. +for _, version in ipairs({ "leafgreen", "ruby", "sapphire" }) do + T.eq(Version.VERSIONS[version], nil, "additional GBA ROM profile is not yet registered") +end +Version.set(old) +T.finish("native encounter.table preview and generation contracts") diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua index dab0d78bd..e57ba4c11 100644 --- a/tests/engine/world_overview_api.lua +++ b/tests/engine/world_overview_api.lua @@ -151,7 +151,7 @@ math.random = function() end Runtime.hooks = Hooks.new() local callbacks = 0 -for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do +for _, name in ipairs({ "encounter.roll", "encounter.species" }) do Runtime.hooks:wrap(name, function() callbacks = callbacks + 1 error("query executed encounter hook") diff --git a/tools/encounter-table-contract-check/check.py b/tools/encounter-table-contract-check/check.py new file mode 100644 index 000000000..6a45412ef --- /dev/null +++ b/tools/encounter-table-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Compare one public encounter-preview consumer through both engines.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/encounter_table/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("ENCOUNTER_TABLE_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("ENCOUNTER_TABLE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native effective-encounter preview", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"encounter preview mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native encounter-preview observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_encounter_table/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 44e746d68e76dd2a37d56079e485f7144efede2e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:10:42 +0200 Subject: [PATCH 37/86] Add native public charge-turn decisions and shared battle contracts --- .../workflows/charge-required-contract.yml | 44 +++ docs/api3-portability.md | 3 + docs/modding.md | 3 + docs/portable-charge-required.md | 79 ++++ examples/portable_charge_required/main.lua | 32 ++ .../portable_charge_required/manifest.json | 1 + src/battle/BattleState.lua | 11 +- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- tests/charge_required/cases.lua | 152 ++++++++ tests/charge_required/fixture.lua | 361 ++++++++++++++++++ tests/charge_required/worker.lua | 31 ++ tests/engine/charge_required_contract.lua | 72 ++++ tools/charge-required-contract-check/check.py | 61 +++ 14 files changed, 852 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/charge-required-contract.yml create mode 100644 docs/portable-charge-required.md create mode 100644 examples/portable_charge_required/main.lua create mode 100644 examples/portable_charge_required/manifest.json create mode 100644 tests/charge_required/cases.lua create mode 100644 tests/charge_required/fixture.lua create mode 100644 tests/charge_required/worker.lua create mode 100644 tests/engine/charge_required_contract.lua create mode 100644 tools/charge-required-contract-check/check.py diff --git a/.github/workflows/charge-required-contract.yml b/.github/workflows/charge-required-contract.yml new file mode 100644 index 000000000..948479389 --- /dev/null +++ b/.github/workflows/charge-required-contract.yml @@ -0,0 +1,44 @@ +name: portable charge-required contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: charge-required-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native battle charge decisions and compatibility + run: | + luajit tests/engine/charge_required_contract.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/charge-required-contract + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/charge-required-contract/api1-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/charge-required-contract/api2-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/charge-required-contract/api2-unified.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/charge-required-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: charge-required-contract + path: /tmp/charge-required-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index e599a99fe..4d42d36f6 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -154,3 +154,6 @@ GBA contract and the remaining Gold/DS boundaries. [Portable encounter previews](portable-encounter-table.md) document `encounter.table` and its preview-only scope, SDK modes and native boundaries. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. diff --git a/docs/modding.md b/docs/modding.md index 3acc0cdba..ff0b0e274 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -440,3 +440,6 @@ GBA contract and the remaining Gold/DS boundaries. [Portable encounter previews](portable-encounter-table.md) document `encounter.table` and its preview-only scope, SDK modes and native boundaries. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. diff --git a/docs/portable-charge-required.md b/docs/portable-charge-required.md new file mode 100644 index 000000000..a1c70ae95 --- /dev/null +++ b/docs/portable-charge-required.md @@ -0,0 +1,79 @@ +# Portable charge-turn decisions + +`battle.charge_required(next, ctx)` lets a mod skip the initial charge turn +of a native two-turn move. Gen1–3 hosts expose it; DS reports unavailable and +retains its current native move flow. + +```lua +local mod = ... +if mod:supports("hooks", "battle.charge_required") then + mod.hooks:wrap("battle.charge_required", function(next, ctx) + if myOptions.instantChargeMoves then return false end + return next(ctx) + end) +end +``` + +The context has `battle`, `user`, `target`, `move`, `charge = true` and +`isCalled`. `next(ctx)` returns `ctx.charge`. Only an explicit false skips the +charge; nil, zero and other values retain it, as in the reference engines. +Setting `ctx.charge = false` before `next(ctx)` is another way to bypass. + +The native pipeline invokes the hook only when it would otherwise enter a +first charge turn. Regular moves, releases and native sunny SolarBeam skips do +not invoke it. It cannot force a charge turn for an ordinary move or override +a native weather skip. Called moves set `isCalled = true` and keep their +existing caller-owned PP semantics. + +A bypass continues ordinary native damage/effects on the same turn. It stores +no charge continuation or semi-invulnerable state. GBA's queued move animation +uses the release branch (`moveTurn = 1`). A delegated charge retains native +continuation, invulnerability, charge text and animation; release clears that +state and does not spend a second player PP. No subscriber means the engine +does not call Runtime for this seam. Unsubscribe and failed-entry rollback +restore this path. Wrapper errors retain normal hook-bus recovery; repeated +API-3 `next` calls reuse the downstream verdict. + +API 3 and Gen2Recomped's API-2 unified mode expose common move/actor records. +Legacy native IDs and object layouts remain engine-specific; forward opaque +battle handles and use API 3 for portable field access. The producer does not +translate the whole battle implementation into the other engine's layout. + +## Validation and remaining native differences + +`examples/portable_charge_required` has no engine imports. The same entry is +loaded through both SDKs. The shared harness drives the actual GB +`BattleState:performMove`, Gold `Battle:useMove` and GBA `Engine.resolveMove` +paths in Gen1Recomp, and Gen2Recomped's native `BattleState:performMove`. +Fixtures supply native data/cache records; the hook producer, verdict, +charge state and damage pipeline are not mocked. + +Tests cover SolarBeam, Fly, Dig, Razor Wind, Skull Bash and Sky Attack, player +and enemy actions, release, caller PP, native sunlight, context/ID formats, +false versus nil/zero, errors, API-3 replay, unsubscribe, genuine no-mod cases +and failed-entry rollback. Native tests cover every registered host, SDK mode, +DS exclusion and the GBA release animation branch. + +This proves the tested public decision contract, not identical damage amounts, +text, all move side effects or every underlying battle rule. In particular, +Gen2Recomped currently leaves Gold's default battles on its Gen-1 ruleset: +enemy moves spend zero PP, while the reference Gold engine spends one. The +checker records each engine's `native_policy` costs separately and compares +whether charge/bypass preserve that cost. Enemy PP numbers are not counted +as equal observations. That existing rule difference needs a separate fix; +the new hook must not silently change native PP policy. Native secondary move +effects also remain host-owned, including Skull Bash's defense increase. + +```sh +luajit tests/engine/charge_required_contract.lua +python3 tools/charge-required-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/charge-required.json +``` + +The reference is pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Red/Blue/Yellow, +Gold/Silver/Crystal and FireRed/Emerald are compared in four SDK modes. +Gen1 API 2 has no unified opt-in; that run compares Gen2's adapter against +Gen1's native callback. Imported-data native smoke checks remain bounded; +they do not establish full-playthrough, whole-battle or pixel parity. diff --git a/examples/portable_charge_required/main.lua b/examples/portable_charge_required/main.lua new file mode 100644 index 000000000..ac7ea76c2 --- /dev/null +++ b/examples/portable_charge_required/main.lua @@ -0,0 +1,32 @@ +local mod = ... +local state = { calls = 0, mode = "native" } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "battle.charge_required") then + stop = mod.hooks:wrap("battle.charge_required", function(next, ctx) + state.calls = state.calls + 1 + state.charge, state.isCalled = ctx.charge, ctx.isCalled + state.move = ctx.move and ctx.move.id + state.battle, state.user, state.target = ctx.battle, ctx.user, ctx.target + if state.mode == "bypass" then + return false + end + if state.mode == "nil" then + return nil + end + if state.mode == "truthy" then + return 0 + end + if state.mode == "edit" then + ctx.charge = false + end + local required = next(ctx) + state.nextResult = required + return required + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_charge_required/manifest.json b/examples/portable_charge_required/manifest.json new file mode 100644 index 000000000..5ec52692b --- /dev/null +++ b/examples/portable_charge_required/manifest.json @@ -0,0 +1 @@ +{"id":"portable_charge_required","name":"Portable charge-turn control","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","affects_link":true} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 996a86d68..0942c5fe2 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8090,7 +8090,16 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- BattleCommand_SkipSunCharge (effect_commands.asm:6535): Solar Beam jumps -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. - if record and record.charge and not releasing and not skipCharge then + local chargeRequired = record and record.charge ~= nil and not releasing and not skipCharge + if chargeRequired and GameVersion.generation() <= 3 and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) return c.charge end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) + chargeRequired = required ~= false + if not chargeRequired then animContext.moveTurn = 1 end + end + if chargeRequired then local branchAware = GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 58d6425da..dc8869a8f 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -35,7 +35,7 @@ add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") -add("hooks", "123", "encounter.table") +add("hooks", "123", "encounter.table battle.charge_required") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 031c564f5..300fa8a2f 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation - battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.charge_required battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.table encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build item.use link.fingerprint map.palette movement.collision movement.speed @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.charge_required"] = { "context" }, ["encounter.table"] = { "context", "context" }, ["item.use"] = { false, false, "itemId", false, "context", false, "context" }, ["world.follower.spawn"] = { false, false }, diff --git a/tests/charge_required/cases.lua b/tests/charge_required/cases.lua new file mode 100644 index 000000000..b73d4cbff --- /dev/null +++ b/tests/charge_required/cases.lua @@ -0,0 +1,152 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, b, beforeHP, beforePP, calls) + local out = { + label = label, + damaged = b.hp() < beforeHP, + spent = beforePP - b.pp(), + charging = b.charging(), + hidden = b.hidden(), + calls = e.state.calls - calls, + } + rows[#rows + 1] = out + return out + end + for _, name in ipairs({ "SOLARBEAM", "FLY", "DIG", "RAZOR_WIND", "SKULL_BASH", "SKY_ATTACK" }) do + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh(name) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(name .. ":" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "initial charge-capable move invokes callback once") + T.eq(out.charging, not bypass, "public verdict controls native continuation") + T.eq(out.damaged, bypass, "bypass resolves native damage immediately") + T.eq(out.spent, 1, "initial move spends one PP") + T.eq(e.state.charge, true, "default charge context") + T.eq(e.state.isCalled, false, "ordinary initial use is not called") + T.check( + e.state.battle ~= nil and e.state.user ~= nil and e.state.target ~= nil, + "context has native battle and actors" + ) + local expected = f.backend == "gen2" + and f.gen == 3 + and f.api < 3 + and f.format == "native" + and f.ids[name] + or name + T.eq(e.state.move, expected, "native/canonical move ID follows selected callback format") + if not bypass then + local releaseHP, releasePP, releaseCalls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe(name .. ":release", b, releaseHP, releasePP, releaseCalls) + T.eq(out.charging, false, "release clears native continuation") + T.eq(out.hidden, false, "release clears native semi-invulnerability") + T.eq(out.damaged, true, "release resolves native damage") + T.eq(out.spent, 0, "release cannot spend a second PP") + T.eq(out.calls, 0, "release cannot invoke initial-charge callback") + else + T.eq(out.hidden, false, "bypass creates no semi-invulnerable state") + end + end + end + f.nativePolicy = {} + local ordinary = f.fresh("TACKLE", { enemy = true }) + local ordinaryPP = ordinary.pp() + ordinary.use() + f.nativePolicy.enemyNormalPPCost = ordinaryPP - ordinary.pp() + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh("FLY", { enemy = true }) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe("enemy:" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "enemy initial charge invokes shared seam") + T.eq(out.charging, not bypass, "enemy verdict controls continuation") + T.eq(out.damaged, bypass, "enemy bypass resolves native damage") + f.nativePolicy[bypass and "enemyBypassPPCost" or "enemyChargePPCost"] = out.spent + T.eq(out.spent, f.nativePolicy.enemyNormalPPCost, "charge decision retains native enemy PP policy") + out.nativePPPreserved, out.spent = out.spent == f.nativePolicy.enemyNormalPPCost, nil + end + for _, mode in ipairs({ "edit", "nil", "truthy" }) do + e.state.mode = mode + local b = f.fresh("SOLARBEAM") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(mode, b, hp, pp, calls) + T.eq(out.charging, mode ~= "edit", "only false bypasses charge") + T.eq(out.damaged, mode == "edit", "edited next verdict reaches native damage") + end + e.state.mode = "native" + local b = f.fresh("TACKLE") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + T.eq(observe("ordinary move", b, hp, pp, calls).calls, 0, "ordinary move bypasses charge seam") + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + local out = observe("called", b, hp, pp, calls) + T.eq(e.state.isCalled, true, "called-move context") + T.eq(out.spent, 0, "called-move PP semantics preserved") + T.eq(out.charging, true, "called charge move retains native continuation") + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + out = observe("called bypass", b, hp, pp, calls) + T.eq(out.spent, 0, "called bypass retains caller-owned PP") + T.eq(out.charging, false, "called bypass stores no continuation") + T.eq(out.damaged, true, "called bypass resolves ordinary damage") + e.state.mode = "native" + if f.gen > 1 then + b = f.fresh("SOLARBEAM", { sun = true }) + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("native sun", b, hp, pp, calls) + T.eq(out.calls, 0, "native sun bypass precedes public seam") + T.eq(out.charging, false, "native sun retains instant SolarBeam") + T.eq(out.damaged, true, "native sun damages immediately") + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.charge_required", function(next, ...) + if after then + next(...) + end + error("intentional charge wrapper failure") + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("failure:" .. tostring(after), b, hp, pp, calls) + T.eq(out.calls, 1, "wrapper failure executes downstream once") + T.eq(out.spent, 1, "wrapper failure spends exactly one PP") + T.eq(out.charging, true, "wrapper failure retains native verdict") + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("battle.charge_required", function(next, ctx) + local a, b = next(ctx), next(ctx) + T.eq(a, b, "replayed next retains charge decision") + return a + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("replay", b, hp, pp, calls) + T.eq(out.calls, 1, "API-3 replay invokes downstream once") + T.eq(out.spent, 1, "API-3 replay retains one native PP spend") + off() + end + e.stop() + e.stop() + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("unsubscribed", b, hp, pp, calls) + T.eq(out.calls, 0, "unsubscribe removes charge callback") + T.eq(out.charging, true, "unsubscribe restores native charge") + return rows +end +return M diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua new file mode 100644 index 000000000..ac9625abe --- /dev/null +++ b/tests/charge_required/fixture.lua @@ -0,0 +1,361 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local V = require("src.core.GameVersion") + assert(V.VERSIONS[version], "unregistered version") + V.set(version) + local gen = V.generation() + local rows = { + { "SOLARBEAM", 76, 120, "GRASS", "CHARGE_EFFECT", "EFFECT_SOLARBEAM", 151 }, + { "FLY", 19, 70, "FLYING", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "DIG", 91, 60, "GROUND", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "RAZOR_WIND", 13, 80, "NORMAL", "CHARGE_EFFECT", "EFFECT_RAZOR_WIND", 39 }, + { "SKULL_BASH", 130, 100, "NORMAL", "CHARGE_EFFECT", "EFFECT_SKULL_BASH", 145 }, + { "SKY_ATTACK", 143, 140, "FLYING", "CHARGE_EFFECT", "EFFECT_SKY_ATTACK", 75 }, + { "TACKLE", 33, 35, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, + } + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + if backend == "gen1" and gen == 2 then + data = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + }, + CHARMANDER = { + id = "CHARMANDER", + index = 4, + dex = 4, + name = "CHARMANDER", + types = { "NORMAL", "NORMAL" }, + baseStats = { + hp = 80, + attack = 50, + defense = 50, + speed = 50, + specialAttack = 50, + specialDefense = 50, + }, + catchRate = 45, + baseExp = 64, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = 127, + levelMoves = {}, + evolutions = {}, + }, + }, + moves = {}, + items = {}, + maps = {}, + encounters = {}, + audio = {}, + text = require("tests.gen2_battle_text_fixture")(), + type_chart = { types = {}, matchups = {}, battleRules = require("tests.gen2_battle_rules_fixture")() }, + } + for name, index in pairs({ NORMAL = 0, FLYING = 2, GROUND = 4, GRASS = 22 }) do + data.type_chart.types[name] = + { id = name, index = index, category = name == "GRASS" and "special" or "physical" } + end + elseif not (backend == "gen1" and gen == 3) then + local mon = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + mon.id, mon.name, mon.dex, mon.index, mon.evolutions = "CHARMANDER", "CHARMANDER", 4, 4, {} + mon.types = { "NORMAL" } + data.pokemon.CHARMANDER = mon + end + local ids, rom, names = {}, {}, {} + for _, row in ipairs(rows) do + local name, number, power, kind, gbEffect, g2Effect, g3Effect = unpack(row) + local id = backend == "gen2" and gen == 3 and ("MOVE_%03d"):format(number) or name + ids[name], names[number] = id, name + local effect = gen > 1 and name == "SOLARBEAM" and "SOLARBEAM_EFFECT" or gbEffect + if backend == "gen1" and gen == 2 then + effect = g2Effect + end + if backend == "gen1" and gen == 3 then + data.gen3Moves._rom[number] = { + effect = g3Effect, + power = power, + type = ({ NORMAL = 0, FLYING = 2, GROUND = 4, GRASS = 12 })[kind], + accuracy = 100, + pp = 10, + secondaryChance = 0, + target = 0, + priority = 0, + flags = 51, + } + data.gen3Pokemon._moveNames[number] = name + rom[number] = data.gen3Moves._rom[number] + else + data.moves[id] = { + id = id, + index = number, + name = name, + power = power, + type = kind, + accuracy = 100, + accuracyRaw = 255, + pp = 10, + effect = effect, + effectChance = name == "SKY_ATTACK" and 30 or 0, + effectChanceRaw = name == "SKY_ATTACK" and 77 or 0, + } + end + end + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_charge_required/" .. name, "rb")) + files["mods/portable_charge_required/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/portable_charge_required/manifest.json"]) + manifest.api, manifest.callback_format, manifest.gen2compat = api, format, api < 3 and true or nil + files["mods/portable_charge_required/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_charge_required/main.lua"] = files["mods/portable_charge_required/main.lua"] + .. '\nerror("intentional charge entry failure")' + end + if noMod then + files = {} + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/portable_charge_required" }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + party = {}, + options = {}, + player = { name = "PROBE" }, + flags = {}, + inventory = {}, + pokedex = { seen = {}, caught = {} }, + defeatedTrainers = {}, + }, + stack = setmetatable({}, { __index = require("src.core.StateStack") }), + input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + }, + } + game.stack:init() + result.loader.game = game + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + local P = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + patch(P, key, value) + end + patch(P, "_romMoveNames", names) + patch(P, "_battleMoves", rom) + local Moves = require("src.core.game3.battle.moves") + patch(Moves, "_rom", rom) + patch(Moves, "_romLoaded", true) + patch(Moves, "BY_NUM", names) + local byName = {} + for number, name in pairs(names) do + byName[name] = number + end + patch(Moves, "_numByName", byName) + patch( + require("src.core.game3.scripting.space"), + "bundle", + { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + } + ) + elseif not (backend == "gen1" and gen == 2) then + require("src.render.Font").load(data) + require("src.battle.TypeChart").load(data) + end + local function fresh(name, opts) + opts = opts or {} + local id = ids[name] + local b, user, target, move, use, hp, pp, charging, hidden + if backend == "gen1" and gen == 3 then + local State, Adapter = require("src.core.game3.battle.state"), require("src.core.game3.battle.adapter") + local n = data.gen3Pokemon._moveNames + local number + for k, v in pairs(n) do + if v == name then + number = k + break + end + end + local function mon() + return { + species = 4, + nickname = "PROBE", + level = 30, + hp = 400, + maxHp = 400, + attack = 50, + defense = 50, + speed = 50, + spAtk = 50, + spDef = 50, + ability = 0, + moves = { number }, + pp = { 10 }, + maxPp = { 10 }, + item = 0, + } + end + b = State.new({ + wild = true, + playerParty = { mon() }, + foeMon = mon(), + weather = opts.sun and "SUN" or nil, + }) + b.rng = function(lo) + return lo or 0 + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + local ad = Adapter.new(b) + use = function(called) + local slot = 1 + if called then + slot = nil + end + return require("src.core.game3.battle.engine").resolveMove( + user, + target, + number, + slot, + ad, + b, + {}, + { called = called } + ) + end + hp = function() + return ad:hp(target) + end + pp = function() + return user.mon.pp[1] + end + charging = function() + return user.twoTurnMove ~= nil + end + hidden = function() + return user.semiInvulnerable ~= nil + end + elseif backend == "gen1" and gen == 2 then + local Mon = require("src.battle.gen2.Mon") + local p, e = Mon.new(data, "CHARMANDER", 30), Mon.new(data, "CHARMANDER", 30) + p.hp, p.stats.hp, e.hp, e.stats.hp = 400, 400, 400, 400 + p.moves, e.moves = { { id = id, pp = 10 } }, { { id = id, pp = 10 } } + b = require("src.battle.gen2.Battle").new({ + data = data, + party = { p }, + wild = e, + random = function(n) + return math.max(0, (n or 1) - 1) + end, + }) + b.weather = opts.sun and "sun" or nil + user, target = opts.enemy and e or p, opts.enemy and p or e + move = user.moves[1] + use = function(called) + b.copyDepth = called and 1 or 0 + return b:useMove(user, target, id) + end + hp = function() + return target.hp + end + pp = function() + return move.pp + end + charging = function() + return user.volatile.chargeMove ~= nil + end + hidden = function() + return user.volatile.vanished == true + end + else + local P = require("src.pokemon.Pokemon") + local p = P.new(data, "CHARMANDER", 30, function(lo) + return lo or 0 + end) + p.hp, p.stats.hp, p.moves = 400, 400, { { id = id, pp = 10 } } + game.save.party = { p } + b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b.player.mon.hp, b.player.mon.stats.hp, b.enemy.mon.hp, b.enemy.mon.stats.hp = 400, 400, 400, 400 + b.rng = function(lo) + return lo or 0 + end + if opts.sun then + b.field.weather = "SUN" + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + move = { id = id, pp = 10 } + user.mon.moves, user.curMoves = { move }, { move } + use = function(called) + return b:performMove(user, target, move, called) + end + hp = function() + return target.mon.hp + end + pp = function() + return move.pp + end + charging = function() + return user.charging ~= nil + end + hidden = function() + return user.invulnerable == true + end + end + return { + battle = b, + user = user, + target = target, + move = move, + use = use, + hp = hp, + pp = pp, + charging = charging, + hidden = hidden, + } + end + return { + T = T, + e = result.loader.exports.portable_charge_required, + result = result, + data = data, + game = game, + gen = gen, + backend = backend, + api = api, + format = format, + ids = ids, + fresh = fresh, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/charge_required/worker.lua b/tests/charge_required/worker.lua new file mode 100644 index 000000000..f98af650c --- /dev/null +++ b/tests/charge_required/worker.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/charge_required/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/charge_required/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local native = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + native.T.check(#native.result.errors > 0, "failed entry recorded") + else + native.T.eq(native.result.loader.exports.portable_charge_required, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.charge_required"] or {}) do + native.T.check(link.owner ~= "portable_charge_required", "no-mod/rollback removes owner") + end + local b = native.fresh("FLY") + local pp, hp = b.pp(), b.hp() + b.use() + native.T.eq(b.charging(), true, "no-mod/rollback native charge") + rows[#rows + 1] = { + label = failing and "rollback" or "no mod", + charging = b.charging(), + spent = pp - b.pp(), + damaged = b.hp() < hp, + } + native.close() +end +print("CHARGE_NATIVE_POLICY " .. require("src.link.Json").encode(f.nativePolicy)) +print("CHARGE_REQUIRED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native charge decisions") diff --git a/tests/engine/charge_required_contract.lua b/tests/engine/charge_required_contract.lua new file mode 100644 index 000000000..ce3509cec --- /dev/null +++ b/tests/engine/charge_required_contract.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/charge_required/fixture.lua"), dofile("tests/charge_required/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq( + f.e.api:supports("hooks", "battle.charge_required"), + f.gen <= 3, + "native charge capability is honest" + ) + if f.gen <= 3 then + Cases.run(f) + local off = f.e.api.hooks:wrap("battle.charge_required", function() + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(b.charging(), false, "fresh public bypass works after consumer unsubscribe") + if f.gen == 3 then + local seen = false + for _, row in ipairs(b.battle.queue) do + if row.anim == f.ids.FLY then + seen = true + T.eq(row.animContext.moveTurn, 1, "GBA bypass uses native release animation branch") + end + end + T.eq(seen, true, "GBA bypass retains ordinary move animation") + end + off() + else + local calls = 0 + local off = f.e.api.hooks:wrap("battle.charge_required", function() + calls = calls + 1 + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(calls, 0, "DS does not dispatch unavailable seam") + T.eq(b.charging(), true, "DS native charge flow is preserved") + off() + end + f.close() + for _, failing in ipairs({ false, true }) do + local baseline = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#baseline.result.errors > 0, "entry failure is recorded") + else + T.eq(baseline.result.loader.exports.portable_charge_required, nil, "no-mod consumer is absent") + end + for _, link in ipairs(Runtime.hooks.chains["battle.charge_required"] or {}) do + T.check(link.owner ~= "portable_charge_required", "entry rollback removes owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.charge_required", "unsubscribed hot path invoked charge producer") + return call(name, ...) + end + local b = baseline.fresh("FLY") + local pp = b.pp() + b.use() + Runtime.call = call + T.eq(b.charging(), true, "no-mod/rollback restores native continuation") + T.eq(pp - b.pp(), 1, "no-mod/rollback spends native player PP once") + baseline.close() + end + end +end +Version.set(old) +T.finish("native charge-required decisions and public contract") diff --git a/tools/charge-required-contract-check/check.py b/tools/charge-required-contract-check/check.py new file mode 100644 index 000000000..b23c0f304 --- /dev/null +++ b/tools/charge-required-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare one public charge-turn consumer through both native battle engines.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/charge_required/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("CHARGE_REQUIRED_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_REQUIRED_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native battle charge decision", + "observations": rows, "native_policy": json.loads(next( + line[len("CHARGE_NATIVE_POLICY "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_NATIVE_POLICY "))), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"charge decision mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native charge observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_charge_required/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 733d10ebe37aa4eaaad7f514dd2182d74e0636a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:38 +0200 Subject: [PATCH 38/86] Use automatic native rulesets and track Gen-2 enemy PP --- .github/workflows/enemy-pp-contract.yml | 47 ++++++ docs/api3-portability.md | 4 + docs/native-ruleset-defaults.md | 69 ++++++++ docs/portable-charge-required.md | 17 +- examples/portable_enemy_pp/main.lua | 19 +++ examples/portable_enemy_pp/manifest.json | 8 + src/battle/BattleState.lua | 19 +-- src/battle/RulesetDefaults.lua | 18 +- src/battle/TrainerAI.lua | 4 +- src/battle/rulesets/gen2_finite_pp.lua | 9 + src/core/SaveData.lua | 4 +- src/mods/Builtins.lua | 1 + src/ui/OptionsMenu.lua | 15 +- tests/charge_required/fixture.lua | 17 +- tests/drivers/enemy_pp_defaults.lua | 99 +++++++++++ tests/enemy_pp/cases.lua | 111 ++++++++++++ tests/enemy_pp/worker.lua | 8 + tests/engine/enemy_pp_defaults.lua | 158 ++++++++++++++++++ tests/mod_battle_tests.lua | 20 ++- tests/save_convert_tests.lua | 2 +- tools/charge-required-contract-check/check.py | 7 +- tools/enemy-pp-contract-check/check.py | 52 ++++++ 22 files changed, 657 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/enemy-pp-contract.yml create mode 100644 docs/native-ruleset-defaults.md create mode 100644 examples/portable_enemy_pp/main.lua create mode 100644 examples/portable_enemy_pp/manifest.json create mode 100644 src/battle/rulesets/gen2_finite_pp.lua create mode 100644 tests/drivers/enemy_pp_defaults.lua create mode 100644 tests/enemy_pp/cases.lua create mode 100644 tests/enemy_pp/worker.lua create mode 100644 tests/engine/enemy_pp_defaults.lua create mode 100644 tools/enemy-pp-contract-check/check.py diff --git a/.github/workflows/enemy-pp-contract.yml b/.github/workflows/enemy-pp-contract.yml new file mode 100644 index 000000000..2ea900ddd --- /dev/null +++ b/.github/workflows/enemy-pp-contract.yml @@ -0,0 +1,47 @@ +name: native enemy PP contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: enemy-pp-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native defaults, AI fallback and compatibility + run: | + luajit tests/engine/enemy_pp_defaults.lua + luajit tests/engine/charge_required_contract.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare raw PP policy in every SDK mode and the API-3 observer + run: | + mkdir -p /tmp/enemy-pp-contract + python3 tools/enemy-pp-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/enemy-pp-contract/observer.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 --require-native-policy-parity \ + --api 1 --format native --report /tmp/enemy-pp-contract/api1-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 --require-native-policy-parity \ + --api 2 --format native --report /tmp/enemy-pp-contract/api2-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 --require-native-policy-parity \ + --api 2 --format unified --report /tmp/enemy-pp-contract/api2-unified.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 --require-native-policy-parity \ + --api 3 --format unified --report /tmp/enemy-pp-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: enemy-pp-contract + path: /tmp/enemy-pp-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4d42d36f6..8d45dfdf8 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -157,3 +157,7 @@ GBA contract and the remaining Gold/DS boundaries. [Portable charge decisions](portable-charge-required.md) document `battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. + +[Automatic native rulesets](native-ruleset-defaults.md) document enemy PP +accounting and the existing-settings boundary. The read-only +`examples/portable_enemy_pp` consumer runs unchanged on both engines. diff --git a/docs/native-ruleset-defaults.md b/docs/native-ruleset-defaults.md new file mode 100644 index 000000000..d094d1a24 --- /dev/null +++ b/docs/native-ruleset-defaults.md @@ -0,0 +1,69 @@ +# Automatic rulesets and native enemy PP + +New settings select `options.ruleset = "auto"`. The shared RULESET option row +shows AUTO first, followed by sorted non-hidden registered rulesets, and can +return to AUTO from an explicit choice. AUTO is a reserved preference value, +not a ruleset registry id. Missing old preferences have the same meaning. + +Automatic selection honors `data.constants.defaultRuleset` before the existing +generation fallback. Gen 1 retains `gen1_faithful`; Gen 2 uses `gen2_finite_pp`; +Gen 3/4 use their existing `gen3_emerald`/`gen4_platinum` defaults. The same +fallback records are present without a mod loader. BW retains its existing +native attach-time override; this change does not redesign Gen-5 rules. + +Previously `SaveData.defaultOptions()` inserted `gen1_faithful` into every new +profile, hiding the generation fallback even when the correct later-generation +ruleset was registered. AUTO fixes new profiles and can be stored as a +generation override even when the shared preference names an explicit ruleset. +Options save/reload preserves that override and the other generations' choices. + +## Existing settings and mods + +Every explicit saved ruleset id remains explicit, including `gen1_faithful`, +`modern_clean` and mod-defined ids. Old files cannot reveal whether a literal +Gen-1 id was intentional or copied from the previous default; they are not +silently migrated. Select RULESET -> AUTO to opt an existing installation into +the new defaults, or select `gen2_finite_pp` explicitly. The next constructed +battle uses the preference; an already running battle keeps its chosen rules. +An unknown saved id retains the existing logged fallback to the game's default. + +Mod-defined default ids and registered ruleset overrides continue through the +ordinary content registry. No separate per-mod PP switch is added. + +## Bounded Gen-2 correction + +`gen2_finite_pp` copies the previous host's Gen-1 rule fields and changes only +its name and `enemyUnlimitedPP = false`. This fixes the measured Gold-family +reference/host discrepancy without claiming complete Gen-2 mechanics. Critical +hits, damage, status behavior, Hyper Beam recharge and other rule differences +remain separate work. Gen-1's original built-in record is not mutated. + +The existing native move pipeline now spends enemy PP and the existing AI +rejects depleted/disabled slots and chooses Struggle when none remain. A release +of a charged move remains locked even when its first turn spent the final PP; +it spends no additional PP. Called moves retain caller-owned PP accounting. +Unsubscribing or failing a mod entry preserves the native behavior. + +## Reproduce + +```sh +luajit tests/engine/enemy_pp_defaults.lua +python3 tools/enemy-pp-contract-check/check.py --gen1 /path/to/clean/gen1 +python3 tools/charge-required-contract-check/check.py --gen1 /path/to/clean/gen1 \ + --api 3 --format unified --require-native-policy-parity +``` + +The external Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The unchanged public consumer in +`examples/portable_enemy_pp` forwards native enemy choices and reads current PP +through `mod.battle:snapshot()` on both engines. The Gold/Silver/Crystal harness +uses actual native move execution, AI selection and Struggle execution with +native-shaped data/session fixtures; the snapshot uses a fixture screen on the +reference side. It does not claim a full cartridge playthrough or identical UI. + +The charge checker separately compares Red/Blue/Yellow, Gold/Silver/Crystal and +FireRed/Emerald in API 1 native, API 2 native/unified and API 3. Strict native PP +cost equality is required by the new enemy-PP workflow, rather than treating the +former Gold difference as an equivalent observation. Unit checks additionally +cover registered host defaults, no-loader fallbacks, explicit settings, +per-generation persistence, real option-row cycling, no-mod and entry rollback. diff --git a/docs/portable-charge-required.md b/docs/portable-charge-required.md index a1c70ae95..0fcb88538 100644 --- a/docs/portable-charge-required.md +++ b/docs/portable-charge-required.md @@ -55,14 +55,15 @@ and failed-entry rollback. Native tests cover every registered host, SDK mode, DS exclusion and the GBA release animation branch. This proves the tested public decision contract, not identical damage amounts, -text, all move side effects or every underlying battle rule. In particular, -Gen2Recomped currently leaves Gold's default battles on its Gen-1 ruleset: -enemy moves spend zero PP, while the reference Gold engine spends one. The -checker records each engine's `native_policy` costs separately and compares -whether charge/bypass preserve that cost. Enemy PP numbers are not counted -as equal observations. That existing rule difference needs a separate fix; -the new hook must not silently change native PP policy. Native secondary move -effects also remain host-owned, including Skull Bash's defense increase. +text, all move side effects or every underlying battle rule. With the automatic +ruleset selection, Gold/Silver/Crystal now use `gen2_finite_pp`: ordinary enemy +moves spend one PP, as in the reference. Explicit saved `gen1_faithful` still +keeps unlimited enemy PP. The checker records raw `native_policy` costs and +`--require-native-policy-parity` compares them exactly; the enemy-PP workflow +requires that stricter mode in all four SDK configurations. Secondary move +effects remain host-owned, including Skull Bash's defense increase. See +[automatic rulesets and enemy PP](native-ruleset-defaults.md) for the bounded +correction and existing-settings behavior. ```sh luajit tests/engine/charge_required_contract.lua diff --git a/examples/portable_enemy_pp/main.lua b/examples/portable_enemy_pp/main.lua new file mode 100644 index 000000000..345aa9bbd --- /dev/null +++ b/examples/portable_enemy_pp/main.lua @@ -0,0 +1,19 @@ +local mod = ... +local battle = mod.battle +local choices = 0 +local stop = mod.hooks:wrap("battle.enemy_action", function(next, ...) + choices = choices + 1 + return next(...) +end) +mod.exports.read = function() + local snapshot, reason = battle:snapshot() + if not snapshot then + return nil, reason + end + local moves = {} + for i, move in ipairs(snapshot.enemy.moves or {}) do + moves[i] = { id = move.id, pp = move.pp } + end + return { moves = moves, choices = choices } +end +mod.exports.stop = stop diff --git a/examples/portable_enemy_pp/manifest.json b/examples/portable_enemy_pp/manifest.json new file mode 100644 index 000000000..71d97b6a6 --- /dev/null +++ b/examples/portable_enemy_pp/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_enemy_pp", + "name": "Portable Enemy PP Observer", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0942c5fe2..a6a50a845 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -609,7 +609,10 @@ end local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), modern_clean = require("src.battle.rulesets.modern_clean"), + gen3_emerald = require("src.battle.rulesets.gen3_emerald"), + gen4_platinum = require("src.battle.rulesets.gen4_platinum"), } -- the Poké Ball toss chain (TossBallAnimation) plays even with battle @@ -1192,22 +1195,16 @@ local function newBattle(game) -- records on a mod-free boot); an unknown save value falls back to the -- default with a notice instead of silently switching behavior local rulesets = game.data.rulesets or Rulesets - local selected = game.save.options and game.save.options.ruleset - -- THE FALLBACK IS THE CARTRIDGE'S, not Kanto's. It used to read - -- `constants.defaultRuleset or "gen1_faithful"`, and nothing has ever - -- written that constant -- so Hoenn fought under Generation One's rules on - -- every fresh save while `gen3_emerald.lua` sat beside it, registered and - -- unreferenced. See `src/battle/RulesetDefaults.lua` for the measurement - -- and for why Gen 2 is deliberately left where it was. - local fallback = require("src.battle.RulesetDefaults").defaultFor(game) + local Defaults = require("src.battle.RulesetDefaults") + local selected = Defaults.selection(game) + local fallback = Defaults.defaultFor(game) local ruleset = selected and rulesets[selected] if selected and not ruleset then Logger.warn("unknown ruleset %s; using %s", tostring(selected), fallback) end self.ruleset = ruleset or rulesets[fallback] or Rulesets.gen1_faithful - -- BLACK AND WHITE: a new save's options carry "gen1_faithful" as a default, - -- not as anybody's choice, and Kanto's rules on a BW cartridge freeze for - -- good and sleep for seven turns -- the cartridge's own rules instead + -- Preserve the existing BW compatibility override for legacy Gen-1 values. + -- BW installs its own ruleset at attach time. if self.bw and self.ruleset and self.ruleset.name == "gen1_faithful" and rulesets.gen5_bw then self.ruleset = rulesets.gen5_bw diff --git a/src/battle/RulesetDefaults.lua b/src/battle/RulesetDefaults.lua index 1850ce880..f725afbd3 100644 --- a/src/battle/RulesetDefaults.lua +++ b/src/battle/RulesetDefaults.lua @@ -51,18 +51,26 @@ local RulesetDefaults = {} -- is not. Everything else matched. A player on the old default was therefore -- playing something very close to right, and wrong in every double battle. -- --- GEN 2 IS STILL ON GEN 1'S, and stays there until somebody does to Johto --- what this change did to Sinnoh. There is no Gen 2 ruleset in this repo, and --- handing Johto one written for another region would trade a known wrong --- answer for an unknown one. +-- Gen 2's compatibility default now tracks enemy PP, matching the reference +-- Gold battle loop. It otherwise retains the host's previous rule fields; +-- gen2_finite_pp is not a claim of complete Gen-2 battle-rule parity. RulesetDefaults.DEFAULT_BY_GENERATION = { [1] = "gen1_faithful", - [2] = "gen1_faithful", + [2] = "gen2_finite_pp", [3] = "gen3_emerald", [4] = "gen4_platinum", } RulesetDefaults.FALLBACK = "gen1_faithful" +RulesetDefaults.AUTO = "auto" + +-- AUTO is an option value, not a content registry id. Missing old preferences +-- mean automatic too; a saved explicit id is never migrated to another one. +function RulesetDefaults.selection(game) + local selected = game and game.save and game.save.options and game.save.options.ruleset + if selected == RulesetDefaults.AUTO then return nil end + return selected +end -- The id this cartridge should use when the save has no choice of its own. function RulesetDefaults.defaultFor(game) diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 4247a725a..bb36fef5d 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -332,8 +332,8 @@ end function TrainerAI.chooseMove(battler, rng, battle) rng = rng or love.math.random -- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when - -- every move slot is missing/disabled (core.asm:2957-2999). modern_clean - -- depletes enemy PP and falls back to Struggle when none remain. + -- every move slot is missing/disabled (core.asm:2957-2999). Finite-PP + -- rulesets (including the Gen-2 default) fall back to Struggle when empty. local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP local usable = {} for i, mv in ipairs(battler.curMoves) do diff --git a/src/battle/rulesets/gen2_finite_pp.lua b/src/battle/rulesets/gen2_finite_pp.lua new file mode 100644 index 000000000..78325ee61 --- /dev/null +++ b/src/battle/rulesets/gen2_finite_pp.lua @@ -0,0 +1,9 @@ +-- Current Gen2Recomped Gen-2 rules with finite enemy PP. This is deliberately +-- not named faithful: other differences from the reference Gold engine remain. +local out = {} +for key, value in pairs(require("src.battle.rulesets.gen1_faithful")) do + out[key] = value +end +out.name = "gen2_finite_pp" +out.enemyUnlimitedPP = false +return out diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index ffa666151..b52a04e57 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -445,7 +445,9 @@ function SaveData.defaultOptions() -- moves the screen furniture, so it is opt-in. -- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale. uiLayout = "centered", - ruleset = "gen1_faithful", + -- AUTO follows the game's default; explicit persisted ids stay selected. + -- This must be a value so per-generation overrides can return to AUTO. + ruleset = "auto", -- 0-7 like the GB's NR50 master volume musicVol = 7, sfxVol = 7, diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 470f67ad7..1bde4bf0c 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -32,6 +32,7 @@ local REGISTRANTS = { { name = "tokens", from = "src.render.TextBox" }, -- plain data files with no owning module: registered from here { name = "rulesets", modules = { "src.battle.rulesets.gen1_faithful", + "src.battle.rulesets.gen2_finite_pp", "src.battle.rulesets.modern_clean", "src.battle.rulesets.gen3_emerald", "src.battle.rulesets.gen4_platinum" }, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..d8d49dc96 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -42,10 +42,13 @@ end -- TextSpeedOptionData frame delays with the original labels local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } --- no-loader fallback for the ruleset row, same pair BattleState keeps +-- no-loader fallback for the ruleset row, same records BattleState keeps local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), modern_clean = require("src.battle.rulesets.modern_clean"), + gen3_emerald = require("src.battle.rulesets.gen3_emerald"), + gen4_platinum = require("src.battle.rulesets.gen4_platinum"), } local FILTERS = { "OFF", "1X", "2X", "3X" } @@ -65,18 +68,15 @@ local function rulesetIds(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = {} for id, record in pairs(rulesets) do - if not record.hidden then ids[#ids + 1] = id end + if not record.hidden and id ~= "auto" then ids[#ids + 1] = id end end table.sort(ids) + table.insert(ids, 1, "auto") return ids end local function rulesetIndex(game, ids) - -- The same fallback the battle uses, from the same place. When these two - -- disagreed the row showed GEN 1 FAITHFUL on a cartridge that was about to - -- fight under it -- which was true, and was the bug rather than the display. - local cur = game.save.options.ruleset - or require("src.battle.RulesetDefaults").defaultFor(game) + local cur = require("src.battle.RulesetDefaults").selection(game) or "auto" for i, id in ipairs(ids) do if id == cur then return i end end @@ -87,6 +87,7 @@ local function rulesetName(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = rulesetIds(game) local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset + if id == "auto" then return Strings("AUTO") end local record = id and rulesets[id] return Strings(record and record.name or id or "----") end diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua index ac9625abe..8f2aac378 100644 --- a/tests/charge_required/fixture.lua +++ b/tests/charge_required/fixture.lua @@ -1,5 +1,6 @@ local M = {} -function M.new(root, backend, version, api, format, noMod, failing) +function M.new(root, backend, version, api, format, noMod, failing, consumer) + consumer = consumer or "portable_charge_required" local T = require("tests.modkit") local V = require("src.core.GameVersion") assert(V.VERSIONS[version], "unregistered version") @@ -102,22 +103,22 @@ function M.new(root, backend, version, api, format, noMod, failing) end local files, Json = {}, require("src.link.Json") for _, name in ipairs({ "main.lua", "manifest.json" }) do - local f = assert(io.open(root .. "/examples/portable_charge_required/" .. name, "rb")) - files["mods/portable_charge_required/" .. name] = f:read("*a") + local f = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = f:read("*a") f:close() end - local manifest = Json.decode(files["mods/portable_charge_required/manifest.json"]) + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) manifest.api, manifest.callback_format, manifest.gen2compat = api, format, api < 3 and true or nil - files["mods/portable_charge_required/manifest.json"] = Json.encode(manifest) + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) if failing then - files["mods/portable_charge_required/main.lua"] = files["mods/portable_charge_required/main.lua"] + files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] .. '\nerror("intentional charge entry failure")' end if noMod then files = {} end local result = T.sdk.loadMods( - noMod and {} or { "mods/portable_charge_required" }, + noMod and {} or { "mods/" .. consumer }, { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } ) if not failing then @@ -340,7 +341,7 @@ function M.new(root, backend, version, api, format, noMod, failing) end return { T = T, - e = result.loader.exports.portable_charge_required, + e = result.loader.exports[consumer], result = result, data = data, game = game, diff --git a/tests/drivers/enemy_pp_defaults.lua b/tests/drivers/enemy_pp_defaults.lua new file mode 100644 index 000000000..ef8e2a9bb --- /dev/null +++ b/tests/drivers/enemy_pp_defaults.lua @@ -0,0 +1,99 @@ +-- Bounded imported-data battle input/queue check. Use an isolated identity. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable legacy migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_enemy_pp, "install PP observer example") + local Pokemon, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + break + end + end + assert(species and tackle, "native imported metadata missing") + local previous = game.save.options.ruleset + assert( + previous == require("src.core.SaveData").optionsFor(V.get()).ruleset, + "new save preserves persisted choice" + ) + -- Existing explicit choices are intentionally preserved. Opt into AUTO only + -- in memory for this disposable battle; do not write installation options. + game.save.options.ruleset = "auto" + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh() + local p = Pokemon.new(game.data, species, 30) + p.hp, p.stats.hp, p.moves = 500, 500, { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + local move = { id = tackle, pp = 2 } + b.enemy.mon.hp, b.enemy.mon.stats.hp = 500, 500 + b.enemy.mon.moves, b.enemy.curMoves = { move }, { move } + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + game.stack.states = { world, b } + return b, move + end + local b, move = fresh() + local gen = V.generation() + local expected = ({ "gen1_faithful", "gen2_finite_pp", "gen3_emerald" })[gen] + assert(b.ruleset.name == expected, "native automatic ruleset") + for turn = 1, 3 do + local beforeHP = b.player.mon.hp + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT input did not open move selection") + U.tap(game, "a") + for _ = 1, 1800 do + if b.phase == "menu" then + break + end + U.tap(game, "a") + end + assert(b.phase == "menu", "native result queue did not return to menu") + local wanted = gen == 1 and 2 or math.max(0, 2 - turn) + assert(move.pp == wanted, "native enemy PP after complete input/queue turn") + local view = assert(e.read()) + assert(view.moves[1].id == "TACKLE" and view.moves[1].pp == wanted, "public observer PP") + assert(b.player.mon.hp < beforeHP, "native enemy must still damage player after depletion") + if turn == 3 and gen > 1 then + assert(b:enemyAction().id == "STRUGGLE", "depleted native AI fallback") + end + end + -- A deliberately selected legacy rule must still win on a new battle. + game.save.options.ruleset = "gen1_faithful" + b, move = fresh() + assert(b.ruleset.name == "gen1_faithful", "explicit legacy choice lost") + move.pp = 0 + assert(b:enemyAction().id == tackle, "explicit unlimited rule refuses dry move") + b:performMove(b.enemy, b.player, move) + assert(move.pp == 0, "explicit legacy PP changed") + print("PORTABILITY_ENEMY_PP_PASS " .. V.get() .. " prior=" .. tostring(previous)) + love.event.quit(0) +end diff --git a/tests/enemy_pp/cases.lua b/tests/enemy_pp/cases.lua new file mode 100644 index 000000000..8a8e3bb86 --- /dev/null +++ b/tests/enemy_pp/cases.lua @@ -0,0 +1,111 @@ +local M = {} +function M.run(f) + local T, rows = f.T, {} + local function fresh(name) + local b = f.fresh(name, { enemy = true }) + b.move.pp = 2 + if f.backend == "gen1" then + f.game.save.party = b.battle.party + f.game.stack.states = { { screenId = "Gen2BattleState", battle = b.battle, phase = "menu" } } + else + b.battle.phase, b.battle.queue = "menu", {} + f.game.stack.states = { b.battle } + end + return b + end + local function observe(label, b) + local view = assert(f.e.read()) + T.eq(view.moves[1].id, f.ids.TACKLE, "public snapshot has canonical enemy move") + T.eq(view.moves[1].pp, b.pp(), "same public consumer observes current native enemy PP") + rows[#rows + 1] = { label = label, pp = view.moves[1].pp } + end + local function choose(b) + if f.backend == "gen1" then + return b.battle:enemyMove() + end + return b.battle:enemyAction().id + end + local b = fresh("TACKLE") + observe("initial", b) + for remaining = 1, 0, -1 do + T.eq(choose(b), f.ids.TACKLE, "native AI selects remaining usable move") + b.use() + T.eq(b.pp(), remaining, "ordinary enemy move spends one PP") + observe("remaining:" .. remaining, b) + end + local dry = choose(b) + T.eq(dry == nil or dry == "STRUGGLE", true, "native enemy selection reaches depleted fallback") + -- Run the actual full-turn substitution in Gold and the actual chosen action + -- in the host. Neither PP accounting nor fallback execution is mocked. + f.data.moves.STRUGGLE = { + id = "STRUGGLE", + name = "STRUGGLE", + index = 165, + type = "NORMAL", + power = 50, + accuracy = 100, + accuracyRaw = 255, + pp = 1, + effect = f.backend == "gen1" and "EFFECT_RECOIL_HIT" or "RECOIL_EFFECT", + } + local hp = b.hp() + if f.backend == "gen1" then + b.battle:takeTurn({ kind = "skip" }) + T.eq(b.user.volatile.lastMove, "STRUGGLE", "native full turn executes Struggle") + else + local move = b.battle:enemyAction() + b.battle:performMove(b.user, b.target, move) + T.eq(move.struggle, true, "native AI produced Struggle action") + end + T.check(b.hp() < hp, "depleted enemy still deals native Struggle damage") + T.eq(b.pp(), 0, "Struggle leaves depleted slot at zero") + observe("struggle", b) + T.check(f.e.read().choices >= 3, "public forwarding hook observed ordinary native AI choices") + f.e.stop() + local before = f.e.read().choices + choose(b) + T.eq(f.e.read().choices, before, "unsubscribe restores native choice without callback") + rows[#rows + 1] = { label = "unsubscribe", pp = b.pp() } + b = fresh("FLY") + b.move.pp = 1 + b.use() + T.eq(b.pp(), 0, "enemy charge spends its final PP") + T.eq(b.charging(), true, "zero PP does not erase charged continuation") + T.eq(choose(b), f.ids.FLY, "native AI retains stored release at zero PP") + if f.backend == "gen2" then + b.user.chargeReady = true + end + local hp = b.hp() + b.use() + T.eq(b.pp(), 0, "release spends no additional PP") + T.eq(b.charging(), false, "release clears stored continuation") + T.check(b.hp() < hp, "last-PP release executes native damage") + rows[#rows + 1] = { label = "last PP release", pp = b.pp(), charging = b.charging() } + b = fresh("FLY") + b.move.pp = 1 + b.use(true) + T.eq(b.pp(), 1, "called enemy move retains caller-owned PP") + rows[#rows + 1] = { label = "called", pp = b.pp() } + b = fresh("TACKLE") + local alternate = { id = f.ids.FLY, pp = 0 } + if f.backend == "gen1" then + b.user.moves[2] = alternate + else + b.user.curMoves[2], b.user.mon.moves[2] = alternate, alternate + end + T.eq(choose(b), f.ids.TACKLE, "native AI skips depleted alternate slot") + if f.backend == "gen1" then + b.user.volatile.disabled = f.ids.TACKLE + else + b.user.disabledSlot = 1 + end + local disabled = choose(b) + T.eq( + disabled == nil or disabled == "STRUGGLE", + true, + "disabled usable move plus dry alternate reaches fallback" + ) + rows[#rows + 1] = { label = "disabled", pp = b.pp(), fallback = disabled == nil or disabled == "STRUGGLE" } + return rows +end +return M diff --git a/tests/enemy_pp/worker.lua b/tests/enemy_pp/worker.lua new file mode 100644 index 000000000..6afd96b91 --- /dev/null +++ b/tests/enemy_pp/worker.lua @@ -0,0 +1,8 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local Fixture = dofile(root .. "/tests/charge_required/fixture.lua") +local f = Fixture.new(root, backend, version, 3, "unified", false, false, "portable_enemy_pp") +local rows = dofile(root .. "/tests/enemy_pp/cases.lua").run(f) +f.close() +print("ENEMY_PP_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("native enemy PP and public observer") diff --git a/tests/engine/enemy_pp_defaults.lua b/tests/engine/enemy_pp_defaults.lua new file mode 100644 index 000000000..8de5ec87f --- /dev/null +++ b/tests/engine/enemy_pp_defaults.lua @@ -0,0 +1,158 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Save = require("src.core.GameVersion"), require("src.core.SaveData") +local Defaults = require("src.battle.RulesetDefaults") +local Fixture = dofile("tests/charge_required/fixture.lua") +local old = V.get() + +T.eq(Save.defaultOptions().ruleset, "auto", "new settings select cartridge rules automatically") +T.eq(Save.mergeOptions({}).ruleset, "auto", "missing old preference becomes automatic") +for _, id in ipairs({ "gen1_faithful", "modern_clean", "custom_rules" }) do + T.eq(Save.mergeOptions({ ruleset = id }).ruleset, id, "explicit persisted choice is preserved") +end +local fs = T.sdk.memfs({}) +T.check( + Save.saveOptions({ ruleset = "gen1_faithful", perGeneration = { [2] = { ruleset = "auto" } } }, fs), + "per-generation automatic preference saves" +) +T.eq(Save.optionsFor("gold", fs).ruleset, "auto", "generation override can opt out of shared explicit id") +T.eq(Save.optionsFor("red", fs).ruleset, "gen1_faithful", "automatic override does not leak to other games") +local live = Save.optionsFor("gold", fs) +live.ruleset = "modern_clean" +T.check(Save.saveOptions(live, fs, 2), "explicit generation preference saves") +T.eq(Save.optionsFor("gold", fs).ruleset, "modern_clean", "explicit choice survives reloading") +live = Save.optionsFor("gold", fs) +live.ruleset = "auto" +T.check(Save.saveOptions(live, fs, 2), "return to automatic saves") +T.eq(Save.optionsFor("gold", fs).ruleset, "auto", "automatic survives reloading") +T.eq(Save.optionsFor("red", fs).ruleset, "gen1_faithful", "roundtrip preserves shared explicit choice") + +local faithful = require("src.battle.rulesets.gen1_faithful") +local finite = require("src.battle.rulesets.gen2_finite_pp") +T.eq(faithful.enemyUnlimitedPP, true, "Gen-1 source table keeps unlimited enemy PP") +T.eq(finite.enemyUnlimitedPP, false, "Gen-2 compatibility rules track PP") +for key, value in pairs(faithful) do + if key ~= "name" and key ~= "enemyUnlimitedPP" then + T.eq(finite[key], value, "PP correction preserves other previous host rules: " .. key) + end +end + +local wanted = { "gen1_faithful", "gen2_finite_pp", "gen3_emerald", "gen4_platinum" } +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + if f.gen <= 4 then + -- Both old missing preferences and newly persisted AUTO must reach the + -- same real battle constructor, including without loader-populated data. + for _, choice in ipairs({ {}, { ruleset = "auto" } }) do + f.game.save.options = choice + local b = f.fresh("TACKLE", { enemy = true }) + T.eq(b.battle.ruleset.name, wanted[f.gen], "native battle uses version default: " .. version) + if f.gen <= 3 then + b.move.pp = 1 + b.use() + T.eq(b.pp(), f.gen == 1 and 1 or 0, "native default enemy PP is generation-correct") + local action = b.battle:enemyAction() + T.eq(action.id, f.gen == 1 and f.ids.TACKLE or "STRUGGLE", "AI follows default PP policy") + end + end + f.game.save.options = { ruleset = "gen1_faithful" } + local b = f.fresh("TACKLE", { enemy = true }) + T.eq(b.battle.ruleset.name, "gen1_faithful", "explicit legacy choice remains effective") + b.move.pp = 0 + b.use() + T.eq(b.pp(), 0, "explicit unlimited rule does not spend PP") + T.eq(b.battle:enemyAction().id, f.ids.TACKLE, "explicit unlimited rule permits dry enemy move") + f.game.save.options = { ruleset = "modern_clean" } + b = f.fresh("TACKLE", { enemy = true }) + T.eq(b.battle.ruleset.name, "modern_clean", "explicit alternative remains effective") + b.move.pp = 1 + b.use() + T.eq(b.pp(), 0, "explicit finite rule spends PP") + T.eq(b.battle:enemyAction().id, "STRUGGLE", "explicit finite rule reaches Struggle") + f.game.save.options = { ruleset = "auto" } + f.data.rulesets.CUSTOM_RULE = { name = "CUSTOM_RULE", enemyUnlimitedPP = true } + f.data.constants.defaultRuleset = "CUSTOM_RULE" + T.eq(f.fresh("TACKLE").battle.ruleset.name, "CUSTOM_RULE", "cartridge/mod named default wins") + f.game.save.options.ruleset = "modern_clean" + T.eq(f.fresh("TACKLE").battle.ruleset.name, "modern_clean", "explicit choice wins over mod default") + f.game.save.options.ruleset = "missing_id" + T.eq( + f.fresh("TACKLE").battle.ruleset.name, + "CUSTOM_RULE", + "unknown saved id falls back to named default" + ) + f.data.constants.defaultRuleset, f.data.rulesets = nil, nil + f.game.save.options.ruleset = "auto" + T.eq(f.fresh("TACKLE").battle.ruleset.name, wanted[f.gen], "no-loader fallback includes native rules") + end + f.close() + end +end + +V.set("gold") +local f = Fixture.new(".", "gen2", "gold", 3, "unified", true) +local Options = require("src.ui.OptionsMenu") +f.game.save.options = Save.defaultOptions() +local menu = Options.new(f.game) +local row +for _, candidate in ipairs(menu.rows) do + if candidate.id == "ruleset" then + row = candidate + end +end +T.eq(row.value(f.game), "AUTO", "options show automatic rather than a misleading explicit id") +local hidden = { name = "hidden", hidden = true } +f.data.rulesets.HIDDEN = hidden +local seen = {} +for _ = 1, 30 do + row.step(f.game, 1) + seen[f.game.save.options.ruleset] = true + if f.game.save.options.ruleset == "auto" then + break + end +end +T.eq(seen.HIDDEN, nil, "hidden mod rules are skipped") +T.eq(seen.gen2_finite_pp, true, "new compatibility rule is selectable") +T.eq(seen.gen1_faithful, true, "legacy explicit rule remains selectable") +T.eq(row.value(f.game), "AUTO", "full cycle returns to automatic") +row.step(f.game, -1) +T.check(f.game.save.options.ruleset ~= "auto", "left from automatic wraps to explicit rule") +row.step(f.game, 1) +T.eq(f.game.save.options.ruleset, "auto", "right returns to automatic") +f.close() + +-- Native defaults must also apply after a brand-new save, not just synthetic +-- options = {}. Isolate the installation filesystem; no real options are read. +local previousFs = love.filesystem +love.filesystem = T.sdk.memfs({}) +Save.forgetDataDir() +local save = Save.newGame({ version = "gold" }) +T.eq(save.options.ruleset, "auto", "new save does not mask native default") +love.filesystem = previousFs +Save.forgetDataDir() +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local shared = Fixture.new(".", "gen2", version, 3, "unified", false, false, "portable_enemy_pp") + dofile("tests/enemy_pp/cases.lua").run(shared) + shared.close() + for _, failing in ipairs({ false, true }) do + local baseline = + Fixture.new(".", "gen2", version, 3, "unified", not failing, failing, "portable_enemy_pp") + if failing then + T.check(#baseline.result.errors > 0, "intentional PP consumer entry failure is recorded") + else + T.eq(baseline.result.loader.exports.portable_enemy_pp, nil, "genuine no-mod PP consumer is absent") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.enemy_action"] or {}) do + T.check(link.owner ~= "portable_enemy_pp", "failed/no-mod consumer owns no AI wrappers") + end + local b = baseline.fresh("TACKLE", { enemy = true }) + b.move.pp = 1 + b.use() + T.eq(b.pp(), 0, "no-mod/rollback native enemy spends last PP") + T.eq(b.battle:enemyAction().id, "STRUGGLE", "no-mod/rollback retains real AI fallback") + baseline.close() + end +end +V.set(old) +T.finish("native automatic rulesets and finite enemy PP") diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 34f696c58..2712dbefd 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -299,19 +299,25 @@ do menu:update(1 / 60) pressed = {} end + check(game.save.options.ruleset == "auto", + "new saves follow the cartridge default") + press("right") + check(game.save.options.ruleset == "aaa_rules", + "AUTO steps to the first sorted explicit id") + press("right") check(game.save.options.ruleset == "gen1_faithful", - "new saves start on the default ruleset") + "the explicit Gen-1 record remains selectable") press("right") check(game.save.options.ruleset == "modern_clean", "right steps to the next sorted id") press("right") - check(game.save.options.ruleset == "aaa_rules", - "a mod-registered ruleset is selectable") - press("right") - check(game.save.options.ruleset == "gen1_faithful", - "the cycle wraps and never offers the hidden record") + check(game.save.options.ruleset == "auto", + "the cycle returns to AUTO and skips hidden records") press("left") - check(game.save.options.ruleset == "aaa_rules", "left steps backwards") + check(game.save.options.ruleset == "modern_clean", "left steps backwards") + press("right") + press("right") + check(game.save.options.ruleset == "aaa_rules", "a mod record remains selectable") local drawn = {} local savedDraw = Font.draw diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index a775f0c4d..8df69a726 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -284,7 +284,7 @@ check(scSave and scSave.player and scSave.player.id == 12345, check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5, "SaveConvert.importSav: decoded bag items survive the merge") -- merge over new-game defaults -check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "gen1_faithful", +check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "auto", "SaveConvert.importSav: new-game default options merged in") check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table" and scSave.repelSteps == 0, diff --git a/tools/charge-required-contract-check/check.py b/tools/charge-required-contract-check/check.py index b23c0f304..5d4f23a35 100644 --- a/tools/charge-required-contract-check/check.py +++ b/tools/charge-required-contract-check/check.py @@ -22,6 +22,8 @@ def main(): p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) p.add_argument("--format", choices=["native", "unified"], default="unified") p.add_argument("--report", type=Path) + p.add_argument("--require-native-policy-parity", action="store_true", + help="also compare raw native enemy PP costs, not just preservation") a = p.parse_args() gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): @@ -46,11 +48,14 @@ def main(): line[len("CHARGE_NATIVE_POLICY "):] for line in run.stdout.splitlines() if line.startswith("CHARGE_NATIVE_POLICY "))), "checks": run.stdout.splitlines()[-1]}) + if a.require_native_policy_parity: + assert results[-2]["native_policy"] == results[-1]["native_policy"], ( + f"native enemy PP policy mismatch: {version}") assert observations[0] == observations[1], f"charge decision mismatch: {version}" print(f"PASS {version}: {len(observations[0])} matching native charge observations") assert not git(gen1, "status", "--porcelain"), "reference changed" report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, - "format": a.format, "results": results, + "format": a.format, "require_native_policy_parity": a.require_native_policy_parity, "results": results, "consumer_sha256": hashlib.sha256( (gen2/"examples/portable_charge_required/main.lua").read_bytes()).hexdigest()} if a.report: diff --git a/tools/enemy-pp-contract-check/check.py b/tools/enemy-pp-contract-check/check.py new file mode 100644 index 000000000..ea99453c3 --- /dev/null +++ b/tools/enemy-pp-contract-check/check.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare native Gold-family enemy PP, actual AI depletion and a public API-3 observer.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ("gold", "silver", "crystal"): + pair = [] + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + run = subprocess.run([a.lua, str(gen2 / "tests/enemy_pp/worker.lua"), str(gen2), + backend, version], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [line.removeprefix("ENEMY_PP_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("ENEMY_PP_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + pair.append(rows) + results.append({"backend": backend, "version": version, "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert pair[0] == pair[1], f"enemy PP mismatch: {version}" + print(f"PASS {version}: {len(pair[0])} matching native enemy PP observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "results": results, + "consumer_sha256": hashlib.sha256( + (gen2 / "examples/portable_enemy_pp/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main() From d7450bd9cd6c1c46ddc0d1bf81407f668efcf719 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:31:50 +0200 Subject: [PATCH 39/86] Keep automatic rules on cartridge save import too --- docs/native-ruleset-defaults.md | 2 ++ src/save_convert/SaveConvert.lua | 2 +- tests/engine/enemy_pp_defaults.lua | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/native-ruleset-defaults.md b/docs/native-ruleset-defaults.md index d094d1a24..99059fa8b 100644 --- a/docs/native-ruleset-defaults.md +++ b/docs/native-ruleset-defaults.md @@ -16,6 +16,8 @@ profile, hiding the generation fallback even when the correct later-generation ruleset was registered. AUTO fixes new profiles and can be stored as a generation override even when the shared preference names an explicit ruleset. Options save/reload preserves that override and the other generations' choices. +The cartridge save-import skeleton uses AUTO too, so importing a save does not +reintroduce the fixed Gen-1 preference. ## Existing settings and mods diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 74127bf04..743e1a42b 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -269,7 +269,7 @@ local function defaultsSave() options = { textSpeed = 3, animations = true, battleStyle = "shift", battleLayout = "og", - ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7, + ruleset = "auto", musicVol = 7, sfxVol = 7, pikaVol = 7, musicFilter = 0, speed = 1, colors = "gbc", tilt = 0, gbcfx = 0, videoMode = "windowed", mods = {}, diff --git a/tests/engine/enemy_pp_defaults.lua b/tests/engine/enemy_pp_defaults.lua index 8de5ec87f..c03c62e30 100644 --- a/tests/engine/enemy_pp_defaults.lua +++ b/tests/engine/enemy_pp_defaults.lua @@ -27,6 +27,17 @@ T.check(Save.saveOptions(live, fs, 2), "return to automatic saves") T.eq(Save.optionsFor("gold", fs).ruleset, "auto", "automatic survives reloading") T.eq(Save.optionsFor("red", fs).ruleset, "gen1_faithful", "roundtrip preserves shared explicit choice") +local Convert = require("src.save_convert.SaveConvert") +for generation = 1, 5 do + local imported = Convert.mergeDefaults({ player = { map = "TEST", x = 1, y = 2 } }, nil, generation) + T.eq(imported.options.ruleset, "auto", "cartridge import skeleton follows native default") + local explicit = Convert.mergeDefaults({ + player = { map = "TEST", x = 1, y = 2 }, + options = { ruleset = "custom_rules" }, + }, nil, generation) + T.eq(explicit.options.ruleset, "custom_rules", "import merge keeps explicit decoded settings") +end + local faithful = require("src.battle.rulesets.gen1_faithful") local finite = require("src.battle.rulesets.gen2_finite_pp") T.eq(faithful.enemyUnlimitedPP, true, "Gen-1 source table keeps unlimited enemy PP") From 72ad168fabb613d371eddf73ab0616d264abac03 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:35:33 +0200 Subject: [PATCH 40/86] Represent automatic selection without reserving mod ruleset ids --- docs/native-ruleset-defaults.md | 7 +++--- src/battle/RulesetDefaults.lua | 5 ++-- src/core/SaveData.lua | 2 +- src/save_convert/SaveConvert.lua | 2 +- src/ui/OptionsMenu.lua | 10 ++++---- tests/drivers/enemy_pp_defaults.lua | 2 +- tests/engine/enemy_pp_defaults.lua | 36 ++++++++++++++++++----------- tests/mod_battle_tests.lua | 4 ++-- tests/save_convert_tests.lua | 2 +- 9 files changed, 40 insertions(+), 30 deletions(-) diff --git a/docs/native-ruleset-defaults.md b/docs/native-ruleset-defaults.md index 99059fa8b..0725a9c2d 100644 --- a/docs/native-ruleset-defaults.md +++ b/docs/native-ruleset-defaults.md @@ -1,9 +1,10 @@ # Automatic rulesets and native enemy PP -New settings select `options.ruleset = "auto"`. The shared RULESET option row +New settings select `options.ruleset = false`. The shared RULESET option row shows AUTO first, followed by sorted non-hidden registered rulesets, and can -return to AUTO from an explicit choice. AUTO is a reserved preference value, -not a ruleset registry id. Missing old preferences have the same meaning. +return to AUTO from an explicit choice. AUTO uses the existing false/no-explicit-choice value, so no string id is +reserved from mods (including a registered id named `auto`). Missing old +preferences have the same meaning. Automatic selection honors `data.constants.defaultRuleset` before the existing generation fallback. Gen 1 retains `gen1_faithful`; Gen 2 uses `gen2_finite_pp`; diff --git a/src/battle/RulesetDefaults.lua b/src/battle/RulesetDefaults.lua index f725afbd3..5e725372a 100644 --- a/src/battle/RulesetDefaults.lua +++ b/src/battle/RulesetDefaults.lua @@ -62,9 +62,10 @@ RulesetDefaults.DEFAULT_BY_GENERATION = { } RulesetDefaults.FALLBACK = "gen1_faithful" -RulesetDefaults.AUTO = "auto" +RulesetDefaults.AUTO = false --- AUTO is an option value, not a content registry id. Missing old preferences +-- AUTO uses false, the existing no-explicit-choice value, so no string id +-- is reserved from mods. Missing old preferences -- mean automatic too; a saved explicit id is never migrated to another one. function RulesetDefaults.selection(game) local selected = game and game.save and game.save.options and game.save.options.ruleset diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b52a04e57..9209a1d6b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -447,7 +447,7 @@ function SaveData.defaultOptions() uiLayout = "centered", -- AUTO follows the game's default; explicit persisted ids stay selected. -- This must be a value so per-generation overrides can return to AUTO. - ruleset = "auto", + ruleset = false, -- 0-7 like the GB's NR50 master volume musicVol = 7, sfxVol = 7, diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 743e1a42b..e85cbcb84 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -269,7 +269,7 @@ local function defaultsSave() options = { textSpeed = 3, animations = true, battleStyle = "shift", battleLayout = "og", - ruleset = "auto", musicVol = 7, sfxVol = 7, pikaVol = 7, + ruleset = false, musicVol = 7, sfxVol = 7, pikaVol = 7, musicFilter = 0, speed = 1, colors = "gbc", tilt = 0, gbcfx = 0, videoMode = "windowed", mods = {}, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d8d49dc96..9fca5e757 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -68,15 +68,15 @@ local function rulesetIds(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = {} for id, record in pairs(rulesets) do - if not record.hidden and id ~= "auto" then ids[#ids + 1] = id end + if not record.hidden then ids[#ids + 1] = id end end table.sort(ids) - table.insert(ids, 1, "auto") + table.insert(ids, 1, false) return ids end local function rulesetIndex(game, ids) - local cur = require("src.battle.RulesetDefaults").selection(game) or "auto" + local cur = require("src.battle.RulesetDefaults").selection(game) or false for i, id in ipairs(ids) do if id == cur then return i end end @@ -86,8 +86,8 @@ end local function rulesetName(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = rulesetIds(game) - local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset - if id == "auto" then return Strings("AUTO") end + local id = ids[rulesetIndex(game, ids)] + if id == false then return Strings("AUTO") end local record = id and rulesets[id] return Strings(record and record.name or id or "----") end diff --git a/tests/drivers/enemy_pp_defaults.lua b/tests/drivers/enemy_pp_defaults.lua index ef8e2a9bb..1cb411cda 100644 --- a/tests/drivers/enemy_pp_defaults.lua +++ b/tests/drivers/enemy_pp_defaults.lua @@ -43,7 +43,7 @@ return function(game) ) -- Existing explicit choices are intentionally preserved. Opt into AUTO only -- in memory for this disposable battle; do not write installation options. - game.save.options.ruleset = "auto" + game.save.options.ruleset = false game.save.options.textSpeed, game.save.options.animations = 1, false local world = game.overworld local function fresh() diff --git a/tests/engine/enemy_pp_defaults.lua b/tests/engine/enemy_pp_defaults.lua index c03c62e30..ab8a7e90a 100644 --- a/tests/engine/enemy_pp_defaults.lua +++ b/tests/engine/enemy_pp_defaults.lua @@ -5,32 +5,32 @@ local Defaults = require("src.battle.RulesetDefaults") local Fixture = dofile("tests/charge_required/fixture.lua") local old = V.get() -T.eq(Save.defaultOptions().ruleset, "auto", "new settings select cartridge rules automatically") -T.eq(Save.mergeOptions({}).ruleset, "auto", "missing old preference becomes automatic") +T.eq(Save.defaultOptions().ruleset, false, "new settings select cartridge rules automatically") +T.eq(Save.mergeOptions({}).ruleset, false, "missing old preference becomes automatic") for _, id in ipairs({ "gen1_faithful", "modern_clean", "custom_rules" }) do T.eq(Save.mergeOptions({ ruleset = id }).ruleset, id, "explicit persisted choice is preserved") end local fs = T.sdk.memfs({}) T.check( - Save.saveOptions({ ruleset = "gen1_faithful", perGeneration = { [2] = { ruleset = "auto" } } }, fs), + Save.saveOptions({ ruleset = "gen1_faithful", perGeneration = { [2] = { ruleset = false } } }, fs), "per-generation automatic preference saves" ) -T.eq(Save.optionsFor("gold", fs).ruleset, "auto", "generation override can opt out of shared explicit id") +T.eq(Save.optionsFor("gold", fs).ruleset, false, "generation override can opt out of shared explicit id") T.eq(Save.optionsFor("red", fs).ruleset, "gen1_faithful", "automatic override does not leak to other games") local live = Save.optionsFor("gold", fs) live.ruleset = "modern_clean" T.check(Save.saveOptions(live, fs, 2), "explicit generation preference saves") T.eq(Save.optionsFor("gold", fs).ruleset, "modern_clean", "explicit choice survives reloading") live = Save.optionsFor("gold", fs) -live.ruleset = "auto" +live.ruleset = false T.check(Save.saveOptions(live, fs, 2), "return to automatic saves") -T.eq(Save.optionsFor("gold", fs).ruleset, "auto", "automatic survives reloading") +T.eq(Save.optionsFor("gold", fs).ruleset, false, "automatic survives reloading") T.eq(Save.optionsFor("red", fs).ruleset, "gen1_faithful", "roundtrip preserves shared explicit choice") local Convert = require("src.save_convert.SaveConvert") for generation = 1, 5 do local imported = Convert.mergeDefaults({ player = { map = "TEST", x = 1, y = 2 } }, nil, generation) - T.eq(imported.options.ruleset, "auto", "cartridge import skeleton follows native default") + T.eq(imported.options.ruleset, false, "cartridge import skeleton follows native default") local explicit = Convert.mergeDefaults({ player = { map = "TEST", x = 1, y = 2 }, options = { ruleset = "custom_rules" }, @@ -55,7 +55,7 @@ for _, version in ipairs(V.ORDER) do if f.gen <= 4 then -- Both old missing preferences and newly persisted AUTO must reach the -- same real battle constructor, including without loader-populated data. - for _, choice in ipairs({ {}, { ruleset = "auto" } }) do + for _, choice in ipairs({ {}, { ruleset = false } }) do f.game.save.options = choice local b = f.fresh("TACKLE", { enemy = true }) T.eq(b.battle.ruleset.name, wanted[f.gen], "native battle uses version default: " .. version) @@ -81,7 +81,7 @@ for _, version in ipairs(V.ORDER) do b.use() T.eq(b.pp(), 0, "explicit finite rule spends PP") T.eq(b.battle:enemyAction().id, "STRUGGLE", "explicit finite rule reaches Struggle") - f.game.save.options = { ruleset = "auto" } + f.game.save.options = { ruleset = false } f.data.rulesets.CUSTOM_RULE = { name = "CUSTOM_RULE", enemyUnlimitedPP = true } f.data.constants.defaultRuleset = "CUSTOM_RULE" T.eq(f.fresh("TACKLE").battle.ruleset.name, "CUSTOM_RULE", "cartridge/mod named default wins") @@ -94,7 +94,7 @@ for _, version in ipairs(V.ORDER) do "unknown saved id falls back to named default" ) f.data.constants.defaultRuleset, f.data.rulesets = nil, nil - f.game.save.options.ruleset = "auto" + f.game.save.options.ruleset = false T.eq(f.fresh("TACKLE").battle.ruleset.name, wanted[f.gen], "no-loader fallback includes native rules") end f.close() @@ -113,24 +113,32 @@ for _, candidate in ipairs(menu.rows) do end end T.eq(row.value(f.game), "AUTO", "options show automatic rather than a misleading explicit id") +f.game.save.options.ruleset = nil +T.eq(row.value(f.game), "AUTO", "old missing preference shows automatic too") +f.game.save.options.ruleset = false local hidden = { name = "hidden", hidden = true } f.data.rulesets.HIDDEN = hidden +f.data.rulesets.auto = { name = "mod_auto", enemyUnlimitedPP = true } +f.game.save.options.ruleset = "auto" +T.eq(f.fresh("TACKLE").battle.ruleset.name, "mod_auto", "mod id auto is not reserved by the preference") +f.game.save.options.ruleset = false local seen = {} for _ = 1, 30 do row.step(f.game, 1) seen[f.game.save.options.ruleset] = true - if f.game.save.options.ruleset == "auto" then + if f.game.save.options.ruleset == false then break end end T.eq(seen.HIDDEN, nil, "hidden mod rules are skipped") T.eq(seen.gen2_finite_pp, true, "new compatibility rule is selectable") +T.eq(seen.auto, true, "mod id auto remains selectable") T.eq(seen.gen1_faithful, true, "legacy explicit rule remains selectable") T.eq(row.value(f.game), "AUTO", "full cycle returns to automatic") row.step(f.game, -1) -T.check(f.game.save.options.ruleset ~= "auto", "left from automatic wraps to explicit rule") +T.check(f.game.save.options.ruleset ~= false, "left from automatic wraps to explicit rule") row.step(f.game, 1) -T.eq(f.game.save.options.ruleset, "auto", "right returns to automatic") +T.eq(f.game.save.options.ruleset, false, "right returns to automatic") f.close() -- Native defaults must also apply after a brand-new save, not just synthetic @@ -139,7 +147,7 @@ local previousFs = love.filesystem love.filesystem = T.sdk.memfs({}) Save.forgetDataDir() local save = Save.newGame({ version = "gold" }) -T.eq(save.options.ruleset, "auto", "new save does not mask native default") +T.eq(save.options.ruleset, false, "new save does not mask native default") love.filesystem = previousFs Save.forgetDataDir() for _, version in ipairs({ "gold", "silver", "crystal" }) do diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 2712dbefd..fae950a3a 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -299,7 +299,7 @@ do menu:update(1 / 60) pressed = {} end - check(game.save.options.ruleset == "auto", + check(game.save.options.ruleset == false, "new saves follow the cartridge default") press("right") check(game.save.options.ruleset == "aaa_rules", @@ -311,7 +311,7 @@ do check(game.save.options.ruleset == "modern_clean", "right steps to the next sorted id") press("right") - check(game.save.options.ruleset == "auto", + check(game.save.options.ruleset == false, "the cycle returns to AUTO and skips hidden records") press("left") check(game.save.options.ruleset == "modern_clean", "left steps backwards") diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index 8df69a726..47c149a5a 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -284,7 +284,7 @@ check(scSave and scSave.player and scSave.player.id == 12345, check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5, "SaveConvert.importSav: decoded bag items survive the merge") -- merge over new-game defaults -check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "auto", +check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == false, "SaveConvert.importSav: new-game default options merged in") check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table" and scSave.repelSteps == 0, From 798990d1046599b3025b430787a1c1ae3c51cf34 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:55:32 +0200 Subject: [PATCH 41/86] Add native caught-marker visibility and cross-engine contracts --- .github/workflows/caught-marker-contract.yml | 46 +++++ docs/api3-portability.md | 5 +- docs/portable-caught-marker.md | 77 ++++++++ examples/portable_caught_marker/main.lua | 20 +++ examples/portable_caught_marker/manifest.json | 8 + src/battle/BattleState.lua | 32 +++- src/battle/CaughtMarker.lua | 74 ++++++++ src/battle/Gen3Battle.lua | 18 ++ src/battle/WideBattle.lua | 11 +- src/import/RomExtractorGen3.lua | 10 ++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- tests/caught_marker/cases.lua | 73 ++++++++ tests/caught_marker/fixture.lua | 128 +++++++++++++ tests/caught_marker/worker.lua | 22 +++ tests/drivers/caught_marker.lua | 96 ++++++++++ tests/engine/caught_marker_contract.lua | 44 +++++ tests/engine/caught_marker_import.lua | 62 +++++++ tests/engine/caught_marker_renderers.lua | 168 ++++++++++++++++++ tools/caught-marker-contract-check/check.py | 58 ++++++ 20 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/caught-marker-contract.yml create mode 100644 docs/portable-caught-marker.md create mode 100644 examples/portable_caught_marker/main.lua create mode 100644 examples/portable_caught_marker/manifest.json create mode 100644 src/battle/CaughtMarker.lua create mode 100644 tests/caught_marker/cases.lua create mode 100644 tests/caught_marker/fixture.lua create mode 100644 tests/caught_marker/worker.lua create mode 100644 tests/drivers/caught_marker.lua create mode 100644 tests/engine/caught_marker_contract.lua create mode 100644 tests/engine/caught_marker_import.lua create mode 100644 tests/engine/caught_marker_renderers.lua create mode 100644 tools/caught-marker-contract-check/check.py diff --git a/.github/workflows/caught-marker-contract.yml b/.github/workflows/caught-marker-contract.yml new file mode 100644 index 000000000..af95f022e --- /dev/null +++ b/.github/workflows/caught-marker-contract.yml @@ -0,0 +1,46 @@ +name: portable caught-marker contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: caught-marker-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native caught-marker eligibility, renderers and compatibility + run: | + luajit tests/engine/caught_marker_contract.lua + luajit tests/engine/caught_marker_renderers.lua + luajit tests/engine/caught_marker_import.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/caught-marker-contract + python3 tools/caught-marker-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/caught-marker-contract/api1-native.json + python3 tools/caught-marker-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/caught-marker-contract/api2-native.json + python3 tools/caught-marker-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/caught-marker-contract/api2-unified.json + python3 tools/caught-marker-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/caught-marker-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: caught-marker-contract + path: /tmp/caught-marker-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 8d45dfdf8..910ee21fc 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ 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, -`battle.caught_marker_visible` can be subscribed to but reports unavailable here. Unknown +`battle.field_residual` 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. @@ -161,3 +161,6 @@ GBA contract and the remaining Gold/DS boundaries. [Automatic native rulesets](native-ruleset-defaults.md) document enemy PP accounting and the existing-settings boundary. The read-only `examples/portable_enemy_pp` consumer runs unchanged on both engines. + +[Portable caught markers](portable-caught-marker.md) document strict visibility, +native ownership gates, API-3 contexts and GBA presentation caching. diff --git a/docs/portable-caught-marker.md b/docs/portable-caught-marker.md new file mode 100644 index 000000000..5b8c8bbec --- /dev/null +++ b/docs/portable-caught-marker.md @@ -0,0 +1,77 @@ +# Portable caught-marker visibility + +`battle.caught_marker_visible(next, battle)` controls the already-caught marker +inside eligible enemy healthboxes on GB and GBA. Use the same mod on both engines: + +```lua +local mod = ... +if mod:supports("hooks", "battle.caught_marker_visible") then + mod.hooks:wrap("battle.caught_marker_visible", function(next, battle) + if hideCaughtMarkers then return false end + return next(battle) + end) +end +``` + +Only literal `true` shows the marker. `false`, nil, zero and every other value +hide it. Gen 1's default is false (the original HUD has no caught indicator); +Gen 2 and GBA default to true. A mod can opt Gen 1 in by returning true. + +The native ownership/wild-battle gate precedes the hook. A mod cannot use this +predicate to identify an uncaught species or mark a trainer's Pokémon. Gold +also keeps the already-caught bit captured at encounter entry and checks the +active species again when drawing. GBA excludes player healthboxes, tutorials +and unrevealed ghosts. Status badges take precedence over its marker, and GBA +doubles keep their existing marker exclusion. GB/Gold can show status and marker +together. Intro/hidden HUD branches do not query or draw this element. + +The argument is the native battle screen/state, whose internal shape differs +between engines and generations. Native API 1/2 retain identity; API 3 and the +host's API-2 unified mode use a context view, matching the reference boundary. +Forward the argument to `next`; use public battle snapshots for portable reads. +This hook does not normalize private controller fields into a new battle model. + +GBA retains the visibility verdict for the presented healthbox, keyed by its +Pokémon object/species, displayed name and ailment. It does not reevaluate a +wrapper on every frame; option changes or unsubscribe take effect on the next +presentation refresh. GB/Gold query on each eligible HUD draw. API-3 repeated +`next` invokes the downstream chain once; wrapper failures follow normal bus +recovery, and failed-entry rollback removes the owner. + +## Native rendering and cached artwork + +Classic and wide GB layouts draw the optional ball beside the enemy name. +Gold-family layouts use native HUD tile 0x5d at the enemy healthbox's (1,1). +GBA singles draw at the native healthbox offset (8,16). These paths remain +inside `battle.status_hud_visible` and the usual intro/sendout visibility gates. +DS reports this hook unavailable and keeps its own renderer. + +New GBA imports extract healthbox element tile 70 into +`battle/hud/caught.png` and publish `constants.gen3BattleHud.images.caught`. +Existing caches use their already imported Pokédex marker as a fallback, as the +reference renderer does when its element sheet is unavailable. Reimport for +the dedicated native 8x8 healthbox tile. Missing optional art draws no placeholder. +Legacy fallback artwork/layout is not claimed pixel-identical to the new tile. +No cartridge art is bundled by this change. + +## Verification + +```sh +luajit tests/engine/caught_marker_contract.lua +luajit tests/engine/caught_marker_renderers.lua +luajit tests/engine/caught_marker_import.lua +python3 tools/caught-marker-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified --report /tmp/caught-marker.json +``` + +The checker requires a clean reference at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The unchanged public consumer runs +through the real GB `BattleState:caughtMarkerVisible`, Gold battle-screen +predicate and GBA `Healthbox.shouldShowCaughtMarker`, compared with the host's +native producer for Red/Blue/Yellow, Gold/Silver/Crystal and FireRed/Emerald in +four SDK modes. These use native-shaped data/session fixtures. They prove the +native predicate and SDK boundary, not full shared compositor or cartridge +playthrough parity. Host renderer tests separately drive classic/wide/GBA HUD +compositors with spies only at font/tile/image leaves, including GBA caching, +status precedence, hidden HUDs and ownership gates. A separate 4bpp test verifies +tile selection, palette and transparency for the importer. diff --git a/examples/portable_caught_marker/main.lua b/examples/portable_caught_marker/main.lua new file mode 100644 index 000000000..80f2568b7 --- /dev/null +++ b/examples/portable_caught_marker/main.lua @@ -0,0 +1,20 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.state, mod.exports.api = state, mod +local stop = mod.hooks:wrap("battle.caught_marker_visible", function(next, battle) + state.calls, state.battle = state.calls + 1, battle + if state.mode == "show" then + return true + end + if state.mode == "hide" then + return false + end + if state.mode == "nil" then + return nil + end + if state.mode == "zero" then + return 0 + end + return next(battle) +end) +mod.exports.stop = stop diff --git a/examples/portable_caught_marker/manifest.json b/examples/portable_caught_marker/manifest.json new file mode 100644 index 000000000..089c1b927 --- /dev/null +++ b/examples/portable_caught_marker/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_caught_marker", + "name": "Portable Caught Marker", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a6a50a845..393bba213 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -366,6 +366,26 @@ end -- its RIGHT. Left and right then did nothing at all, because a vertical list -- has no use for them. The fix is one clause, not new maths: the grid this -- layout already draws is the grid it should navigate. +function BattleState:caughtMarkerVisible(battler) + return require("src.battle.CaughtMarker").visible(self, battler) +end + +local caughtBallQuads = setmetatable({}, { __mode = "k" }) +function BattleState:drawCaughtBall(x, y) + local Assets = require("src.render.Assets") + local path = "assets/generated/battle/balls.png" + if not Assets.exists(Assets.resolve(path)) then return false end + local image = Assets.image(path) + local quad = caughtBallQuads[image] + if not quad then + quad = love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()) + caughtBallQuads[image] = quad + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, quad, x, y) + return true +end + function BattleState:moveGridNavigation() return MoveNavigation.grid(self) end @@ -1437,6 +1457,7 @@ function BattleState.newWild(game, species, level, opts) Pokemon.applySeed(game.data, wild, opts.roamerSeed) end self.enemy = makeBattler(game.data, wild, false) + self.caughtMark = require("src.battle.CaughtMarker").caught(game, wild.species) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -12283,7 +12304,16 @@ function BattleState:drawHUDs(slide) love.graphics.translate(hudShake, 0) end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) + local enemyNameX = nameX(1, self.enemy.name) + local enemyNameWidth = Font.draw(self.enemy.name, enemyNameX, 0) + if self:caughtMarkerVisible() then + if require("src.core.GameVersion").isGen2() then + hudTile(0x5d, 8, 8) + else + self:drawCaughtBall(enemyNameX + enemyNameWidth, 0) + end + love.graphics.setColor(0, 0, 0, 1) + end if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then diff --git a/src/battle/CaughtMarker.lua b/src/battle/CaughtMarker.lua new file mode 100644 index 000000000..e39dca7ac --- /dev/null +++ b/src/battle/CaughtMarker.lua @@ -0,0 +1,74 @@ +-- Eligibility and strict boolean verdict shared by the native GB/GBA renderers. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local M = {} +function M.caught(game, species) + local dex = game and game.save and game.save.pokedex + return not not (dex and ((dex.owned and dex.owned[species]) or (dex.caught and dex.caught[species]))) +end +function M.visible(battle, battler) + local gen = Version.generation() + battler = battler or battle.enemy + if + gen > 3 + or not battler + or battler.isPlayer + or not battler.mon + or (battle.kind ~= "wild" and battle.kind ~= "safari") + or not M.caught(battle.game, battler.mon.species) + then + return false + end + -- Gold latches the already-caught bit when the encounter starts, then checks + -- the active species again when drawing (e.g. after Transform). + if gen == 2 and not battle.caughtMark then + return false + end + if + gen == 3 + and ( + battle.demo + or battle.dudeDemo + or battle.pokedudeDemo + or battle.firstBattle + or (battle.ghost and not battle.ghostUnveiled) + ) + then + return false + end + local default = gen > 1 + if not Runtime.wantsHook("battle.caught_marker_visible") then + return default + end + return Runtime.call("battle.caught_marker_visible", function() + return default + end, battle) == true +end +-- The GBA renderer creates this element for a presented healthbox and retains +-- its verdict until that presentation changes, as in the reference engine. +function M.gbaVisible(battle, battler) + if not battler or battler.shownStatus or (battle.isDouble and battle:isDouble()) then + return false + end + local old = battler._caughtIcon + local species = battler.mon and battler.mon.species + if + old + and old.mon == battler.mon + and old.species == species + and old.name == battler.name + and old.status == battler.shownStatus + then + return old.show + end + local show = M.visible(battle, battler) + battler._caughtIcon = { + mon = battler.mon, + species = species, + name = battler.name, + status = battler.shownStatus, + show = show, + } + return show +end +return M diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index 155df7754..219d4cc77 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -906,6 +906,22 @@ local function levelAt(battle, battler, x, y, text, isStatus) end end +-- Native imports carry tile 70 of the healthbox elements. Older caches use +-- the existing Pokédex marker, the same fallback as the reference renderer. +local function drawCaughtMarker(battle, battler, x, y) + if not require("src.battle.CaughtMarker").gbaVisible(battle, battler) then return end + local record = hudRecord(battle) + local path = record and record.images and record.images.caught + local function exists(p) return p and Assets.exists(Assets.resolve(p)) end + if not exists(path) then + path = require("src.core.GameVersion").get() == "firered" + and "assets/generated/pokedex_frlg/caught.png" or "assets/generated/ui/pokedex/caught_ball.png" + end + if not exists(path) then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(Assets.image(path), x + 8, y + 16) +end + local function drawStatusPanel(battle, battler, x, y, player) local record = hudRecord(battle) local images = record and record.images or {} @@ -919,6 +935,7 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) Font.draw(fitName(battler.name, 56), x + 8, y + 8) + if not player then drawCaughtMarker(battle, battler, x, y) end levelAt(battle, battler, x + tw * 8 - 40, y + 8) if player then Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), @@ -929,6 +946,7 @@ local function drawStatusPanel(battle, battler, x, y, player) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(panel, x, y) + if not player then drawCaughtMarker(battle, battler, x, y) end -- THE SMALL FACE, which is what the cartridge sets a healthbox in. -- diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 59bd18163..c7a68d942 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -167,7 +167,16 @@ local function drawGen2Panel(battle, battler, x, y, player, gray) end HudTiles.tile(0x6F, x, y + 32) else - Font.draw(fitName(battler.name, 80), nameX(x + 8, battler.name), y) + local nx = nameX(x + 8, battler.name) + local width = Font.draw(fitName(battler.name, 80), nx, y) + if battle:caughtMarkerVisible(battler) then + if require("src.core.GameVersion").isGen2() then + HudTiles.tile(0x5d, x + 8, y + 8) + else + battle:drawCaughtBall(nx + width, y) + end + love.graphics.setColor(0, 0, 0, 1) + end levelAt(battle, battler, x + 32, y + 8) HudTiles.tile(0x73, x + 8, y + 16) HudTiles.drawHPBar(data, tx + 2, ty + 2, bar, nil, gray) diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..0a630f5c1 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -48933,6 +48933,13 @@ end -- -- Returns nil rather than half a record: a row of balls in the wrong place is -- worse than no row, and the screen already knows how to draw nothing. +function RomExtractorGen3:battleCaughtMarker(elements, colors) + if type(elements) ~= "table" or #elements < 71 * 32 then return nil end + local path = "battle/hud/caught.png" + self:saveImage(self:hudImage(elements, 70 * 32 + 1, 1, 1, colors), path) + return "assets/generated/" .. path +end + function RomExtractorGen3:partySummary() local S = RomExtractorGen3.PARTY_SUMMARY local rom = self.rom @@ -49923,6 +49930,9 @@ function RomExtractorGen3:extractBattleHud() end) local _ = okBars + -- B_INTERFACE_GFX_CAUGHT_BALL (tile 70) uses the healthbox palette. + images.caught = self:battleCaughtMarker(elements, boxColors) + -- WHERE INSIDE ITS OWN TILE THE EXP BAR ACTUALLY IS. -- -- Reported from play: "the EXP icon is not lined up". It was three pixels diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index dc8869a8f..3cd640f71 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -35,7 +35,7 @@ add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") -add("hooks", "123", "encounter.table battle.charge_required") +add("hooks", "123", "encounter.table battle.charge_required battle.caught_marker_visible") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 300fa8a2f..6a30b58c3 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation - battle.charge_required battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.caught_marker_visible battle.charge_required battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.table encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build item.use link.fingerprint map.palette movement.collision movement.speed @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.caught_marker_visible"] = { "context" }, ["battle.charge_required"] = { "context" }, ["encounter.table"] = { "context", "context" }, ["item.use"] = { false, false, "itemId", false, "context", false, "context" }, diff --git a/tests/caught_marker/cases.lua b/tests/caught_marker/cases.lua new file mode 100644 index 000000000..76590df2e --- /dev/null +++ b/tests/caught_marker/cases.lua @@ -0,0 +1,73 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, want, calls) + local before = e.state.calls + local got = f.visible() + T.eq(got, want, label .. " verdict") + T.eq(e.state.calls - before, calls, label .. " dispatch count") + if calls > 0 then + T.eq(e.state.battle.publicTag, "caught_context", "callback carries native context fields") + if f.api >= 3 or (f.backend == "gen2" and f.api == 2 and f.format == "unified") then + T.check(e.state.battle ~= f.battle, "unified callback has a boundary view") + else + T.eq(e.state.battle, f.battle, "legacy native callback retains identity") + end + end + rows[#rows + 1] = { label = label, visible = got, calls = e.state.calls - before } + end + for _, spec in ipairs({ + { "native", f.gen > 1 }, + { "show", true }, + { "hide", false }, + { "nil", false }, + { "zero", false }, + { "native", f.gen > 1 }, + }) do + f.reset() + e.state.mode = spec[1] + observe(spec[1], spec[2], 1) + end + e.state.mode = "show" + for _, opts in ipairs({ { caught = false }, { trainer = true }, { noDex = true } }) do + f.reset(opts) + observe(opts.caught == false and "not caught" or opts.trainer and "trainer" or "no dex", false, 0) + end + if f.gen == 2 then + f.reset({ latched = false }) + observe("entry latch", false, 0) + elseif f.gen == 3 then + for _, opts in ipairs({ { tutorial = true }, { ghost = true }, { player = true } }) do + f.reset(opts) + observe(opts.tutorial and "tutorial" or opts.ghost and "ghost" or "player", false, 0) + end + end + e.state.mode = "native" + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.caught_marker_visible", function(next, battle) + if after then + next(battle) + end + error("intentional marker wrapper failure") + end, 100) + f.reset() + observe("error:" .. tostring(after), f.gen > 1, 1) + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("battle.caught_marker_visible", function(next, battle) + local a, b = next(battle), next(battle) + T.eq(a, b, "API-3 repeated next retains verdict") + return a + end, 100) + f.reset() + observe("replay", f.gen > 1, 1) + off() + end + e.stop() + e.stop() + f.reset() + observe("unsubscribed", f.gen > 1, 0) + return rows +end +return M diff --git a/tests/caught_marker/fixture.lua b/tests/caught_marker/fixture.lua new file mode 100644 index 000000000..2135ec8fb --- /dev/null +++ b/tests/caught_marker/fixture.lua @@ -0,0 +1,128 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local V, Json = require("src.core.GameVersion"), require("src.link.Json") + assert(V.VERSIONS[version], "unregistered version") + V.set(version) + local gen = V.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local f = assert(io.open(root .. "/examples/portable_caught_marker/" .. name, "rb")) + files["mods/portable_caught_marker/" .. name] = f:read("*a") + f:close() + end + local mf = Json.decode(files["mods/portable_caught_marker/manifest.json"]) + mf.api, mf.callback_format, mf.gen2compat = api, format, api < 3 and true or nil + files["mods/portable_caught_marker/manifest.json"] = Json.encode(mf) + if failing then + files["mods/portable_caught_marker/main.lua"] = files["mods/portable_caught_marker/main.lua"] + .. '\nerror("intentional marker entry rollback")' + end + if noMod then + files = {} + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/portable_caught_marker" }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { data = data, save = { options = {}, pokedex = { owned = {}, caught = {} }, party = {} } } + result.loader.game = game + local e, b, battler = result.loader.exports.portable_caught_marker + local f = { + T = T, + game = game, + data = data, + gen = gen, + backend = backend, + api = api, + format = format, + e = e, + result = result, + } + function f.reset(opts) + opts = opts or {} + local id = backend == "gen1" and gen == 3 and 16 or "FIXMON_A" + local mon = { + species = id, + nickname = "FOE", + name = "FOE", + level = 20, + hp = 60, + maxHp = 60, + stats = { hp = 60 }, + status = opts.status, + } + local dex = { owned = {}, caught = {} } + if opts.caught ~= false then + dex.owned[id], dex.caught[id] = true, true + end + if opts.noDex then + dex = nil + end + game.save.pokedex = dex + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + b = { + kind = opts.trainer and "trainer" or "wild", + wild = not opts.trainer, + trainer = opts.trainer, + dex = dex, + session = { game = game, dex = dex }, + firstBattle = opts.tutorial, + ghostBattle = opts.ghost, + } + battler = { + species = id, + mon = mon, + isPlayer = opts.player or false, + side = opts.player and "player" or "enemy", + } + elseif backend == "gen1" and gen == 2 then + b = setmetatable({ + game = game, + data = data, + save = game.save, + caughtMark = opts.latched ~= false, + battle = { wild = not opts.trainer, enemy = mon }, + activeMon = function() + return mon + end, + }, { __index = require("src.ui.gen2.BattleState") }) + battler = mon + else + battler = { mon = mon, name = "FOE", isPlayer = opts.player or false, shownStatus = opts.status } + b = setmetatable( + { + game = game, + data = data, + kind = opts.trainer and "trainer" or "wild", + enemy = battler, + caughtMark = opts.latched ~= false, + demo = opts.tutorial, + firstBattle = opts.tutorial, + ghost = opts.ghost, + }, + { __index = require("src.battle.BattleState") } + ) + end + b.publicTag = "caught_context" + f.battle, f.battler, f.mon = b, battler, mon + return b + end + function f.visible() + if backend == "gen1" and gen == 3 then + return require("src.core.game3.battle.healthbox").shouldShowCaughtMarker(b, battler) + end + return b:caughtMarkerVisible() + end + f.reset() + f.close = function() + result.release() + end + return f +end +return M diff --git a/tests/caught_marker/worker.lua b/tests/caught_marker/worker.lua new file mode 100644 index 000000000..c0ffe9763 --- /dev/null +++ b/tests/caught_marker/worker.lua @@ -0,0 +1,22 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/caught_marker/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/caught_marker/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + bare.T.check(#bare.result.errors > 0, "intentional failed entry recorded") + else + bare.T.eq(bare.result.loader.exports.portable_caught_marker, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.caught_marker_visible"] or {}) do + bare.T.check(link.owner ~= "portable_caught_marker", "no rollback owner survives") + end + bare.T.eq(bare.visible(), bare.gen > 1, "no-mod/rollback restores native default") + rows[#rows + 1] = { label = failing and "rollback" or "no mod", visible = bare.visible() } + bare.close() +end +print("CAUGHT_MARKER_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native caught marker") diff --git a/tests/drivers/caught_marker.lua b/tests/drivers/caught_marker.lua new file mode 100644 index 000000000..0ba00cf68 --- /dev/null +++ b/tests/drivers/caught_marker.lua @@ -0,0 +1,96 @@ +-- Imported-data native HUD/GPU probe, without a normal SAVE action. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable legacy migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_caught_marker, "install caught marker example") + local species + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + assert(species, "imported Mew missing") + local dex = game.save.pokedex + dex.owned, dex.caught = dex.owned or {}, dex.caught or {} + dex.owned[species], dex.caught[species] = true, true + local mon = require("src.pokemon.Pokemon").new(game.data, species, 30) + mon.hp, mon.stats.hp = 100, 100 + game.save.party = { mon } + local b = require("src.battle.BattleState").newWild(game, species, 30) + b.phase, b.frame, b.fx, b.shown = "contract", 0, {}, {} + b.introBalls, b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut = false, true, false, false + game.stack.states = { game.overworld, b } + local g, captured = love.graphics + local function capture(mode) + e.state.mode = mode + -- A new native presented Pokémon refreshes GBA's cached healthbox verdict. + b.enemy.mon = require("src.mods.Merge").deepCopy(b.enemy.mon) + local canvas = g.newCanvas(V.isGen3() and 240 or 160, V.isGen3() and 160 or 144) + g.push("all") + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setShader() + b:drawHUDs(0) + g.pop() + local data = canvas:newImageData() + canvas:release() + captured = data + return data:getString() + end + local hidden = capture("hide") + local shown = capture("show") + assert(hidden ~= shown, "native marker must change actual HUD pixels") + local shot = os.getenv("POKEPORT_CAUGHT_SHOT") + if shot then + local f = assert(io.open(shot, "wb")) + f:write(captured:encode("png"):getString()) + f:close() + end + assert(capture("nil") == hidden and capture("zero") == hidden, "strict native boolean verdict") + assert(capture("native") == (V.generation() == 1 and hidden or shown), "generation-native marker default") + dex.owned[species], dex.caught[species] = nil, nil + assert(capture("show") == hidden, "native ownership gate precedes forced show") + dex.owned[species], dex.caught[species] = true, true + b.kind = "trainer" + assert(capture("show") == hidden, "native trainer exclusion") + b.kind = "wild" + if V.isGen3() then + b.enemy.shownStatus = "PSN" + local calls = e.state.calls + local statusShown = capture("show") + assert(capture("hide") == statusShown and e.state.calls == calls, "native GBA status takes precedence") + b.enemy.shownStatus = nil + end + local off = e.api.hooks:wrap("battle.status_hud_visible", function() + return false + end) + local calls = e.state.calls + capture("show") + assert(e.state.calls == calls, "hidden native HUD must not query marker") + off() + e.stop() + local calls = e.state.calls + assert(capture("show") == (V.generation() == 1 and hidden or shown), "unsubscribe restores native draw") + assert(e.state.calls == calls, "unsubscribe removes callback") + print("PORTABILITY_CAUGHT_MARKER_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/caught_marker_contract.lua b/tests/engine/caught_marker_contract.lua new file mode 100644 index 000000000..cabfe7ce7 --- /dev/null +++ b/tests/engine/caught_marker_contract.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture = dofile("tests/caught_marker/fixture.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq( + f.e.api:supports("hooks", "battle.caught_marker_visible"), + f.gen <= 3, + "version-aware marker capability" + ) + if f.gen <= 3 then + dofile("tests/caught_marker/cases.lua").run(f) + else + f.e.state.mode = "show" + T.eq(f.visible(), false, "DS helper never dispatches unavailable marker hook") + T.eq(f.e.state.calls, 0, "DS callback remains unused") + end + f.close() + for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#bare.result.errors > 0, "failed entry recorded") + else + T.eq(bare.result.loader.exports.portable_caught_marker, nil, "no-mod consumer is absent") + end + for _, link in ipairs(Runtime.hooks.chains["battle.caught_marker_visible"] or {}) do + T.check(link.owner ~= "portable_caught_marker", "rollback removes marker wrappers") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.caught_marker_visible", "empty-chain marker path dispatched") + return call(name, ...) + end + T.eq(bare.visible(), bare.gen > 1 and bare.gen <= 3, "native default after no-mod/rollback") + Runtime.call = call + bare.close() + end + end +end +V.set(old) +T.finish("native caught marker hook and public SDK contracts") diff --git a/tests/engine/caught_marker_import.lua b/tests/engine/caught_marker_import.lua new file mode 100644 index 000000000..77b5ec8d2 --- /dev/null +++ b/tests/engine/caught_marker_import.lua @@ -0,0 +1,62 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Extractor = require("src.import.RomExtractorGen3") +local previous = love.image +love.image = {} +local pixels +love.image.newImageData = function(w, h) + pixels = { w = w, h = h } + return { + setPixel = function(_, x, y, ...) + pixels[y * w + x] = { ... } + end, + mapPixel = function(self, fn) + for y = 0, h - 1 do + for x = 0, w - 1 do + self:setPixel(x, y, fn(x, y, 0, 0, 0, 0)) + end + end + end, + } +end +local extractor = setmetatable({ + saveImage = function(_, image, path) + T.eq(path, "battle/hud/caught.png", "native marker has a dedicated cache asset") + end, +}, { __index = Extractor }) +local elements = {} +for i = 1, 71 * 32 do + elements[i] = 0 +end +-- Tile 69 differs deliberately; decoding its neighbor would fail every pixel. +for i = 69 * 32 + 1, 70 * 32 do + elements[i] = 0x33 +end +for i = 70 * 32 + 1, 71 * 32 do + elements[i] = i % 2 == 0 and 0x21 or 0x10 +end +local palette = { { 0, 0, 0 }, { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 } } +T.eq( + extractor:battleCaughtMarker(elements, palette), + "assets/generated/battle/hud/caught.png", + "import publishes tile 70 path" +) +T.eq(pixels.w, 8, "native caught marker is eight pixels wide") +T.eq(pixels.h, 8, "native caught marker is eight pixels high") +for y = 0, 7 do + for x = 0, 7 do + local value = pixels[y * 8 + x] + local byte = elements[70 * 32 + y * 4 + math.floor(x / 2) + 1] + local index = x % 2 == 0 and byte % 16 or math.floor(byte / 16) + local expected = palette[index + 1] + T.eq(value[4], index == 0 and 0 or 1, "4bpp zero remains transparent") + if index > 0 then + for channel = 1, 3 do + T.eq(value[channel], expected[channel] / 255, "native tile/palette channel") + end + end + end +end +T.eq(extractor:battleCaughtMarker({}, palette), nil, "short optional old sheet is unavailable") +love.image = previous +T.finish("native caught-marker 4bpp import") diff --git a/tests/engine/caught_marker_renderers.lua b/tests/engine/caught_marker_renderers.lua new file mode 100644 index 000000000..408372f9a --- /dev/null +++ b/tests/engine/caught_marker_renderers.lua @@ -0,0 +1,168 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local Battle, G3 = require("src.battle.BattleState"), require("src.battle.Gen3Battle") +local Font, Hud, Assets = + require("src.render.Font"), require("src.render.HudTiles"), require("src.render.Assets") +local Fixture = dofile("tests/caught_marker/fixture.lua") +local old = V.get() +if jit then + jit.off(Battle.drawHUDs, true) +end +local function upvalue(owner, wanted) + local visited = {} + local function scan(fn) + if type(fn) ~= "function" or visited[fn] then + return + end + visited[fn] = true + 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 + local found = scan(value) + if found then + return found + end + end + end + for _, fn in pairs(owner) do + local found = scan(fn) + if found then + return found + end + end + error("missing native compositor " .. wanted) +end +local wideHUDs = upvalue(require("src.battle.WideBattle"), "drawHUDs") +for _, version in ipairs(V.ORDER) do + if V.generation(version) <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local restore = {} + local function patch(owner, key, value) + local old = owner[key] + restore[#restore + 1] = function() + owner[key] = old + end + owner[key] = value + end + local ink = 0 + patch(Font, "draw", function(s) + return Font.width(s) + end) + patch(Font, "drawCode", function() end) + patch(Font, "drawBox", function() end) + patch(Hud, "tile", function(code) + if code == 0x5d then + ink = ink + 1 + end + end) + patch(Battle, "drawCaughtBall", function() + ink = ink + 1 + end) + for i = 1, 100 do + local key, value = debug.getupvalue(Battle.drawHUDs, i) + if not key then + break + end + if key == "hudTile" then + debug.setupvalue(Battle.drawHUDs, i, Hud.tile) + restore[#restore + 1] = function() + debug.setupvalue(Battle.drawHUDs, i, value) + end + break + end + end + local game = f.game + game.save = require("src.core.SaveData").newGame() + game.save.party = { require("src.pokemon.Pokemon").new(f.data, "FIXMON_A", 20) } + game.save.pokedex = { seen = {}, owned = { FIXMON_C = true }, caught = { FIXMON_C = true } } + Font.load(f.data) + local b = Battle.newWild(game, "FIXMON_C", 20) + b.phase, b.frame, b.fx, b.shown = "contract", 0, {}, {} + b.introBalls, b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut = false, true, false, false + b.growInScale = function() end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + if f.gen == 3 then + local panel, marker = love.graphics.newCanvas(128, 40), love.graphics.newCanvas(8, 8) + local draw = love.graphics.draw + patch(Assets, "exists", function(path) + return path == "marker" + end) + patch(Assets, "image", function(path) + return path == "marker" and marker or panel + end) + patch(love.graphics, "draw", function(image, ...) + if image == marker then + ink = ink + 1 + else + return draw(image, ...) + end + end) + f.data.constants.gen3BattleHud = + { images = { opponent = "panel", player = "panel", caught = "marker" } } + end + local function draw(mode, refresh, wanted, expectedCalls, wide) + f.e.state.mode = mode + if refresh then + b.enemy.name = "FOE" .. tostring(b.frame) + b.frame = b.frame + 1 + end + local calls = f.e.state.calls + ink = 0 + if wide then + wideHUDs(b, 0) + else + b:drawHUDs(0) + end + T.eq(ink, wanted, "native compositor emits expected marker: " .. version .. "/" .. mode) + T.eq(f.e.state.calls - calls, expectedCalls, "native compositor queries marker only when eligible") + end + draw("native", true, f.gen == 1 and 0 or 1, 1) + draw("hide", false, f.gen == 3 and 1 or 0, f.gen == 3 and 0 or 1) + draw("hide", true, 0, 1) + draw("show", true, 1, 1) + b.enemy.shownStatus = "PSN" + draw("show", true, f.gen == 3 and 0 or 1, f.gen == 3 and 0 or 1) + b.enemy.shownStatus = nil + local off = f.e.api.hooks:wrap("battle.status_hud_visible", function() + return false + end) + draw("show", true, 0, 0) + off() + b.kind = "trainer" + draw("show", true, 0, 0) + b.kind = "wild" + game.save.pokedex.owned.FIXMON_C, game.save.pokedex.caught.FIXMON_C = nil, nil + draw("show", true, 0, 0) + game.save.pokedex.owned.FIXMON_C = true + if f.gen < 3 then + draw("show", true, 1, 1, true) + draw("hide", true, 0, 1, true) + else + b.double = true + T.eq( + require("src.battle.CaughtMarker").gbaVisible(b, b.enemy), + false, + "GBA doubles retain native marker exclusion" + ) + end + for i = #restore, 1, -1 do + restore[i]() + end + f.close() + end + end +end +V.set(old) +T.finish("caught marker native classic, wide and GBA compositors") diff --git a/tools/caught-marker-contract-check/check.py b/tools/caught-marker-contract-check/check.py new file mode 100644 index 000000000..4478c234a --- /dev/null +++ b/tools/caught-marker-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Compare one unchanged public caught-marker consumer through both native predicates.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/caught_marker/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("CAUGHT_MARKER_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("CAUGHT_MARKER_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native caught marker decision", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"caught marker mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native caught marker observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_caught_marker/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 42f37b945b89d7347de2a4a4b966c72a677f1da8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:59:48 +0200 Subject: [PATCH 42/86] Preserve standalone wide compositors and align legacy caught artwork --- docs/portable-caught-marker.md | 4 +-- src/battle/BattleState.lua | 41 ++++++++++++------------ src/battle/CaughtMarker.lua | 8 +++++ src/battle/Gen3Battle.lua | 17 ++++++++-- src/battle/WideBattle.lua | 2 +- src/import/RomExtractorGen3.lua | 15 +++++---- tests/drivers/caught_marker.lua | 2 +- tests/engine/caught_marker_renderers.lua | 37 +++++++++++++++++++-- 8 files changed, 91 insertions(+), 35 deletions(-) diff --git a/docs/portable-caught-marker.md b/docs/portable-caught-marker.md index 5b8c8bbec..7c1bfead9 100644 --- a/docs/portable-caught-marker.md +++ b/docs/portable-caught-marker.md @@ -48,8 +48,8 @@ DS reports this hook unavailable and keeps its own renderer. New GBA imports extract healthbox element tile 70 into `battle/hud/caught.png` and publish `constants.gen3BattleHud.images.caught`. -Existing caches use their already imported Pokédex marker as a fallback, as the -reference renderer does when its element sheet is unavailable. Reimport for +Existing caches use their already imported Pokédex marker as a fallback, with +the older tall Emerald cell cropped to its central native 8x8 footprint. Reimport for the dedicated native 8x8 healthbox tile. Missing optional art draws no placeholder. Legacy fallback artwork/layout is not claimed pixel-identical to the new tile. No cartridge art is bundled by this change. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 393bba213..2ec6c6687 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -366,26 +366,6 @@ end -- its RIGHT. Left and right then did nothing at all, because a vertical list -- has no use for them. The fix is one clause, not new maths: the grid this -- layout already draws is the grid it should navigate. -function BattleState:caughtMarkerVisible(battler) - return require("src.battle.CaughtMarker").visible(self, battler) -end - -local caughtBallQuads = setmetatable({}, { __mode = "k" }) -function BattleState:drawCaughtBall(x, y) - local Assets = require("src.render.Assets") - local path = "assets/generated/battle/balls.png" - if not Assets.exists(Assets.resolve(path)) then return false end - local image = Assets.image(path) - local quad = caughtBallQuads[image] - if not quad then - quad = love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()) - caughtBallQuads[image] = quad - end - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(image, quad, x, y) - return true -end - function BattleState:moveGridNavigation() return MoveNavigation.grid(self) end @@ -604,6 +584,27 @@ end BattleState.statusHUDVisible = StatusHUDVisibility.visible +function BattleState:caughtMarkerVisible(battler) + return require("src.battle.CaughtMarker").visible(self, battler) +end + +local caughtBallQuads = setmetatable({}, { __mode = "k" }) +function BattleState:drawCaughtBall(x, y) + local Assets = require("src.render.Assets") + local path = "assets/generated/battle/balls.png" + if not Assets.exists(Assets.resolve(path)) then return false end + local image = Assets.image(path) + local quad = caughtBallQuads[image] + if not quad then + quad = love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()) + caughtBallQuads[image] = quad + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, quad, x, y) + return true +end + + function BattleState:playerPartyView() return self.playerParty or self.game.save.party or {} end diff --git a/src/battle/CaughtMarker.lua b/src/battle/CaughtMarker.lua index e39dca7ac..b76d76305 100644 --- a/src/battle/CaughtMarker.lua +++ b/src/battle/CaughtMarker.lua @@ -44,6 +44,14 @@ function M.visible(battle, battler) return default end, battle) == true end +-- Legacy standalone compositors can carry a partial battle fixture. +function M.forBattle(battle, battler) + local method = battle.caughtMarkerVisible + if type(method) == "function" then + return method(battle, battler) + end + return M.visible(battle, battler) +end -- The GBA renderer creates this element for a presented healthbox and retains -- its verdict until that presentation changes, as in the reference engine. function M.gbaVisible(battle, battler) diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index 219d4cc77..d740fd176 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -907,7 +907,8 @@ local function levelAt(battle, battler, x, y, text, isStatus) end -- Native imports carry tile 70 of the healthbox elements. Older caches use --- the existing Pokédex marker, the same fallback as the reference renderer. +-- the existing Pokédex marker, cropped to the native 8x8 healthbox footprint. +local caughtQuads = setmetatable({}, { __mode = "k" }) local function drawCaughtMarker(battle, battler, x, y) if not require("src.battle.CaughtMarker").gbaVisible(battle, battler) then return end local record = hudRecord(battle) @@ -919,7 +920,19 @@ local function drawCaughtMarker(battle, battler, x, y) end if not exists(path) then return end love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(Assets.image(path), x + 8, y + 16) + local image = Assets.image(path) + local w, h = image:getDimensions() + if w == 8 and h == 16 then + -- The older Emerald Pokédex cache centers a 7x7 mark inside an 8x16 cell. + local quad = caughtQuads[image] + if not quad then + quad = love.graphics.newQuad(0, 4, 8, 8, w, h) + caughtQuads[image] = quad + end + love.graphics.draw(image, quad, x + 8, y + 16) + else + love.graphics.draw(image, x + 8, y + 16) + end end local function drawStatusPanel(battle, battler, x, y, player) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index c7a68d942..a3aff50fd 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -169,7 +169,7 @@ local function drawGen2Panel(battle, battler, x, y, player, gray) else local nx = nameX(x + 8, battler.name) local width = Font.draw(fitName(battler.name, 80), nx, y) - if battle:caughtMarkerVisible(battler) then + if require("src.battle.CaughtMarker").forBattle(battle, battler) then if require("src.core.GameVersion").isGen2() then HudTiles.tile(0x5d, x + 8, y + 8) else diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 0a630f5c1..737963e1a 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -43948,6 +43948,14 @@ function RomExtractorGen3:hudImage(raw, from, cols, rows, colors) return image end +-- The dedicated 8x8 caught indicator from the validated healthbox sheet. +function RomExtractorGen3:battleCaughtMarker(elements, colors) + if type(elements) ~= "table" or #elements < 71 * 32 then return nil end + local path = "battle/hud/caught.png" + self:saveImage(self:hudImage(elements, 70 * 32 + 1, 1, 1, colors), path) + return "assets/generated/" .. path +end + -- The two OAM halves side by side, which is the panel as the player sees it. -- TWO SPRITES SIDE BY SIDE -- AND THEY ARE NOT ALWAYS THE SAME SIZE. -- @@ -48933,13 +48941,6 @@ end -- -- Returns nil rather than half a record: a row of balls in the wrong place is -- worse than no row, and the screen already knows how to draw nothing. -function RomExtractorGen3:battleCaughtMarker(elements, colors) - if type(elements) ~= "table" or #elements < 71 * 32 then return nil end - local path = "battle/hud/caught.png" - self:saveImage(self:hudImage(elements, 70 * 32 + 1, 1, 1, colors), path) - return "assets/generated/" .. path -end - function RomExtractorGen3:partySummary() local S = RomExtractorGen3.PARTY_SUMMARY local rom = self.rom diff --git a/tests/drivers/caught_marker.lua b/tests/drivers/caught_marker.lua index 0ba00cf68..0ab025387 100644 --- a/tests/drivers/caught_marker.lua +++ b/tests/drivers/caught_marker.lua @@ -47,7 +47,7 @@ return function(game) local canvas = g.newCanvas(V.isGen3() and 240 or 160, V.isGen3() and 160 or 144) g.push("all") g.setCanvas(canvas) - g.clear(0, 0, 0, 0) + g.clear(1, 1, 1, 1) g.setShader() b:drawHUDs(0) g.pop() diff --git a/tests/engine/caught_marker_renderers.lua b/tests/engine/caught_marker_renderers.lua index 408372f9a..214755bbe 100644 --- a/tests/engine/caught_marker_renderers.lua +++ b/tests/engine/caught_marker_renderers.lua @@ -95,12 +95,13 @@ for _, version in ipairs(V.ORDER) do end if f.gen == 3 then local panel, marker = love.graphics.newCanvas(128, 40), love.graphics.newCanvas(8, 8) + local markerPath = "marker" local draw = love.graphics.draw patch(Assets, "exists", function(path) - return path == "marker" + return path == markerPath end) patch(Assets, "image", function(path) - return path == "marker" and marker or panel + return path == markerPath and marker or panel end) patch(love.graphics, "draw", function(image, ...) if image == marker then @@ -157,6 +158,38 @@ for _, version in ipairs(V.ORDER) do "GBA doubles retain native marker exclusion" ) end + if f.gen == 3 then + b.double = false + local legacy = love.graphics.newCanvas(8, 16) + local path = V.get() == "firered" and "assets/generated/pokedex_frlg/caught.png" + or "assets/generated/ui/pokedex/caught_ball.png" + f.data.constants.gen3BattleHud.images.caught = nil + patch(Assets, "exists", function(candidate) + return candidate == path + end) + local panel = love.graphics.newCanvas(128, 40) + patch(Assets, "image", function(candidate) + return candidate == path and legacy or panel + end) + local quadArgs + local quad = love.graphics.newQuad + patch(love.graphics, "newQuad", function(...) + quadArgs = { ... } + return quad(...) + end) + patch(love.graphics, "draw", function(image) + if image == legacy then + ink = ink + 1 + end + end) + draw("show", true, 1, 1) + T.check(quadArgs ~= nil, "legacy tall marker uses a cropped native tile") + T.same(quadArgs, { 0, 4, 8, 8, 8, 16 }, "legacy cell crops to central 8x8 without stretching") + patch(Assets, "exists", function() + return false + end) + draw("show", true, 0, 1) + end for i = #restore, 1, -1 do restore[i]() end From e3f1b3eb637dbcf5b9284ea2744656943461e1fd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:02:11 +0200 Subject: [PATCH 43/86] Ignore malformed optional caught-marker asset metadata --- src/battle/Gen3Battle.lua | 2 +- tests/engine/caught_marker_renderers.lua | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index d740fd176..6a57f79e4 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -913,7 +913,7 @@ local function drawCaughtMarker(battle, battler, x, y) if not require("src.battle.CaughtMarker").gbaVisible(battle, battler) then return end local record = hudRecord(battle) local path = record and record.images and record.images.caught - local function exists(p) return p and Assets.exists(Assets.resolve(p)) end + local function exists(p) return type(p) == "string" and p ~= "" and Assets.exists(Assets.resolve(p)) end if not exists(path) then path = require("src.core.GameVersion").get() == "firered" and "assets/generated/pokedex_frlg/caught.png" or "assets/generated/ui/pokedex/caught_ball.png" diff --git a/tests/engine/caught_marker_renderers.lua b/tests/engine/caught_marker_renderers.lua index 214755bbe..97f6c622d 100644 --- a/tests/engine/caught_marker_renderers.lua +++ b/tests/engine/caught_marker_renderers.lua @@ -165,6 +165,7 @@ for _, version in ipairs(V.ORDER) do or "assets/generated/ui/pokedex/caught_ball.png" f.data.constants.gen3BattleHud.images.caught = nil patch(Assets, "exists", function(candidate) + assert(type(candidate) == "string", "asset lookup requires a path string") return candidate == path end) local panel = love.graphics.newCanvas(128, 40) @@ -185,6 +186,8 @@ for _, version in ipairs(V.ORDER) do draw("show", true, 1, 1) T.check(quadArgs ~= nil, "legacy tall marker uses a cropped native tile") T.same(quadArgs, { 0, 4, 8, 8, 8, 16 }, "legacy cell crops to central 8x8 without stretching") + f.data.constants.gen3BattleHud.images.caught = { invalid = true } + draw("show", true, 1, 1) patch(Assets, "exists", function() return false end) From bd182e0f4c39df69f86bcc76ceb15a51e1330579 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:04:55 +0200 Subject: [PATCH 44/86] Read native caught artwork beyond the bounded status sheet --- docs/portable-caught-marker.md | 3 ++- src/import/RomExtractorGen3.lua | 13 +++++++++---- tests/engine/caught_marker_import.lua | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/portable-caught-marker.md b/docs/portable-caught-marker.md index 7c1bfead9..8bbcc94da 100644 --- a/docs/portable-caught-marker.md +++ b/docs/portable-caught-marker.md @@ -74,4 +74,5 @@ native predicate and SDK boundary, not full shared compositor or cartridge playthrough parity. Host renderer tests separately drive classic/wide/GBA HUD compositors with spies only at font/tile/image leaves, including GBA caching, status precedence, hidden HUDs and ownership gates. A separate 4bpp test verifies -tile selection, palette and transparency for the importer. +the real flat ROM reader, tile selection, palette and transparency for the +importer, including the separate read beyond the unchanged 66-tile status scan. diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 737963e1a..d9c7919de 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -43949,10 +43949,15 @@ function RomExtractorGen3:hudImage(raw, from, cols, rows, colors) end -- The dedicated 8x8 caught indicator from the validated healthbox sheet. -function RomExtractorGen3:battleCaughtMarker(elements, colors) - if type(elements) ~= "table" or #elements < 71 * 32 then return nil end +function RomExtractorGen3:battleCaughtMarker(elementsAt, colors) + local rom = self.rom + if type(elementsAt) ~= "number" or elementsAt < 0 or not rom + or elementsAt + 71 * 32 > rom.size then return nil end + -- The bar/status scan intentionally ends after tile 65. Read tile 70 + -- separately; extending that scan would change its end-of-sheet checks. + local raw = rom:bytes(elementsAt + 70 * 32, 32) local path = "battle/hud/caught.png" - self:saveImage(self:hudImage(elements, 70 * 32 + 1, 1, 1, colors), path) + self:saveImage(self:hudImage(raw, 1, 1, 1, colors), path) return "assets/generated/" .. path end @@ -49932,7 +49937,7 @@ function RomExtractorGen3:extractBattleHud() local _ = okBars -- B_INTERFACE_GFX_CAUGHT_BALL (tile 70) uses the healthbox palette. - images.caught = self:battleCaughtMarker(elements, boxColors) + images.caught = self:battleCaughtMarker(elementsAt, boxColors) -- WHERE INSIDE ITS OWN TILE THE EXP BAR ACTUALLY IS. -- diff --git a/tests/engine/caught_marker_import.lua b/tests/engine/caught_marker_import.lua index 77b5ec8d2..63a8c9ab5 100644 --- a/tests/engine/caught_marker_import.lua +++ b/tests/engine/caught_marker_import.lua @@ -1,6 +1,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Extractor = require("src.import.RomExtractorGen3") +local Rom = require("src.import.RomGba") local previous = love.image love.image = {} local pixels @@ -35,9 +36,17 @@ end for i = 70 * 32 + 1, 71 * 32 do elements[i] = i % 2 == 0 and 0x21 or 0x10 end +local encoded = {} +for i, byte in ipairs(elements) do + encoded[i] = string.char(byte) +end +local offset = 37 +extractor.rom = Rom.new(string.rep(string.char(255), offset) .. table.concat(encoded)) +local before = extractor.rom:bytes(offset, 66 * 32) +T.eq(#before, 66 * 32, "normal bar/status scan retains its original 66 tiles") local palette = { { 0, 0, 0 }, { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 } } T.eq( - extractor:battleCaughtMarker(elements, palette), + extractor:battleCaughtMarker(offset, palette), "assets/generated/battle/hud/caught.png", "import publishes tile 70 path" ) @@ -57,6 +66,9 @@ for y = 0, 7 do end end end -T.eq(extractor:battleCaughtMarker({}, palette), nil, "short optional old sheet is unavailable") +T.same(extractor.rom:bytes(offset, 66 * 32), before, "marker import does not alter the bar/status scan") +extractor.rom = Rom.new(string.rep(string.char(0), offset + 66 * 32)) +T.eq(extractor:battleCaughtMarker(offset, palette), nil, "short optional sheet refuses an out-of-ROM tail") +T.eq(extractor:battleCaughtMarker(-1, palette), nil, "invalid element offset is refused") love.image = previous T.finish("native caught-marker 4bpp import") From b1c8e7e76b3e23a591e0f26635ca2867ec6d5f89 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:08:10 +0200 Subject: [PATCH 45/86] Normalize caught-marker fixture formatting --- tests/caught_marker/fixture.lua | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/caught_marker/fixture.lua b/tests/caught_marker/fixture.lua index 2135ec8fb..91bb1c5b1 100644 --- a/tests/caught_marker/fixture.lua +++ b/tests/caught_marker/fixture.lua @@ -95,19 +95,16 @@ function M.new(root, backend, version, api, format, noMod, failing) battler = mon else battler = { mon = mon, name = "FOE", isPlayer = opts.player or false, shownStatus = opts.status } - b = setmetatable( - { - game = game, - data = data, - kind = opts.trainer and "trainer" or "wild", - enemy = battler, - caughtMark = opts.latched ~= false, - demo = opts.tutorial, - firstBattle = opts.tutorial, - ghost = opts.ghost, - }, - { __index = require("src.battle.BattleState") } - ) + b = setmetatable({ + game = game, + data = data, + kind = opts.trainer and "trainer" or "wild", + enemy = battler, + caughtMark = opts.latched ~= false, + demo = opts.tutorial, + firstBattle = opts.tutorial, + ghost = opts.ghost, + }, { __index = require("src.battle.BattleState") }) end b.publicTag = "caught_context" f.battle, f.battler, f.mon = b, battler, mon From 124da70a77a09737d6a99aef2933ab722b3d5133 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:36:36 +0200 Subject: [PATCH 46/86] Add portable native field residuals and cross-engine contracts --- .github/workflows/field-residual-contract.yml | 45 +++ docs/api3-portability.md | 3 + docs/portable-field-residuals.md | 99 +++++++ examples/portable_field_residual/main.lua | 26 ++ .../portable_field_residual/manifest.json | 8 + src/battle/BattleState.lua | 10 +- src/battle/FieldResiduals.lua | 265 ++++++++++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- tests/drivers/field_residual.lua | 152 ++++++++++ tests/engine/field_residual_authority.lua | 167 +++++++++++ tests/engine/field_residual_contract.lua | 48 ++++ tests/field_residual/cases.lua | 264 +++++++++++++++++ tests/field_residual/fixture.lua | 136 +++++++++ tests/field_residual/worker.lua | 35 +++ tools/field-residual-contract-check/README.md | 33 +++ tools/field-residual-contract-check/check.py | 59 ++++ 17 files changed, 1351 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/field-residual-contract.yml create mode 100644 docs/portable-field-residuals.md create mode 100644 examples/portable_field_residual/main.lua create mode 100644 examples/portable_field_residual/manifest.json create mode 100644 src/battle/FieldResiduals.lua create mode 100644 tests/drivers/field_residual.lua create mode 100644 tests/engine/field_residual_authority.lua create mode 100644 tests/engine/field_residual_contract.lua create mode 100644 tests/field_residual/cases.lua create mode 100644 tests/field_residual/fixture.lua create mode 100644 tests/field_residual/worker.lua create mode 100644 tools/field-residual-contract-check/README.md create mode 100644 tools/field-residual-contract-check/check.py diff --git a/.github/workflows/field-residual-contract.yml b/.github/workflows/field-residual-contract.yml new file mode 100644 index 000000000..fe58536b3 --- /dev/null +++ b/.github/workflows/field-residual-contract.yml @@ -0,0 +1,45 @@ +name: portable field-residual contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: field-residual-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native field admission, faint authority and SDK compatibility + run: | + luajit tests/engine/field_residual_contract.lua + luajit tests/engine/field_residual_authority.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/field-residual-contract + python3 tools/field-residual-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/field-residual-contract/api1-native.json + python3 tools/field-residual-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/field-residual-contract/api2-native.json + python3 tools/field-residual-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/field-residual-contract/api2-unified.json + python3 tools/field-residual-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/field-residual-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: field-residual-contract + path: /tmp/field-residual-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 910ee21fc..05e63458f 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -164,3 +164,6 @@ accounting and the existing-settings boundary. The read-only [Portable caught markers](portable-caught-marker.md) document strict visibility, native ownership gates, API-3 contexts and GBA presentation caching. + +[Portable field residuals](portable-field-residuals.md) document detached +end-of-turn views, validated damage batches and native faint/EXP authority. diff --git a/docs/portable-field-residuals.md b/docs/portable-field-residuals.md new file mode 100644 index 000000000..8e560a4c6 --- /dev/null +++ b/docs/portable-field-residuals.md @@ -0,0 +1,99 @@ +# Portable field effects at the end of a battle turn + +`battle.field_residual(next, context)` lets a mod request additional damage +after ordinary native end-of-turn effects. Gen1–3 hosts expose it; DS reports +unavailable. Mods return data, while the engine owns HP, messages, animation, +faint events, experience and replacement menus. + +```lua +local mod = ... +if mod:supports("hooks", "battle.field_residual") then + mod.hooks:wrap("battle.field_residual", function(next, context) + local rows = next(context) + if myFieldEffectActive and context.battlers.enemy then + rows[#rows + 1] = { + side = "enemy", amount = 3, message = "The field takes its toll!", + } + end + return rows + end) +end +``` + +## Context and requests + +The detached context contains `turn`, `field = {weather, tokens}` and +`battlers.player` / `battlers.enemy`. Each battler view has `side`, `name`, +`hp`, `maxHp`, `types` and `vanished`. There is no battle controller or live +field/battler handle. Nested field extension data is copied; callbacks, +nonfinite numbers and cyclic references are omitted. Editing these views +does not mutate the native battle. Gold retains its reference lowercase +weather names (`rain`, `sun`, `sandstorm`); GB/GBA retain their native values. + +GBA also exposes battlers keyed by ID 0–3, with `battlerId` on each view. +The string aliases refer to the left flanks when those slots are present. +A GBA request may specify `battlerId` (or legacy `battler`) to target a right +flank. Its `side` must agree with that ID. Omit the ID for portable singles; +GB/Gold retain their reference behavior and ignore these extra ID fields. + +`next(context)` returns an initially empty list. A request must be a plain +table with `side = "player" | "enemy"`, a positive finite integer `amount`, +and an optional string `message`. The result must be a plain sequence table. +Invalid rows are ignored; nil or a non-table result applies nothing. Empty +messages are allowed. Damage is clamped to remaining HP, never heals, and +cannot hit an already fainted target. Admission validates the batch before +any of its HP changes are applied. + +Replacing the save, dataset, loader, hook bus, game, stack contents, turn, +active battler or its Pokemon during the callback invalidates the batch. +GBA also checks that both sides can still continue and latches dispatch once +per native turn. Unsubscribe and failed-entry rollback restore the empty-chain +path, which does not allocate views or dispatch this hook. Wrapper errors use +normal bus recovery; API-3 repeated `next` calls reuse downstream results. + +## Native resolution + +Every accepted HP change lands before new faints are resolved. When the last +usable player Pokemon falls, that loss owns the batch even when enemy rows +come first. Gold retains both faint announcements; GB/GBA suppress enemy +faint/EXP processing for that terminal batch. Otherwise native faint, EXP, +victory and replacement paths run normally, including GBA empty flank filling. +Field damage does not activate Gold/GBA's direct-move Destiny Bond or Grudge. + +The hook follows native status, weather, held-item and counter processing and +precedes the host's token expiry. This does not change ordinary residual +damage formulas, other battle rules or the reference engines' differing +turn schedules. The reference GBA residual collector records steps for its +controller; it is not itself a complete battle presentation/award sequence. + +## Validation + +`examples/portable_field_residual` is loaded unchanged through both SDKs. +The shared checker drives actual GB `BattleState:endOfTurn`, Gold +`Battle:takeTurn` and GBA's registered native residual scheduler, and the +host's real `BattleState:endOfTurn`. It never manually dispatches the field +hook or replaces HP/faint producers. + +Tests compare Red/Blue/Yellow, Gold/Silver/Crystal and FireRed/Emerald in four +SDK modes: API 1 native, API 2 native, API 2 unified and API 3 unified. Gen1 +API 2 has no unified opt-in, so that comparison uses its native callback. +Cases cover batches, clamping, invalid rows, detached data, native weather +expiry, callback failures/replay, GBA flanks, stale GBA callbacks, repeated +phases, terminal single/double KOs, unsubscribe, no-mod and entry rollback. +The host suites additionally exercise every registered version, DS exclusion, +stale state boundaries, non-move faint policy and real queued EXP/replacement. + +```sh +luajit tests/engine/field_residual_contract.lua +luajit tests/engine/field_residual_authority.lua +python3 tools/field-residual-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/field-residual-contract.json +``` + +The reference is pinned to `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Imported-data LÖVE verification uses `tests/drivers/field_residual.lua` and +the same installed public consumer on an isolated identity. It starts New +Game, drives native FIGHT/move input and drains actual result queues, then +checks terminal loss and GBA flank replacement. This is a bounded native +battle test, not full-playthrough or cross-engine pixel/rule parity. diff --git a/examples/portable_field_residual/main.lua b/examples/portable_field_residual/main.lua new file mode 100644 index 000000000..cecfa9730 --- /dev/null +++ b/examples/portable_field_residual/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local state = { calls = 0, mode = "none", requests = {}, faints = 0, awards = 0 } +mod.exports.api, mod.exports.state = mod, state +mod.events:on("battle.fainted", function() + state.faints = state.faints + 1 +end) +mod.events:on("battle.exp_gained", function() + state.awards = state.awards + 1 +end) +local stop = mod.hooks:wrap("battle.field_residual", function(next, context) + state.calls, state.last = state.calls + 1, context + if state.mode == "invalid" then + return state.invalid + end + if state.mode == "mutate" then + context.battlers.player.hp = 0 + context.battlers.player.types[1] = "FORGED" + context.field.tokens[1].state.intensity = 999 + end + local rows = next(context) + for _, request in ipairs(state.requests) do + rows[#rows + 1] = request + end + return rows +end) +mod.exports.stop = stop diff --git a/examples/portable_field_residual/manifest.json b/examples/portable_field_residual/manifest.json new file mode 100644 index 000000000..ffe85ed0e --- /dev/null +++ b/examples/portable_field_residual/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_field_residual", + "name": "Portable Field Residual", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2ec6c6687..b83c50193 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -6315,6 +6315,8 @@ function BattleState:holdItemTrigger(battler, when) return true end +BattleState.applyFieldResiduals = require("src.battle.FieldResiduals").apply + function BattleState:endOfTurn() -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual @@ -6415,6 +6417,7 @@ function BattleState:endOfTurn() self:holdItemTrigger(self.enemy, "turn") end self:gen3CountersTick() + self:applyFieldResiduals() self:tickTokens() self:roamerFlees() -- BATTLE ARENA: the third turn is over and both are standing -- judgment @@ -8341,7 +8344,7 @@ function BattleState:killerOf(battler) return foes[1] end -function BattleState:onFaint(battler) +function BattleState:onFaint(battler, fieldTerminal) if battler.faintQueued then return end battler.faintQueued = true -- GRUDGE: the move that did it loses every PP it had. Asked before @@ -8406,7 +8409,7 @@ function BattleState:onFaint(battler) end) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) - if not battler.isPlayer and self.kind == "wild" then + if not fieldTerminal and not battler.isPlayer and self.kind == "wild" then -- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild -- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON -- as the slide lands, BEFORE EnemyMonFaintedText and the exp text; @@ -8417,6 +8420,9 @@ function BattleState:onFaint(battler) end -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText self:sayNext(Strings("%s\nfainted!", displayName(battler))) + -- Gold announces both faints in a terminal field batch, while the player's + -- loss alone owns resolution. Do not enqueue enemy EXP, victory or replacement. + if fieldTerminal then return end -- A FAINT IN A DOUBLE BATTLE ENDS NOTHING. -- -- The two functions below are the SINGLE-battle flow, and they are shaped diff --git a/src/battle/FieldResiduals.lua b/src/battle/FieldResiduals.lua new file mode 100644 index 000000000..c00c0d665 --- /dev/null +++ b/src/battle/FieldResiduals.lua @@ -0,0 +1,265 @@ +-- Public data-only field effects. Native battle code owns HP, queues and faints. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Party = require("src.pokemon.Party") +local M = {} + +local function scalar(value) + local kind = type(value) + return kind == "string" + or kind == "boolean" + or (kind == "number" and value == value and value > -math.huge and value < math.huge) +end +local function data(value, active) + if scalar(value) then + return value + end + if type(value) ~= "table" then + return nil + end + active = active or {} + if active[value] then + return nil + end + active[value] = true + local out = {} + for key, child in next, value do + if scalar(key) then + out[key] = data(child, active) + end + end + active[value] = nil + return out +end +local function actors(battle) + local out, byId = {}, {} + for _, b in battle:activeBattlers() do + if b and b.mon then + local id = b.position + if id == nil then + id = b == battle.player and 0 or b == battle.enemy and 1 or nil + end + if id ~= nil then + out[#out + 1] = b + byId[id] = b + end + end + end + return out, byId +end +local function canContinue(battle, player) + -- A partner's party cannot keep the player's field batch alive. The GBA + -- reference checks only the player's own half in an allied battle. + if player then + return Party.firstHealthy(battle:playerPartyView()) ~= nil + end + if battle:isDouble() then + return battle:sideCanContinue(player) + end + if battle.enemy and battle.enemy.mon.hp > 0 then + return true + end + return Party.firstHealthy(battle.enemyParty or {}) ~= nil +end +local function view(b, side, id, generation) + local types = {} + for i, kind in ipairs(b.curTypes or {}) do + types[i] = generation == 3 and kind == "PSYCHIC_TYPE" and "PSYCHIC" or kind + end + if generation == 3 and types[1] and not types[2] then + types[2] = types[1] + end + local mon = b.mon + local out = { + side = side, + name = b.name, + hp = mon.hp, + maxHp = mon.stats.hp, + types = types, + vanished = not not b.invulnerable, + } + if generation == 3 then + out.battlerId = id + end + return out +end +local function weather(field, generation) + local raw = field.weather + if generation == 2 then + raw = ({ RAIN = "rain", SUN = "sun", SANDSTORM = "sandstorm" })[raw] or raw + end + return raw +end + +function M.apply(battle) + -- An empty chain must not allocate a context or inspect extension data. + if not Runtime.wantsHook("battle.field_residual") then + return + end + local gen = Version.generation() + if gen > 3 or battle.result then + return + end + local turn = battle.turnCount or 0 + if gen == 3 then + if + battle._modFieldResidualTurn == turn + or not canContinue(battle, true) + or not canContinue(battle, false) + then + return + end + battle._modFieldResidualTurn = turn + end + local active, byId = actors(battle) + local mons, snapshots = {}, {} + for _, b in ipairs(active) do + mons[b] = b.mon + end + for id, b in pairs(byId) do + local side = b.isPlayer and "player" or "enemy" + local row = view(b, side, id, gen) + if gen == 3 then + snapshots[id] = row + end + if b == battle.player then + snapshots.player = row + end + if b == battle.enemy then + snapshots.enemy = row + end + end + local game, hooks = battle.game, Runtime.hooks + local save, dataset, stack, loader = game.save, game.data, game.stack, game.mods + local stackStates, loaderGame = stack and stack.states, loader and loader.game + local stackView = {} + for i, state in ipairs(stackStates or {}) do + stackView[i] = state + end + local field = battle.field or {} + local rows = Runtime.call("battle.field_residual", function() + return {} + end, { + field = data({ weather = weather(field, gen), tokens = field.tokens or {} }), + battlers = snapshots, + turn = turn, + }) + -- Admission happens once after the chain. Context edits never become live + -- field/battler state, and a suspended/replaced battle loses HP authority. + if + type(rows) ~= "table" + or getmetatable(rows) ~= nil + or battle.result + or battle.game ~= game + or game.save ~= save + or game.data ~= dataset + or game.stack ~= stack + or (stack and stack.states ~= stackStates) + or game.mods ~= loader + or (loader and loader.game ~= loaderGame) + or Runtime.hooks ~= hooks + or (battle.turnCount or 0) ~= turn + then + return + end + if stackStates then + if #stackStates ~= #stackView then + return + end + for i, state in ipairs(stackView) do + if stackStates[i] ~= state then + return + end + end + end + local current, now = actors(battle) + if #current ~= #active then + return + end + for id, b in pairs(byId) do + if now[id] ~= b or b.mon ~= mons[b] then + return + end + end + if gen == 3 and (not canContinue(battle, true) or not canContinue(battle, false)) then + return + end + local accepted = {} + for _, row in ipairs(rows) do + if + type(row) == "table" + and getmetatable(row) == nil + and (row.side == "player" or row.side == "enemy") + then + local b = row.side == "player" and battle.player or battle.enemy + local id = row.battlerId + if id == nil then + id = row.battler + end + if gen == 3 and id ~= nil then + b = type(id) == "number" and id == math.floor(id) and id >= 0 and id <= 3 and byId[id] or nil + end + local amount = row.amount + if + b + and mons[b] + and (b.isPlayer and "player" or "enemy") == row.side + and type(amount) == "number" + and amount == amount + and amount > 0 + and amount < math.huge + and amount == math.floor(amount) + and (row.message == nil or type(row.message) == "string") + then + accepted[#accepted + 1] = { target = b, amount = amount, message = row.message } + end + end + end + local fainted = {} + for _, row in ipairs(accepted) do + local b = row.target + if b.mon.hp > 0 then + if row.message and row.message ~= "" then + battle:sayNext(row.message) + end + b.mon.hp = b.mon.hp - math.min(row.amount, b.mon.hp) + battle:drainNext(b, b.mon.hp) + if b.mon.hp == 0 then + fainted[b] = true + end + end + end + -- Decide only after the entire batch: descriptor order cannot turn a + -- terminal player blackout into an enemy EXP/replacement path. + local terminalPlayer = not canContinue(battle, true) + if terminalPlayer and next(fainted) and battle:isDouble() then + -- The native generic side check prefers an enemy wipe. This field batch + -- must give the terminal player faint authority before that check runs. + battle.sideBeaten = "player" + battle:act(function() + battle:playerMonFainted() + end) + end + if gen == 3 then + table.sort(active, function(a, b) + if a.isPlayer ~= b.isPlayer then + return a.isPlayer + end + return a.position < b.position + end) + end + for _, b in ipairs(active) do + if fainted[b] and (not terminalPlayer or b.isPlayer or gen == 2) then + if gen > 1 then + b.destinyBond, b.grudge = nil, nil + end + battle:onFaint(b, terminalPlayer and not b.isPlayer and gen == 2) + end + end + if next(fainted) and battle:isDouble() then + battle:act(function() + battle:fillEmptySlots() + end) + end +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 3cd640f71..34e4e5be9 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -35,7 +35,7 @@ add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") -add("hooks", "123", "encounter.table battle.charge_required battle.caught_marker_visible") +add("hooks", "123", "encounter.table battle.charge_required battle.caught_marker_visible battle.field_residual") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 6a30b58c3..93ef1342e 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation - battle.caught_marker_visible battle.charge_required battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.field_residual battle.caught_marker_visible battle.charge_required battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.table encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build item.use link.fingerprint map.palette movement.collision movement.speed @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.field_residual"] = { "context" }, ["battle.caught_marker_visible"] = { "context" }, ["battle.charge_required"] = { "context" }, ["encounter.table"] = { "context", "context" }, diff --git a/tests/drivers/field_residual.lua b/tests/drivers/field_residual.lua new file mode 100644 index 000000000..f217437bb --- /dev/null +++ b/tests/drivers/field_residual.lua @@ -0,0 +1,152 @@ +-- Imported-data input, native queue and faint/replacement smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_field_residual, "install field residual example") + local Pokemon, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + break + end + end + assert(species and tackle, "native imported metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function mon() + local p = Pokemon.new(game.data, species, 30) + p.hp, p.stats.hp, p.moves = 500, 500, { { id = tackle, pp = 10 } } + return p + end + local function fresh(double) + game.save.party = { mon() } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp = 500, 500 + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + if double then + local second = Battle.newWild(game, species, 30) + second.enemy.mon.hp, second.enemy.mon.stats.hp = 500, 500 + local p = mon() + second.player.mon = p + game.save.party[2] = p + b.enemyParty = { b.enemy.mon, second.enemy.mon } + b.double = true + b:placeBattler(2, second.player) + b:placeBattler(3, second.enemy) + b:syncSides() + end + b.field.tokens = { { id = "probe", state = { intensity = 4 } } } + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + e.state.calls, e.state.faints, e.state.awards = 0, 0, 0 + e.state.requests, e.state.mode = {}, "none" + return b + end + local function drain(b, settled) + for _ = 1, 4000 do + if settled and b.result then + return + end + if not settled and b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native field/faint result queue did not settle") + end + local b = fresh() + e.state.requests = { + { side = "player", amount = 3, message = "Player field damage" }, + { side = "enemy", amount = 7, message = "Enemy field damage" }, + } + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT did not open move selection") + U.tap(game, "a") + drain(b) + local ctx = assert(e.state.last) + assert(e.state.calls == 1 and ctx.turn == 1, "real turn must dispatch field once") + assert(b.player.mon.hp == ctx.battlers.player.hp - 3, "native turn player field HP") + assert(b.enemy.mon.hp == ctx.battlers.enemy.hp - 7, "native turn enemy field HP") + b = fresh() + e.state.mode = "mutate" + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b.player.mon.hp == 500, "detached snapshot edit changed live HP") + assert(b.field.tokens[1].state.intensity == 4, "detached token edit changed live field") + for _, reverse in ipairs({ false, true }) do + b = fresh() + local p, n = { side = "player", amount = 99999 }, { side = "enemy", amount = 99999 } + e.state.requests = reverse and { n, p } or { p, n } + b:endOfTurn() + b.phase = "messages" + drain(b, true) + assert(b.result == "lose", "native terminal field batch must lose") + assert(e.state.awards == 0, "native terminal field batch must not award EXP") + assert(e.state.faints == (V.generation() == 2 and 2 or 1), "native generation faint announcements") + -- Stop at the in-battle result; never run finish/blackout/save processing. + end + if V.generation() == 3 then + b = fresh(true) + local reserve = mon() + b.enemyParty[3] = reserve + e.state.requests = { { side = "enemy", battlerId = 3, amount = 99999 } } + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b:battlerAt(3).mon == reserve, "native GBA field KO must fill empty flank") + assert(e.state.faints == 1 and e.state.awards > 0, "native GBA flank faint/EXP") + b = fresh(true) + for id = 0, 3 do + e.state.requests[#e.state.requests + 1] = { + side = id % 2 == 0 and "player" or "enemy", + battlerId = id, + amount = 99999, + } + end + b:endOfTurn() + b.phase = "messages" + drain(b, true) + assert( + b.result == "lose" and e.state.awards == 0 and e.state.faints == 2, + "native GBA simultaneous double KO must have player loss authority" + ) + end + b = fresh() + e.stop() + b:endOfTurn() + assert(e.state.calls == 0 and b.enemy.mon.hp == 500, "native unsubscribe") + print("PORTABILITY_FIELD_RESIDUAL_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/field_residual_authority.lua b/tests/engine/field_residual_authority.lua new file mode 100644 index 000000000..d2cea25b2 --- /dev/null +++ b/tests/engine/field_residual_authority.lua @@ -0,0 +1,167 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture = dofile("tests/field_residual/fixture.lua") +local copy = require("src.mods.Merge").deepCopy +local old = V.get() +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e = f.e + local mutations = { + turn = function(b) + b.turnCount = b.turnCount + 1 + end, + result = function(b) + b.result = "run" + end, + save = function(b) + b.game.save = copy(b.game.save) + end, + data = function(b) + b.game.data = {} + end, + stack = function(b) + b.game.stack = { states = {} } + end, + states = function(b) + b.game.stack.states = {} + end, + pop = function(b) + table.remove(b.game.stack.states) + end, + cover = function(b) + table.insert(b.game.stack.states, {}) + end, + replaceTop = function(b) + b.game.stack.states[1] = {} + end, + loader = function(b) + b.game.mods = {} + end, + loaderGame = function(b) + b.game.mods.game = {} + end, + hooks = function() + Runtime.hooks = {} + end, + actor = function(b) + b:placeBattler(1, copy(b.enemy)) + end, + mon = function(b) + b.enemy.mon = copy(b.enemy.mon) + end, + game = function(b) + b.game = { save = b.game.save, data = b.game.data, stack = b.game.stack } + end, + } + for name, change in pairs(mutations) do + local b = f.reset() + local player, enemy = b.player.mon, b.enemy.mon + e.state.requests = { { side = "enemy", amount = 4 }, { side = "player", amount = 3 } } + local hooks = Runtime.hooks + local off = e.api.hooks:wrap("battle.field_residual", function(next, ctx) + local rows = next(ctx) + change(b) + return rows + end, 100) + -- Exercise admission through the actual native end-of-turn producer. + b:endOfTurn() + Runtime.hooks = hooks + off() + T.eq(player.hp, 400, name .. " invalidates field player authority") + T.eq(enemy.hp, 400, name .. " invalidates field enemy authority") + T.eq(e.state.calls, 1, name .. " ran the genuine callback") + f.game.data, f.game.mods, f.result.loader.game = f.data, f.result.loader, f.game + f.game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + f.game.stack:init() + end + local b = f.reset() + b.field.tokens[1].state.cycle = b.field.tokens[1].state + b.field.tokens[1].state.bad = math.huge + b.field.tokens[1].state.fn = function() + error("private") + end + b.field.tokens[1].state[function() end] = "private key" + e.state.mode = "mutate" + f.run() + T.eq(e.state.last.field.tokens[1].state.cycle, nil, "cycles are removed from public field data") + T.eq(e.state.last.field.tokens[1].state.bad, nil, "nonfinite field data is removed") + T.eq(e.state.last.field.tokens[1].state.fn, nil, "callbacks are removed recursively") + T.eq(b.player.curTypes[1], "NORMAL", "snapshot type edits cannot change native types") + b = f.reset() + e.state.mode, e.state.invalid = + "invalid", setmetatable({}, { + __index = function() + error("private") + end, + }) + f.run() + T.eq(f.hp("enemy"), 400, "metatable result is never executed") + b = f.reset() + e.state.requests = { setmetatable({ side = "enemy", amount = 3 }, {}) } + f.run() + T.eq(f.hp("enemy"), 400, "metatable descriptor is rejected") + -- Exercise the real native faint/EXP queue instead of replacing onFaint. + b = f.reset() + if f.gen > 1 then + b.enemy.destinyBond, b.enemy.grudge = true, true + end + b.player.lastMove = f.ids.TACKLE + local pp = b.player.curMoves[1].pp + e.state.requests = { { side = "enemy", amount = 99999 } } + f.run() + f.drain() + T.eq(f.hp("player"), 400, "field KO is not direct-move Destiny Bond damage") + T.eq(b.player.curMoves[1].pp, pp, "field KO is not direct-move Grudge damage") + T.eq(e.state.faints, 1, "native field faint emits once") + T.check(e.state.awards > 0, "native surviving player receives EXP after field KO") + if f.gen == 3 then + b = f.reset({ double = true }) + local reserve = copy(b.enemy.mon) + b.enemyParty[#b.enemyParty + 1] = reserve + e.state.requests = { { side = "enemy", battlerId = 3, amount = 99999 } } + f.run() + f.drain() + T.eq(b:battlerAt(3).mon, reserve, "native end-of-turn queue fills field KO's empty flank") + T.eq(b.result, nil, "living double side keeps battle active") + T.eq(e.state.faints, 1, "native double flank faint emits once") + T.check(e.state.awards > 0, "native double flank faint awards EXP once") + b = f.reset({ double = true }) + e.state.requests = { + { side = "enemy", battlerId = 1, amount = 99999 }, + { side = "enemy", battlerId = 3, amount = 99999 }, + } + f.run() + f.drain() + T.eq(b.result, "win", "native doubles field wipe resolves victory") + T.eq(e.state.faints, 2, "native doubles field wipe announces both foes") + T.eq(e.state.awards, 2, "native doubles field wipe awards each foe once") + for _, reverse in ipairs({ false, true }) do + b = f.reset({ double = true }) + local requests = {} + for id = 0, 3 do + requests[#requests + 1] = { + side = id % 2 == 0 and "player" or "enemy", + battlerId = reverse and 3 - id or id, + amount = 99999, + } + end + if reverse then + for _, row in ipairs(requests) do + row.side = row.battlerId % 2 == 0 and "player" or "enemy" + end + end + e.state.requests = requests + f.run() + f.drain() + T.eq(b.result, "lose", "terminal doubles batch gives player loss authority") + T.eq(e.state.awards, 0, "terminal doubles never awards enemy EXP") + T.eq(e.state.faints, 2, "terminal doubles announces player slots only") + end + end + f.close() + end +end +V.set(old) +T.finish("field residual admission, detached data and native faint authority") diff --git a/tests/engine/field_residual_contract.lua b/tests/engine/field_residual_contract.lua new file mode 100644 index 000000000..5b13d05e2 --- /dev/null +++ b/tests/engine/field_residual_contract.lua @@ -0,0 +1,48 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/field_residual/fixture.lua"), dofile("tests/field_residual/cases.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq(f.e.api:supports("hooks", "battle.field_residual"), f.gen <= 3, "honest native field capability") + if f.gen <= 3 then + Cases.run(f) + else + f.e.state.requests = { { side = "player", amount = 99999 } } + f.run() + T.eq(f.e.state.calls, 0, "DS never dispatches unavailable field hook") + T.eq(f.hp("player"), 400, "DS ignores unavailable field requests") + end + f.close() + for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#bare.result.errors > 0, "entry failure recorded") + else + T.eq(bare.result.loader.exports.portable_field_residual, nil, "genuine no-mod fixture") + end + for _, link in ipairs(Runtime.hooks.chains["battle.field_residual"] or {}) do + T.check(link.owner ~= "portable_field_residual", "rollback removes field owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.field_residual", "empty field hook chain dispatched") + return call(name, ...) + end + require("src.battle.FieldResiduals").apply(setmetatable({}, { + __index = function() + error("empty-chain field path inspected battle") + end, + })) + bare.run() + Runtime.call = call + T.eq(bare.hp("player"), 400, "no-mod/rollback native player HP") + T.eq(bare.hp("enemy"), 400, "no-mod/rollback native enemy HP") + bare.close() + end + end +end +V.set(old) +T.finish("native field residuals and public SDK contracts") diff --git a/tests/field_residual/cases.lua b/tests/field_residual/cases.lua new file mode 100644 index 000000000..16dd4f45d --- /dev/null +++ b/tests/field_residual/cases.lua @@ -0,0 +1,264 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, player, enemy) + f.run() + T.eq(e.state.calls, 1, "ordinary native residual phase invokes public hook once") + local ctx = e.state.last + T.eq(f.hp("player"), ctx.battlers.player.hp - player, "native player HP follows accepted descriptor") + T.eq(f.hp("enemy"), ctx.battlers.enemy.hp - enemy, "native enemy HP follows accepted descriptor") + for _, side in ipairs({ "player", "enemy" }) do + T.eq(ctx.battlers[side].name, "PROBE", "native display name in detached snapshot") + T.eq(ctx.battlers[side].maxHp, 400, "native maximum HP in detached snapshot") + end + T.eq(ctx.turn, 1, "current native turn in detached field context") + T.eq(ctx.battle, nil, "field context contains no live battle handle") + T.eq(ctx.field.tokens[1].onExpire, nil, "field callback is absent") + T.eq(ctx.field.tokens[1].state.intensity, 4, "portable extension data survives") + rows[#rows + 1] = { + label = label, + playerDamage = player, + enemyDamage = enemy, + turn = ctx.turn, + tokens = ctx.field.tokens[1].state.intensity, + playerSide = ctx.battlers.player.side, + enemySide = ctx.battlers.enemy.side, + types = ctx.battlers.player.types, + } + end + observe("default", 0, 0) + f.reset() + e.state.calls = 0 + e.state.requests = { + { side = "enemy", amount = 3, message = "Field enemy" }, + { side = "player", amount = 5, message = "Field player" }, + { side = "enemy", amount = 2 }, + } + observe("batch", 5, 5) + f.reset() + e.state.calls = 0 + e.state.requests = { + { side = "enemy", amount = -1 }, + { side = "player", amount = 1.5 }, + { side = "enemy", amount = math.huge }, + { side = "enemy", amount = 0 / 0 }, + { side = "other", amount = 1 }, + { side = "player", amount = 1, message = {} }, + { side = "enemy", amount = 4, message = "" }, + } + observe("validation", 0, 4) + f.reset() + e.state.calls = 0 + e.state.mode, e.state.invalid = "invalid", false + observe("non table", 0, 0) + f.reset() + e.state.calls = 0 + e.state.mode, e.state.invalid = "invalid", nil + observe("nil", 0, 0) + for _, after in ipairs({ false, true }) do + f.reset() + e.state.calls = 0 + local off = e.api.hooks:wrap("battle.field_residual", function(next, ctx) + if after then + next(ctx) + end + error("intentional field wrapper failure") + end, 100) + e.state.requests = { { side = "enemy", amount = 1 } } + observe("error:" .. tostring(after), 0, 1) + off() + end + if f.api >= 3 then + f.reset() + e.state.calls = 0 + local off = e.api.hooks:wrap("battle.field_residual", function(next, ctx) + local a, b = next(ctx), next(ctx) + T.eq(a, b, "API-3 repeated next returns same downstream result") + return a + end, 100) + e.state.requests = { { side = "player", amount = 1 } } + observe("replay", 1, 0) + off() + end + f.reset() + e.state.calls = 0 + e.state.mode = "mutate" + f.run() + T.check(f.hp("player") > 0, "context HP mutation cannot replace native HP") + T.eq(f.battle.field.tokens[1].state.intensity, 4, "nested token mutation stays detached") + rows[#rows + 1] = + { label = "detached", live = f.hp("player") > 0, tokens = f.battle.field.tokens[1].state.intensity } + if f.gen > 1 then + for _, expiry in ipairs({ false, true }) do + f.reset() + local b = f.battle + if f.backend == "gen1" then + b.weather, b.weatherTurns = f.gen == 2 and "sun" or "SUN", expiry and 1 or 3 + else + b.field.weather, b.field.weatherTurns = "SUN", expiry and 1 or 3 + end + f.run() + local expected = not expiry and (f.gen == 2 and "sun" or "SUN") or nil + T.eq(e.state.last.field.weather, expected, "field hook observes native weather upkeep first") + rows[#rows + 1] = { label = "weather:" .. tostring(expiry), weather = e.state.last.field.weather } + end + end + f.reset() + e.state.requests = { { side = "enemy", amount = 99999, message = "Field KO" } } + f.run() + T.eq(f.hp("enemy"), 0, "field damage clamps at zero") + T.eq(e.state.faints, 1, "native field KO emits faint once") + rows[#rows + 1] = { label = "enemy KO", enemy = f.hp("enemy"), faints = e.state.faints } + if f.gen == 3 then + f.reset() + if f.backend == "gen1" then + f.battle.player.type1, f.battle.player.type2 = 14, 14 + else + f.battle.player.curTypes = { "PSYCHIC_TYPE" } + end + f.run() + T.same(e.state.last.battlers.player.types, { "PSYCHIC", "PSYCHIC" }, "GBA native Psychic type pair") + rows[#rows + 1] = { label = "psychic", types = e.state.last.battlers.player.types } + f.reset({ double = true }) + e.state.requests = { + { side = "player", battlerId = 2, amount = 7 }, + { side = "enemy", battler = 3, amount = 9 }, + { side = "player", battlerId = 3, amount = 80 }, + { side = "enemy", battlerId = 2, amount = 80 }, + { side = "enemy", battlerId = "3", amount = 80 }, + { side = "enemy", battlerId = 4, amount = 80 }, + { side = "enemy", amount = 2 }, + } + f.run() + local ctx = e.state.last + for id = 0, 3 do + T.eq(ctx.battlers[id].battlerId, id, "GBA snapshot carries actual native slot ID") + T.eq(f.actor(id).mon.hp, 400 - ({ [0] = 0, 2, 7, 9 })[id], "GBA descriptors target correct flank") + end + T.eq(ctx.battlers.player.battlerId, 0, "player alias is left flank") + T.eq(ctx.battlers.enemy.battlerId, 1, "enemy alias is left flank") + f.run() + T.eq(e.state.calls, 1, "GBA same native turn dispatches once") + T.eq(f.actor(2).mon.hp, 393, "GBA duplicate phase cannot replay damage") + rows[#rows + 1] = { + label = "doubles", + calls = e.state.calls, + player = f.actor(2).mon.hp, + enemy = f.actor(3).mon.hp, + left = f.actor(1).mon.hp, + } + for _, reverse in ipairs({ false, true }) do + f.reset({ double = true }) + e.state.requests = {} + local mons = {} + for id = 0, 3 do + mons[id] = f.actor(id).mon + local target = reverse and 3 - id or id + e.state.requests[#e.state.requests + 1] = + { side = target % 2 == 0 and "player" or "enemy", battlerId = target, amount = 99999 } + end + f.run() + for id = 0, 3 do + T.eq(mons[id].hp, 0, "native terminal double batch settles every target") + end + T.eq(e.state.faints, 2, "native terminal double batch emits only player faints") + T.eq(e.state.awards, 0, "native terminal double batch cannot award enemy EXP") + rows[#rows + 1] = { + label = "double terminal:" .. tostring(reverse), + faints = e.state.faints, + awards = e.state.awards, + } + end + for _, ownDown in ipairs({ false, true }) do + local b = f.reset({ double = true }) + if f.backend == "gen1" then + b.playerHalf = 1 + else + b.ally = { party = { f.actor(2).mon } } + b.playerParty = { f.actor(0).mon } + f.game.save.party = b.playerParty + end + if ownDown then + f.actor(0).mon.hp = 0 + end + e.state.requests = { + { side = "player", battlerId = 0, amount = 99999 }, + { side = "enemy", battlerId = 1, amount = 99999 }, + { side = "enemy", battlerId = 3, amount = 99999 }, + } + f.run() + T.eq(e.state.calls, ownDown and 0 or 1, "GBA field authority checks player's own party") + T.eq(f.actor(2).mon.hp, 400, "GBA partner survives player's own-party terminal batch") + T.eq(e.state.awards, 0, "GBA partner cannot authorize terminal enemy EXP") + if not ownDown then + T.eq(e.state.faints, 1, "GBA own-party terminal batch suppresses enemy faint processing") + if f.backend == "gen2" then + f.drain() + T.eq(b.result, "lose", "native ally cannot prevent own-party field loss") + end + end + rows[#rows + 1] = { + label = "allied:" .. tostring(ownDown), + calls = e.state.calls, + partner = 400, + awards = e.state.awards, + } + end + for _, what in ipairs({ "turn", "mon", "hooks" }) do + f.reset({ double = true }) + local Runtime = require("src.mods.Runtime") + local hooks = Runtime.hooks + local off = e.api.hooks:wrap("battle.field_residual", function(next, ctx) + local result = next(ctx) + if what == "turn" then + if f.backend == "gen1" then + f.battle.turn = f.battle.turn + 1 + else + f.battle.turnCount = f.battle.turnCount + 1 + end + elseif what == "mon" then + f.actor(2).mon = require("src.mods.Merge").deepCopy(f.actor(2).mon) + else + Runtime.hooks = {} + end + return result + end, 100) + e.state.requests = { { side = "player", battlerId = 2, amount = 8 } } + f.run() + Runtime.hooks = hooks + off() + T.eq(f.actor(2).mon.hp, 400, "GBA stale callback cannot damage replacement/next turn") + rows[#rows + 1] = { label = "stale:" .. what, hp = f.actor(2).mon.hp } + end + end + for _, reverse in ipairs({ false, true }) do + f.reset() + local p, n = { side = "player", amount = 99999 }, { side = "enemy", amount = 99999 } + e.state.requests = reverse and { n, p } or { p, n } + f.run() + T.eq(f.hp("player"), 0, "terminal batch clamps player HP") + T.eq(f.hp("enemy"), 0, "terminal batch clamps enemy HP") + f.drain() + T.eq(e.state.awards, 0, "terminal field batch never awards enemy EXP") + T.eq(e.state.faints, f.gen == 2 and 2 or 1, "native terminal faint announcements") + if f.backend == "gen2" or f.gen < 3 then + T.eq(f.battle.result or f.battle.outcome, "lose", "native terminal field batch resolves as loss") + end + rows[#rows + 1] = { + label = "terminal:" .. tostring(reverse), + player = f.hp("player"), + enemy = f.hp("enemy"), + awards = e.state.awards, + faints = e.state.faints, + } + end + e.stop() + e.stop() + f.reset() + local calls = e.state.calls + f.run() + T.eq(e.state.calls, calls, "unsubscribe removes field callback") + rows[#rows + 1] = { label = "unsubscribed", calls = e.state.calls - calls } + return rows +end +return M diff --git a/tests/field_residual/fixture.lua b/tests/field_residual/fixture.lua new file mode 100644 index 000000000..056be654d --- /dev/null +++ b/tests/field_residual/fixture.lua @@ -0,0 +1,136 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + noMod, + failing, + "portable_field_residual" + ) + local source = f.fresh + local b + f.game.mods = f.result.loader + function f.reset(opts) + opts = opts or {} + if backend == "gen2" and f.gen == 2 then + f.data.pokemon.CHARMANDER.types = { "NORMAL", "NORMAL" } + end + b = source("TACKLE").battle + if opts.double then + assert(f.gen == 3, "double field fixture requires GBA") + if backend == "gen1" then + local copy = require("src.mods.Merge").deepCopy + b = require("src.core.game3.battle.state").new({ + double = true, + wild = true, + playerParty = { b.player.mon, copy(b.player.mon) }, + foeParty = { b.enemy.mon, copy(b.enemy.mon) }, + }) + b.rng = function(lo) + return lo or 0 + end + else + local extra = source("TACKLE").battle + f.game.save.party = { b.player.mon, extra.player.mon } + b.playerParty = f.game.save.party + b.enemyParty = { b.enemy.mon, extra.enemy.mon } + b.double = true + b:placeBattler(2, extra.player) + b:placeBattler(3, extra.enemy) + b:syncSides() + end + end + f.game.save.party = backend == "gen1" and f.gen == 2 and b.party or f.game.save.party + b.field = b.field or {} + b.field.tokens = { + { + id = "portable", + state = { intensity = 4 }, + onExpire = function() + error("private callback") + end, + }, + } + b.turnCount, b.turn = 1, backend == "gen1" and f.gen == 2 and 0 or 1 + b.phase, b.queue = "menu", {} + local active = {} + if backend == "gen1" and f.gen == 3 then + active = require("src.core.game3.battle.adapter").new(b):activeBattlers() + elseif backend == "gen1" then + active = { b.player, b.enemy } + else + for _, actor in b:activeBattlers() do + active[#active + 1] = actor + end + end + for _, actor in ipairs(active) do + local mon = backend == "gen1" and f.gen == 2 and actor or actor.mon + mon.nickname, mon.maxHp, mon.hp = "PROBE", 400, 400 + actor.name = "PROBE" + if backend == "gen1" and f.gen == 3 then + actor.type1, actor.type2 = 0, 0 + end + end + f.battle = b + if backend == "gen2" or f.gen == 1 then + f.game.stack.states = { b } + end + if f.e then + f.e.state.requests, f.e.state.mode = {}, "none" + f.e.state.calls, f.e.state.faints, f.e.state.awards = 0, 0, 0 + end + return b + end + function f.actor(id) + if backend == "gen1" and f.gen == 3 then + return require("src.core.game3.battle.state").battler(b, id) + end + return b:battlerAt(id) + end + function f.hp(side) + local actor = b[side] + return backend == "gen1" and f.gen == 2 and actor.hp or actor.mon.hp + end + function f.setHP(side, value) + local actor = b[side] + if backend == "gen1" and f.gen == 2 then + actor.hp = value + else + actor.mon.hp = value + end + end + function f.run() + if backend == "gen1" and f.gen == 2 then + f.events = b:takeTurn({ kind = "skip" }) + elseif backend == "gen1" and f.gen == 3 then + require("src.core.game3.battle.engine") + f.events = + require("src.core.game3.battle.residuals").runTurn(require("src.core.game3.battle.adapter").new(b)) + else + b:endOfTurn() + end + return f.events + end + function f.drain() + if backend == "gen1" and f.gen > 1 then + return + end + for _ = 1, 2000 do + local row = table.remove(b.queue, 1) + if not row then + return + end + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + error("native field/faint queue did not terminate") + end + f.reset() + return f +end +return M diff --git a/tests/field_residual/worker.lua b/tests/field_residual/worker.lua new file mode 100644 index 000000000..e18ec5309 --- /dev/null +++ b/tests/field_residual/worker.lua @@ -0,0 +1,35 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = + dofile(root .. "/tests/field_residual/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/field_residual/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local bare = dofile(root .. "/tests/field_residual/fixture.lua").new( + root, + backend, + version, + tonumber(api), + format, + not failing, + failing + ) + if failing then + bare.T.check(#bare.result.errors > 0, "failed entry recorded") + else + bare.T.eq(bare.result.loader.exports.portable_field_residual, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.field_residual"] or {}) do + bare.T.check(link.owner ~= "portable_field_residual", "rollback removes field owner") + end + bare.run() + bare.T.eq(bare.hp("enemy"), 400, "no-mod/rollback preserves native enemy HP") + bare.T.check(bare.hp("player") > 0, "no-mod/rollback preserves living native player") + if backend ~= "gen1" or bare.gen ~= 2 then + bare.T.eq(bare.hp("player"), 400, "no-mod/rollback adds no field damage") + end + bare.close() + rows[#rows + 1] = { label = failing and "rollback" or "no mod", native = true } +end +print("FIELD_RESIDUAL_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native field residuals") diff --git a/tools/field-residual-contract-check/README.md b/tools/field-residual-contract-check/README.md new file mode 100644 index 000000000..de42b6485 --- /dev/null +++ b/tools/field-residual-contract-check/README.md @@ -0,0 +1,33 @@ +# Native field-residual contract comparison + +Run from a complete Gen2Recomped checkout with LuaJIT and Python 3: + +```sh +python3 tools/field-residual-contract-check/check.py \ + --gen1 /path/to/gen1 --api 3 --format unified \ + --report /tmp/field-residual-contract.json +``` + +The clean Gen1Recomp checkout must be at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. It is read only; tests come +from the host checkout and load the same public example entry via each real +SDK. No ROM, cartridge cache, private mod or network access is needed. + +The checker runs one isolated worker per engine/version across +Red/Blue/Yellow, Gold/Silver/Crystal and FireRed/Emerald. Use `--api 1 --format +native`, `--api 2 --format native`, `--api 2 --format unified`, and `--api 3 +--format unified` for the complete compatibility matrix. API 2 unified uses +the source engine's native callbacks because its SDK has no such opt-in. + +Workers execute real native end-of-turn/residual code, including registration +of GBA ordinary residual handlers, rather than calling Runtime manually. +Canonical observations compare public context and descriptor decisions; +ordinary native move damage is measured relative to the callback snapshot. +Gold's full native turn includes an enemy move, whereas GBA's collector +records residual steps without running the full presentation/award controller. +Neither comparison establishes whole-battle rule or pixel equivalence. + +Host-only authority tests additionally drain real native EXP/faint/replacement +queues and cover all registered versions and stale state boundaries. JSON +reports record the reference/runtime refs, dirty-worktree status, consumer +hash, observations and assertion totals. They contain no cartridge data. diff --git a/tools/field-residual-contract-check/check.py b/tools/field-residual-contract-check/check.py new file mode 100644 index 000000000..ae7e38dc8 --- /dev/null +++ b/tools/field-residual-contract-check/check.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Compare one unchanged public field-residual consumer through both native residual phases.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/field_residual/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("FIELD_RESIDUAL_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("FIELD_RESIDUAL_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native field residual decision", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"field residual mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native field residual observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_field_residual/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 1ce83faf4b980584be9d747eee82efc530ede09f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:20:40 +0200 Subject: [PATCH 47/86] Add portable held-item reads and native cross-engine contracts --- .github/workflows/held-item-contract.yml | 45 +++ docs/api3-portability.md | 3 + docs/portable-held-items.md | 113 +++++++ examples/portable_held_item/main.lua | 26 ++ examples/portable_held_item/manifest.json | 8 + src/battle/BattleState.lua | 57 ++-- src/battle/Damage.lua | 2 +- src/battle/HeldItemRead.lua | 195 +++++++++++++ src/battle/HeldItems.lua | 53 ++-- src/battle/HoldItems.lua | 36 ++- src/battle/MoveEffects.lua | 3 + src/battle/TurnOrder.lua | 14 +- src/mods/CallbackRecords.lua | 23 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- tests/charge_required/fixture.lua | 3 +- tests/drivers/held_item.lua | 129 ++++++++ tests/engine/held_item_authority.lua | 73 +++++ tests/engine/held_item_contract.lua | 43 +++ tests/held_item/cases.lua | 340 ++++++++++++++++++++++ tests/held_item/fixture.lua | 315 ++++++++++++++++++++ tests/held_item/worker.lua | 38 +++ tests/modkit/catalog.lua | 8 +- tools/held-item-contract-check/README.md | 31 ++ tools/held-item-contract-check/check.py | 71 +++++ 25 files changed, 1567 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/held-item-contract.yml create mode 100644 docs/portable-held-items.md create mode 100644 examples/portable_held_item/main.lua create mode 100644 examples/portable_held_item/manifest.json create mode 100644 src/battle/HeldItemRead.lua create mode 100644 tests/drivers/held_item.lua create mode 100644 tests/engine/held_item_authority.lua create mode 100644 tests/engine/held_item_contract.lua create mode 100644 tests/held_item/cases.lua create mode 100644 tests/held_item/fixture.lua create mode 100644 tests/held_item/worker.lua create mode 100644 tools/held-item-contract-check/README.md create mode 100644 tools/held-item-contract-check/check.py diff --git a/.github/workflows/held-item-contract.yml b/.github/workflows/held-item-contract.yml new file mode 100644 index 000000000..30f9eb0c3 --- /dev/null +++ b/.github/workflows/held-item-contract.yml @@ -0,0 +1,45 @@ +name: portable held-item contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: held-item-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native held readers, metadata authority and SDK compatibility + run: | + luajit tests/engine/held_item_contract.lua + luajit tests/engine/held_item_authority.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/held-item-contract + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/held-item-contract/api1-native.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/held-item-contract/api2-native.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/held-item-contract/api2-unified.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/held-item-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: held-item-contract + path: /tmp/held-item-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 05e63458f..f33cbf5e1 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -167,3 +167,6 @@ native ownership gates, API-3 contexts and GBA presentation caching. [Portable field residuals](portable-field-residuals.md) document detached end-of-turn views, validated damage batches and native faint/EXP authority. + +[Portable held-item reads](portable-held-items.md) document shared `HELD_*` +names, byte odds, native consumers and the remaining GBA scheduling gap. diff --git a/docs/portable-held-items.md b/docs/portable-held-items.md new file mode 100644 index 000000000..2f92e24cf --- /dev/null +++ b/docs/portable-held-items.md @@ -0,0 +1,113 @@ +# Portable held-effect decisions + +`held_item.trigger(next, ctx)` wraps a native held-effect **read** on Gen 2/3 +hosts. It lets a mod suppress an effect, substitute another `HELD_*` name, or +change its parameter. Native code still owns rolls, damage/healing, messages, +item consumption and battle queues. GB/DS report this seam unavailable. + +```lua +local mod = ... +if mod:supports("hooks", "held_item.trigger") then + mod.hooks:wrap("held_item.trigger", function(next, ctx) + if ctx.item == "LEFTOVERS" and ctx.trigger == "residual" then + return "HELD_BERRY", 7 + end + return next(ctx) + end) +end +``` + +Use API 3 for common item/Pokemon IDs. The unchanged +`examples/portable_held_item` consumer also runs through legacy/native and +API-2 unified adapters. Legacy IDs and other engine objects retain their +native representations. An opaque `ctx.battle` is the actual controller; +`ctx.mon` is the holder's Pokemon. Constructors, external/link battlers and +replacements bind to their current battle without adding a public engine +handle to Pokemon records. + +The context also has `item`, `def`, `effect`, `parameter` and `trigger`. +`def` is absent for an empty hand; an ordinary held item with no native effect +still has a definition. The public effect names include `HELD_BERRY`, +`HELD_LEFTOVERS`, `HELD_QUICK_CLAW`, `HELD_BRIGHTPOWDER`, `HELD_FOCUS_BAND`, +`HELD_FLINCH`, `HELD_CRITICAL_UP`, `HELD_HEAL_*` and the `HELD_*_BOOST` family. +GBA's Scope Lens and pinch-critical berry remain distinct (`HELD_CRITICAL_UP` +and `HELD_PINCH_CRITICAL_UP`). All 66 GBA native enum names are covered. + +`next(ctx)` returns `ctx.effect, ctx.parameter`. A non-string effect suppresses +it. An omitted/non-numeric parameter falls back to the native parameter; zero +is meaningful and is preserved. Mutating `ctx.effect` / `ctx.parameter` before +`next` is supported. GBA probability parameters for Quick Claw, BrightPowder, +Focus Band and King's Rock use the common byte scale: the view is +`floor(nativePercent * 256 / 100)`. An unchanged forwarded pair retains its +exact native percentage; changed odds clamp to 0–256 before converting back. +Other native parameter meanings remain generation-specific. + +Native comparison reasons include `priority`, `damage`, `endure`, `flinch`, +`accuracy`, `confuse`, `residual` and `check`. Gold also uses `status` for +immediate cures and `pp` for MysteryBerry; GBA escape uses `run`. A read is not +an activation event: calls may repeat and an effect can be ineligible after +it is read. Filter by the actual item/effect and trigger rather than counting +calls. Field/party scans are not battle hook producers. + +API-3 definition scalar metadata retains the reference's transaction behavior. +`def.id` uses the common item identity; `def.heldEffect` / `def.heldParameter` +use the public names/parameters. Valid changes are visible to downstream +callbacks. Gold writes to its live item definition, so those metadata changes +persist; GBA supplies a temporary info record and discards them after the read. +The already captured `ctx.effect` is independent of definition edits. Use the +returned pair to steer the current comparison and `mod.content` for deliberate +content changes. Native diagnostic snapshots are not a portable write surface. +Invalid transactional definition edits cannot partially commit Pokemon edits. + +No subscriber means no hook dispatch or context construction. Unsubscribe and +entry rollback restore native reads. Wrapper errors retain normal bus recovery; +API-3 repeated `next` calls reuse both downstream results. + +## Native integration and boundaries + +The hook is wired through the actual priority, critical/type/stat damage, +accuracy, lethal-hit endurance, King's Rock, confusion, PP and residual readers. +GBA also reads it for Macho Brace speed, Shell Bell, held escape, entry prize +multipliers and White Herb. Gold Focus Band now reaches the direct-damage +path, including a holder already on 1 HP. Gold's Berry threshold is strictly +below half HP, matching the reference; GBA retains at-half activation. The GBA +100%-accuracy shortcut no longer bypasses a held accuracy reduction. + +This is a held-read contract, not complete native battle equivalence. Other +move/rule differences and GBA's full Choice Band locking remain separate work. +The source GBA residual scheduler includes extra after-action item scans. A +hook that grants a Berry even to **empty hands** can heal repeatedly there +(reference observation: 35 HP versus the host's 7 for a 7-HP override). The +checker records this as `native_policy` and prints `NATIVE SCHEDULE GAP`; it +is excluded from equal contract observations, never reported as equivalent. +`--require-native-schedule-parity` intentionally fails on that known gap. +The public example defaults to requiring an actual held item, so consuming it +ends the override on both engines. Counting callback invocations, unrestricted +empty-hand grants and identical residual schedules are not established. + +## Validation + +```sh +luajit tests/engine/held_item_contract.lua +luajit tests/engine/held_item_authority.lua +python3 tools/held-item-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/held-item-contract.json +``` + +The reference is clean and pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Gold/Silver/Crystal and +FireRed/Emerald run the same consumer via real SDKs and native held readers, +actual native moves/order, and registered residual code. The producer and +native effect handlers are not replaced or dispatched manually. Extractor- +shaped fixture items also cover all 66 GBA enums through the actual item-data +fallback reader. Four SDK modes are compared; Gen1 API 2 has no unified opt-in, +so that comparison uses its native callback. + +The host suites additionally cover registered generations, replacement/link +binding, no-mod/entry rollback, byte-odds clamping, field/party isolation and +metadata transactions. `tests/drivers/held_item.lua` checks the installed public +consumer against imported Gold/FireRed/Emerald in isolated LÖVE: native +FIGHT/move input and result queues, HP/consumption, endurance and GBA entry/RUN. +It starts New Game and constructs bounded in-memory battles; it is not a full +playthrough or cross-engine pixel-equivalence proof. diff --git a/examples/portable_held_item/main.lua b/examples/portable_held_item/main.lua new file mode 100644 index 000000000..3166d4d8f --- /dev/null +++ b/examples/portable_held_item/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local state = { mode = "delegate", calls = 0, seen = {}, views = {}, requiresItem = true } +mod.exports.api, mod.exports.state = mod, state +mod.exports.stop = mod.hooks:wrap("held_item.trigger", function(next, context) + state.calls = state.calls + 1 + state.last = context + state.seen[context.trigger] = (state.seen[context.trigger] or 0) + 1 + state.views[context.trigger] = context + if + (not state.trigger or state.trigger == context.trigger) + and (not state.holder or state.holder == context.mon.nickname) + and (not state.requiresItem or context.def ~= nil) + then + state.selected = context + if state.mode == "suppress" then + return nil + end + if state.mode == "override" then + return state.effect, state.parameter + end + if state.mode == "mutate" then + context.effect, context.parameter = state.effect, state.parameter + end + end + return next(context) +end) diff --git a/examples/portable_held_item/manifest.json b/examples/portable_held_item/manifest.json new file mode 100644 index 000000000..a1016b81d --- /dev/null +++ b/examples/portable_held_item/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_held_item", + "name": "Portable Held Item Decisions", + "version": "1.0.0", + "api": 3, + "games": ["gen2", "gen3"], + "entry": "main.lua" +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b83c50193..690025d94 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1016,7 +1016,7 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) +local function makeBattler(data, mon, isPlayer, save, battle) local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil @@ -1028,7 +1028,7 @@ local function makeBattler(data, mon, isPlayer, save) if save.inventory[row.badge] then badges[row.badge] = true end end end - return { + local battler = { mon = mon, def = def, name = mon.nickname or def.name, @@ -1085,6 +1085,7 @@ local function makeBattler(data, mon, isPlayer, save) monAnim = (not isPlayer) and require("src.pokemon.MonAnim").new(data, mon.species) or nil, } + return require("src.battle.HeldItemRead").bind(battler, battle) end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1400,7 +1401,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1457,7 +1458,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) self.caughtMark = require("src.battle.CaughtMarker").caught(game, wild.species) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then @@ -1582,9 +1583,9 @@ function BattleState.newPokedudeDemo(game, lesson) local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) + self.player = makeBattler(game.data, party[1], true, game.save, self) self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1774,9 +1775,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1868,7 +1869,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1879,7 +1880,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) else -- one usable mon and a double battle is a state the cartridge refuses -- to enter (special 64 answers non-zero and the approach is called @@ -1895,7 +1896,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1953,7 +1954,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -2188,7 +2189,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -3453,7 +3454,10 @@ end function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + require("src.battle.HeldItemRead").bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3573,6 +3577,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + require("src.battle.HeldItemRead").bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? @@ -5768,7 +5775,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -6178,6 +6185,9 @@ end -- true when the band held and the Pokemon is standing on one HP. function BattleState:focusBandHolds(battler, incoming) if not (battler and battler.mon) then return false end + if GameVersion.isGen2() then + return HeldItems.limitDirectDamage(self.data, battler, incoming, self.rng) < incoming + end -- BLACK AND WHITE: Focus Sash / Focus Band (src/battle/Gen5HoldItems.lua) if self.bw and require("src.battle.Gen5HoldItems").owns(battler) then if battler.mon.hp <= 0 or incoming < battler.mon.hp then return false end @@ -6241,7 +6251,10 @@ function BattleState:holdItemTrigger(battler, when) local label = (def and def.name) or "the item" local rng = self.rng or love.math.random - if act.kind == "heal" then + if act.kind == "prize" then + if GameVersion.get() ~= "emerald" or battler.isPlayer then self.amuletCoin = true end + + elseif act.kind == "heal" then local max = (battler.mon.stats and battler.mon.stats.hp) or battler.mon.hp battler.mon.hp = math.min(max, battler.mon.hp + act.amount) self:drainNext(battler, battler.mon.hp) @@ -7589,7 +7602,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8586,7 +8599,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8628,7 +8641,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -8925,7 +8938,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8963,7 +8976,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9336,7 +9349,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 2664e4b17..37d8b6390 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -180,7 +180,7 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng, -- one, otherwise a 1/256 hit chance is invented. A Gen 3 dataset has no -- such attribute and this answers zero. acc = math.max(0, acc - HeldItems.accuracyPenalty(data, defender)) - if not ruleset.oneIn256Miss and basePct >= 100 and not deepFog + if not ruleset.oneIn256Miss and basePct >= 100 and acc >= 255 and not deepFog and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then return true end diff --git a/src/battle/HeldItemRead.lua b/src/battle/HeldItemRead.lua new file mode 100644 index 000000000..4d4bf74fc --- /dev/null +++ b/src/battle/HeldItemRead.lua @@ -0,0 +1,195 @@ +-- Native held-effect reads share names; native consumers keep their own enums. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local M = {} +local battles = setmetatable({}, { __mode = "kv" }) +local definitions = setmetatable({}, { __mode = "kv" }) + +function M.bind(battler, battle) + if battler and battle then + battles[battler] = battle + end + return battler +end + +local gold = { + [0] = "NONE", + [1] = "BERRY", + [3] = "LEFTOVERS", + [6] = "RESTORE_PP", + [8] = "CLEANSE_TAG", + [10] = "HEAL_POISON", + [11] = "HEAL_FREEZE", + [12] = "HEAL_BURN", + [13] = "HEAL_SLEEP", + [14] = "HEAL_PARALYZE", + [15] = "HEAL_STATUS", + [16] = "HEAL_CONFUSION", + [42] = "METAL_POWDER", + [50] = "NORMAL_BOOST", + [51] = "FIGHTING_BOOST", + [52] = "FLYING_BOOST", + [53] = "POISON_BOOST", + [54] = "GROUND_BOOST", + [55] = "ROCK_BOOST", + [56] = "BUG_BOOST", + [57] = "GHOST_BOOST", + [58] = "FIRE_BOOST", + [59] = "WATER_BOOST", + [60] = "GRASS_BOOST", + [61] = "ELECTRIC_BOOST", + [62] = "PSYCHIC_BOOST", + [63] = "ICE_BOOST", + [64] = "DRAGON_BOOST", + [65] = "DARK_BOOST", + [66] = "STEEL_BOOST", + [72] = "ESCAPE", + [73] = "CRITICAL_UP", + [74] = "QUICK_CLAW", + [75] = "FLINCH", + [76] = "AMULET_COIN", + [77] = "BRIGHTPOWDER", + [79] = "FOCUS_BAND", + -- A public substitution, not a fabricated cartridge enum or saved item ID. + PREVENT_CONFUSE = "PREVENT_CONFUSE", +} +local goldIds = {} +for id, name in pairs(gold) do + gold[id] = "HELD_" .. name + goldIds[gold[id]] = id +end +function M.goldNative(name) + return goldIds[name] or name +end +function M.writeDefinition(view, key, value) + view[key] = value + local def = definitions[view] + if not def then + return + end + if key == "heldEffect" then + def.heldEffect = M.goldNative(value) + elseif key == "heldParameter" then + def.heldParam = value + else + def[key] = value + end +end + +local gba = { + RESTORE_HP = "BERRY", + CURE_PAR = "HEAL_PARALYZE", + CURE_SLP = "HEAL_SLEEP", + CURE_PSN = "HEAL_POISON", + CURE_BRN = "HEAL_BURN", + CURE_FRZ = "HEAL_FREEZE", + CURE_CONFUSION = "HEAL_CONFUSION", + CURE_STATUS = "HEAL_STATUS", + CRITICAL_UP = "PINCH_CRITICAL_UP", + SCOPE_LENS = "CRITICAL_UP", + EVASION_UP = "BRIGHTPOWDER", + DOUBLE_PRIZE = "AMULET_COIN", + REPEL = "CLEANSE_TAG", + CAN_ALWAYS_RUN = "ESCAPE", +} +local gbaNames = [[ +RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP CURE_CONFUSION CURE_STATUS +CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER CONFUSE_SOUR ATTACK_UP DEFENSE_UP +SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP RANDOM_STAT_UP EVASION_UP RESTORE_STATS +MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER +DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE +FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER +ROCK_POWER GRASS_POWER DARK_POWER FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER +POISON_POWER ICE_POWER GHOST_POWER PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER +UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK +]] +local gbaIds = {} +for name in gbaNames:gmatch("%S+") do + local public = "HELD_" .. (gba[name] or name:gsub("_POWER$", "_BOOST")) + gba[name], gbaIds[public] = public, name +end +local byteChance = { QUICK_CLAW = true, EVASION_UP = true, FOCUS_BAND = true, FLINCH = true } + +function M.read(holder, def, effect, parameter, trigger, generation) + -- Field/party item scans and other generations do not acquire a battle hook. + if + not (holder and holder.mon) + or Version.generation() ~= generation + or not Runtime.wantsHook("held_item.trigger") + then + return effect, parameter + end + local names = generation == 2 and gold or gba + local ids = generation == 2 and goldIds or gbaIds + local public = names[effect] + if generation == 2 then + if not def then + public = nil + elseif type(effect) == "string" and not public then + public = effect + end + end + local value = parameter + if generation == 3 and byteChance[effect] then + value = math.floor(value * 256 / 100) + end + local item = holder.mon.item or holder.mon.heldItem + local view + if def then + view = {} + for k, v in pairs(def) do + view[k] = v + end + view.id, view.heldEffect, view.heldParameter = item, public, value + if generation == 2 then + definitions[view] = def + end + end + local before = {} + for k, v in pairs(view or {}) do + before[k] = v + end + local chosen, amount = Runtime.call("held_item.trigger", function(ctx) + return ctx.effect, ctx.parameter + end, { + battle = battles[holder], + mon = holder.mon, + item = item, + def = view, + effect = public, + parameter = value, + trigger = trigger or "check", + }) + -- API-3 commits write at next/return boundaries. Legacy native callbacks + -- write the temporary view directly; settle their scalar metadata here. + if view then + local keys = {} + for k in pairs(before) do + keys[k] = true + end + for k in pairs(view) do + keys[k] = true + end + for k in pairs(keys) do + if view[k] ~= before[k] and type(view[k]) ~= "function" and type(view[k]) ~= "table" then + M.writeDefinition(view, k, view[k]) + end + end + end + if type(chosen) ~= "string" then + return generation == 2 and 0 or nil, 0, true + end + amount = tonumber(amount) or value + if chosen == public and amount == value then + return effect, parameter, false + end + local native = ids[chosen] or (generation == 2 and chosen or nil) + if not native then + return generation == 2 and 0 or nil, amount, true + end + if generation == 3 and byteChance[native] then + amount = math.max(0, math.min(256, amount)) * 100 / 256 + end + return native, amount, true +end +return M diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 195f4ad0b..02f7a16ac 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -49,6 +49,7 @@ HeldItems.EFFECT = { AMULET_COIN = 76, BRIGHTPOWDER = 77, FOCUS_BAND = 79, + PREVENT_CONFUSE = "PREVENT_CONFUSE", } local TYPE_BY_EFFECT = { @@ -103,7 +104,7 @@ function HeldItems.itemKey(data, holder) return norm(key or (def and (def.key or def.name)) or id) end -function HeldItems.effect(data, holder) +local function nativeEffect(data, holder) local def, id = itemDef(data, holder) if type(def) ~= "table" then return HeldItems.EFFECT.NONE, 0 end -- Modern Polished berries are handled by HoldItems; their enum values @@ -123,10 +124,16 @@ function HeldItems.effect(data, holder) return HeldItems.EFFECT.NONE, 0 end - return tonumber(def.heldEffect) or HeldItems.EFFECT.NONE, + return tonumber(def.heldEffect) or require("src.battle.HeldItemRead").goldNative(def.heldEffect) or HeldItems.EFFECT.NONE, tonumber(def.heldParam) or 0 end +function HeldItems.effect(data, holder, trigger) + local effect, param = nativeEffect(data, holder) + local def = itemDef(data, holder) + return require("src.battle.HeldItemRead").read(holder, def, effect, param, trigger, 2) +end + local function speciesKey(holder) local mon = monOf(holder) return norm(mon and mon.species) @@ -173,19 +180,19 @@ end -- ------------------------------------------------------------------------- function HeldItems.quickClaw(data, holder, rng) - local effect, param = HeldItems.effect(data, holder) + local effect, param = HeldItems.effect(data, holder, "priority") if effect ~= HeldItems.EFFECT.QUICK_CLAW then return false end rng = rng or love.math.random return rng(0, 255) < param end function HeldItems.accuracyPenalty(data, holder) - local effect, param = HeldItems.effect(data, holder) + local effect, param = HeldItems.effect(data, holder, "accuracy") return effect == HeldItems.EFFECT.BRIGHTPOWDER and param or 0 end function HeldItems.criticalStageBonus(data, holder) - local effect = HeldItems.effect(data, holder) + local effect = HeldItems.effect(data, holder, "damage") if effect == HeldItems.EFFECT.CRITICAL_UP then return 1 end local key, species = HeldItems.itemKey(data, holder), speciesKey(holder) if key == "STICK" and species == "FARFETCHD" then return 2 end @@ -199,7 +206,7 @@ end function HeldItems.modifyBattleStats(data, attacker, defender, atkStat, defStat, atk, dfn) local aKey, aSpecies = HeldItems.itemKey(data, attacker), speciesKey(attacker) - local dEffect = HeldItems.effect(data, defender) + local dEffect = HeldItems.effect(data, defender, "damage") local dSpecies = speciesKey(defender) if aKey == "THICK_CLUB" and (aSpecies == "CUBONE" or aSpecies == "MAROWAK") @@ -222,7 +229,7 @@ function HeldItems.modifyBattleStats(data, attacker, defender, atkStat, defStat, end function HeldItems.applyTypeBoost(data, attacker, moveType, damage) - local effect, param = HeldItems.effect(data, attacker) + local effect, param = HeldItems.effect(data, attacker, "damage") if TYPE_BY_EFFECT[effect] ~= moveType then return damage end -- ItemAttributes stores 10 for the retail type boosters: x1.10, with the -- floor occurring here inside the damage pipeline. @@ -231,10 +238,10 @@ end function HeldItems.limitDirectDamage(data, target, damage, rng) if not isAlive(target) or damage <= 0 then return damage end - local effect, param = HeldItems.effect(data, target) - if effect ~= HeldItems.EFFECT.FOCUS_BAND then return damage end local hp = tonumber(monOf(target).hp) or 0 - if hp <= 1 or damage < hp then return damage end + if hp <= 0 or damage < hp then return damage end + local effect, param = HeldItems.effect(data, target, "endure") + if effect ~= HeldItems.EFFECT.FOCUS_BAND then return damage end rng = rng or love.math.random if rng(0, 255) < param then return hp - 1 end return damage @@ -298,7 +305,7 @@ function HeldItems.tryKingsRock(data, user, target, move, rng, blockedBySubstitu if blockedBySubstitute or not isAlive(target) or not HeldItems.supportsKingsRock(move) then return false end - local effect, param = HeldItems.effect(data, user) + local effect, param = HeldItems.effect(data, user, "flinch") if effect ~= HeldItems.EFFECT.FLINCH then return false end rng = rng or love.math.random if rng(0, 255) < param then @@ -344,7 +351,7 @@ end function HeldItems.onStatus(battle, holder) if not (battle and isAlive(holder)) then return {} end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "status") local wanted = STATUS_BY_EFFECT[effect] local changed = false if wanted then @@ -367,11 +374,11 @@ end function HeldItems.onConfusion(battle, holder) if not (battle and isAlive(holder)) or not holder.confusedTurns then return {} end - local effect = HeldItems.effect(battle.data, holder) + local effect, _, changed = HeldItems.effect(battle.data, holder, "status") local key = HeldItems.itemKey(battle.data, holder) if effect ~= HeldItems.EFFECT.HEAL_CONFUSION and effect ~= HeldItems.EFFECT.HEAL_STATUS - and key ~= "BITTER_BERRY" and key ~= "MIRACLE_BERRY" then + and (changed or (key ~= "BITTER_BERRY" and key ~= "MIRACLE_BERRY")) then return {} end holder.confusedTurns = nil @@ -401,7 +408,7 @@ end local function processLeftovers(battle, holder) if not isAlive(holder) then return end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "residual") if effect ~= HeldItems.EFFECT.LEFTOVERS then return end local mon = monOf(holder) local maxHP = mon.stats and tonumber(mon.stats.hp) @@ -414,7 +421,7 @@ end local function processRestorePP(battle, holder) if not isAlive(holder) then return end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "pp") if effect == HeldItems.EFFECT.RESTORE_PP and restorePP(battle.data, holder) then consume(holder) battle:sayNext(Strings("%s's held item\nrestored PP!", displayName(holder))) @@ -423,11 +430,11 @@ end local function processHealingItem(battle, holder) if not isAlive(holder) then return end - local effect, param = HeldItems.effect(battle.data, holder) + local effect, param = HeldItems.effect(battle.data, holder, "residual") local mon = monOf(holder) if effect == HeldItems.EFFECT.BERRY then local maxHP = mon.stats and tonumber(mon.stats.hp) - if maxHP and mon.hp * 2 <= maxHP then + if maxHP and mon.hp * 2 < maxHP then local got = heal(holder, param) if got > 0 then consume(holder) @@ -463,6 +470,9 @@ end -- that legacy bug by initializing a normal Gen II confusion duration. function HeldItems.onEntry(battle, holder) if not (battle and isAlive(holder)) then return false end + if require("src.core.GameVersion").isGen3() then + return battle:holdItemTrigger(holder, "entry") + end -- PLATINUM'S AMULET COIN counts on EITHER side: battle_lib.c's -- SWITCH_IN_CHECK_STATE_AMULET_COIN walks every battler on the field, and its -- items name the effect "MONEY_UP" rather than the Game Boy number. @@ -494,7 +504,7 @@ end function HeldItems.observeParticipant(battle, holder) if battle and holder and holder.isPlayer then - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "check") if effect == HeldItems.EFFECT.AMULET_COIN then battle.amuletCoin = true end end end @@ -506,7 +516,10 @@ end function HeldItems.canEscape(data, holder) -- BLACK AND WHITE's Smoke Ball (src/battle/Gen5HoldItems.lua) if require("src.battle.Gen5HoldItems").canEscape(holder) then return true end - local effect = HeldItems.effect(data, holder) + if require("src.core.GameVersion").isGen3() then + return require("src.battle.HoldItems").effectOf(holder, "run") == "CAN_ALWAYS_RUN" + end + local effect = HeldItems.effect(data, holder, "check") return effect == HeldItems.EFFECT.ESCAPE end diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index df5e2a031..1a5090c43 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -48,7 +48,7 @@ end -- The named effect and its parameter, or nil. An effect the extractor had -- no name for comes back as a number and matches no rule below, which is -- the same silence an unknown ability gets. -function HoldItems.effectOf(battler) +local function nativeEffect(battler) local def = HoldItems.defOf(battler) if not def then return nil end local effect,param=require('src.inventory.PolishedBerries').held(def,battler.mon) @@ -58,6 +58,14 @@ function HoldItems.effectOf(battler) return effect, tonumber(def.holdEffectParam) or 0, def end +function HoldItems.effectOf(battler, trigger) + local effect, param, def = nativeEffect(battler) + if not require("src.mods.Runtime").wantsHook("held_item.trigger") then return effect, param, def end + local definition = def or HoldItems.defOf(battler) + local chosen, amount = require("src.battle.HeldItemRead").read(battler, definition, effect, param or 0, trigger, 3) + return chosen, amount, def +end + HoldItems.effect = HoldItems.effectOf -- BLACK AND WHITE's items keep their hold effect as a number; a battler @@ -109,7 +117,7 @@ local SOUL_DEW_SPECIES = { LATIAS = true, LATIOS = true } function HoldItems.attackMultiplier(attacker, move, special) local g5 = bw(attacker) if g5 then return g5.attackMultiplier(attacker, move, special) end - local effect = HoldItems.effectOf(attacker) + local effect = HoldItems.effectOf(attacker, "damage") if not effect then return 1, 1 end -- CHOICE BAND: half again as much, physical only if effect == "CHOICE_BAND" and not special then return 3, 2 end @@ -129,7 +137,7 @@ end function HoldItems.defenceMultiplier(defender, move, special) local g5 = bw(defender) if g5 then return g5.defenceMultiplier(defender, move, special) end - local effect = HoldItems.effectOf(defender) + local effect = HoldItems.effectOf(defender, "damage") if not effect then return 1, 1 end local row = SPECIES_STAT[effect] if row and row.species[speciesOf(defender)] then @@ -165,7 +173,7 @@ HoldItems.TYPE_POWER = TYPE_POWER -- Applied to the FINISHED damage, the way the cartridge applies it: as -- damage * (100 + param) / 100. function HoldItems.damageMultiplier(attacker, move) - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "damage") if not (effect and move) then return 1, 1 end local boostedType = TYPE_POWER[effect] if boostedType and boostedType == normalizeType(move.type) then @@ -184,7 +192,7 @@ end function HoldItems.accuracyMultiplier(attacker, defender) local g5 = bw(attacker) or bw(defender) if g5 then return g5.accuracyMultiplier(attacker, defender) end - local effect, param = HoldItems.effectOf(defender) + local effect, param = HoldItems.effectOf(defender, "accuracy") if effect == "EVASION_UP" then return 100 - (param or 0) end return 100 end @@ -199,7 +207,7 @@ local CRIT_SPECIES = { function HoldItems.critStages(battler) local g5 = bw(battler) if g5 then return g5.critStages(battler) end - local effect = HoldItems.effectOf(battler) + local effect = HoldItems.effectOf(battler, "damage") if not effect then return 0 end if effect == "SCOPE_LENS" then return 1 end local list = CRIT_SPECIES[effect] @@ -212,7 +220,7 @@ end function HoldItems.quickClawChance(battler) local g5 = bw(battler) if g5 then return g5.quickClawChance(battler) end - local effect, param = HoldItems.effectOf(battler) + local effect, param = HoldItems.effectOf(battler, "priority") if effect ~= "QUICK_CLAW" then return 0 end return param or 0 end @@ -223,7 +231,7 @@ end function HoldItems.flinchChance(attacker, move) local g5 = bw(attacker) if g5 then return g5.flinchChance(attacker, move) end - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "flinch") if effect ~= "FLINCH" then return 0 end if move and move.kingsRockAffected == false then return 0 end return param or 0 @@ -232,14 +240,14 @@ end -- FOCUS BAND: a `param` percent chance to be left standing on one HP by a -- hit that would otherwise knock the holder out. function HoldItems.focusBandChance(battler) - local effect, param = HoldItems.effectOf(battler) + local effect, param = HoldItems.effectOf(battler, "endure") if effect ~= "FOCUS_BAND" then return 0 end return param or 0 end -- SHELL BELL: the attacker takes back a `param`th of the damage it dealt. function HoldItems.shellBellShare(attacker) - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "flinch") if effect ~= "SHELL_BELL" then return nil end if not param or param <= 0 then return nil end return param @@ -306,11 +314,15 @@ end -- { kind = "critUp", delta = 2 } LANSAT BERRY -- { kind = "restoreStats" } WHITE HERB function HoldItems.trigger(battler, when) - local effect, param = HoldItems.effectOf(battler) + local effect, param = HoldItems.effectOf(battler, when == "entry" and "check" or "residual") if not effect then return nil end local mon = battler.mon if not mon or (mon.hp or 0) <= 0 then return nil end local max = maxHpOf(battler) + if when == "entry" then + if effect == "DOUBLE_PRIZE" then return { kind = "prize", consume = false } end + if effect ~= "RESTORE_STATS" then return nil end + end -- LEFTOVERS, the one that is never eaten. Its parameter is 10 and it is -- NOT a percentage: ItemBattleEffects' LEFTOVERS arm reads maxHP / 16 and @@ -357,7 +369,7 @@ function HoldItems.trigger(battler, when) amount = math.max(1, param), consume = true } end - if when ~= "turn" then return nil end + if when ~= "turn" and when ~= "entry" then return nil end local cured = CURE_STATUS[effect] if cured then diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index ed4249843..5c26301a4 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -201,6 +201,9 @@ local function confuse(battle, target, pierceSub) if target.confusedTurns or (target.substituteHP and not pierceSub) then return { Strings("But, it failed!") } end + if HeldItems.effect(battle.data, target, "confuse") == HeldItems.EFFECT.PREVENT_CONFUSE then + return { Strings("But, it failed!") } + end target.confusedTurns = battle.rng(2, 5) HeldItems.setConfusionCounter(battle, target, target.confusedTurns) local msgs = { Strings("%s\nbecame confused!", displayName(target)) } diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..05fdce1c0 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -59,6 +59,10 @@ local function effectiveSpeed(battler, battle) if battler.items or battler.bwBattle then spd = require("src.battle.Gen5HoldItems").speed(battler, spd) end + if require("src.core.GameVersion").isGen3() + and require("src.battle.HoldItems").effectOf(battler, "priority") == "MACHO_BRACE" then + spd = math.max(1, math.floor(spd / 2)) + end return spd end @@ -93,11 +97,13 @@ function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie, battle, data) local first, second = a, b local firstIsA = true if invertTie then first, second, firstIsA = b, a, false end - if HeldItems.effect(data, first) == HeldItems.EFFECT.QUICK_CLAW then - if HeldItems.quickClaw(data, first, rng) then return firstIsA end + local firstEffect, firstParam = HeldItems.effect(data, first, "priority") + if firstEffect == HeldItems.EFFECT.QUICK_CLAW then + if rng(0, 255) < firstParam then return firstIsA end end - if HeldItems.effect(data, second) == HeldItems.EFFECT.QUICK_CLAW then - if HeldItems.quickClaw(data, second, rng) then return not firstIsA end + local secondEffect, secondParam = HeldItems.effect(data, second, "priority") + if secondEffect == HeldItems.EFFECT.QUICK_CLAW then + if rng(0, 255) < secondParam then return not firstIsA end end local sa, sb = effectiveSpeed(a, battle), effectiveSpeed(b, battle) diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 4ce1679c5..f8a57a73e 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -238,6 +238,13 @@ function R.new(loader, name, generation, api3) 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 == "heldItem" then + local out = { native = copy(raw) } + for k, v in pairs(raw) do + if type(v) ~= "table" and type(v) ~= "function" then out[k] = v end + end + out.id = read("items", raw.id) + return out 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" @@ -294,6 +301,7 @@ function R.new(loader, name, generation, api3) local out = shallow(raw) for k, v in pairs(raw) do local role = recordRoles[k] + if name == "held_item.trigger" and k == "def" then role = "heldItem" end if name == "world.interacted" and k == "target" then role = nil end @@ -483,7 +491,20 @@ function R.new(loader, name, generation, api3) 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 == "heldItem" then + local keys = shallow(before) + for k in pairs(value) do keys[k] = true end + for key in pairs(keys) do + if key ~= "native" and not equal(value[key], before[key]) then + local v = value[key] + assert(type(v) ~= "table" and type(v) ~= "function", "held definition field must be scalar") + if key == "id" then v = native("items", v, raw.id) end + pending[#pending + 1] = function() + require("src.battle.HeldItemRead").writeDefinition(raw, key, v) + end + end + end + elseif 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), diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 34e4e5be9..297087daa 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -35,6 +35,7 @@ add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") +add("hooks", "23", "held_item.trigger") add("hooks", "123", "encounter.table battle.charge_required battle.caught_marker_visible battle.field_residual") add("hooks", "12", "intro.oak_speech.build") diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 93ef1342e..4928928a0 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation + held_item.trigger battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation battle.field_residual battle.caught_marker_visible battle.charge_required battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update encounter.table encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["held_item.trigger"] = { "context" }, ["battle.field_residual"] = { "context" }, ["battle.caught_marker_visible"] = { "context" }, ["battle.charge_required"] = { "context" }, diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua index 8f2aac378..71488abc1 100644 --- a/tests/charge_required/fixture.lua +++ b/tests/charge_required/fixture.lua @@ -1,5 +1,5 @@ local M = {} -function M.new(root, backend, version, api, format, noMod, failing, consumer) +function M.new(root, backend, version, api, format, noMod, failing, consumer, prepare) consumer = consumer or "portable_charge_required" local T = require("tests.modkit") local V = require("src.core.GameVersion") @@ -101,6 +101,7 @@ function M.new(root, backend, version, api, format, noMod, failing, consumer) } end end + if prepare then prepare(data, gen, backend) end local files, Json = {}, require("src.link.Json") for _, name in ipairs({ "main.lua", "manifest.json" }) do local f = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) diff --git a/tests/drivers/held_item.lua b/tests/drivers/held_item.lua new file mode 100644 index 000000000..b8258ce47 --- /dev/null +++ b/tests/drivers/held_item.lua @@ -0,0 +1,129 @@ +-- Imported-data native input/held-effect/result-queue smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_held_item, "install held-item example") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle, leftovers + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + break + end + end + for id, def in pairs(game.data.items) do + if def.name == "LEFTOVERS" then + leftovers = id + break + end + end + assert(species and tackle and leftovers, "imported native metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh(hp) + local p = P.new(game.data, species, 30) + p.hp, p.stats.hp, p.nickname, p.item = hp or 300, 500, "PLAYER", leftovers + p.moves = { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp, b.enemy.mon.nickname = 500, 500, "ENEMY" + b.enemy.mon.item, b.enemy.mon.heldItem = leftovers, nil + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + e.state.mode, e.state.trigger, e.state.holder, e.state.requiresItem = "delegate", nil, nil, true + e.state.calls, e.state.seen, e.state.views, e.state.selected = 0, {}, {}, nil + return b + end + local function drain(b) + for _ = 1, 4000 do + if b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native held-item result queue did not settle") + end + for _, mode in ipairs({ "suppress", "override" }) do + local b = fresh(240) + e.state.mode, e.state.trigger, e.state.holder = mode, "residual", "PLAYER" + e.state.effect, e.state.parameter = "HELD_BERRY", 7 + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT did not open move selection") + U.tap(game, "a") + drain(b) + local ctx = assert(e.state.selected, "native player's held read missing") + assert(ctx.mon.species == "MEW" and ctx.item == "LEFTOVERS", "portable imported held IDs") + assert( + b.player.mon.hp == ctx.mon.hp + (mode == "override" and 7 or 0), + "native input/HP follows held decision" + ) + assert((b.player.mon.item ~= nil) == (mode ~= "override"), "native held consumption") + end + local b = fresh() + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b.player.mon.hp == 331 and b.player.mon.item == leftovers, "native delegated Leftovers") + b = fresh() + b.enemy.mon.hp = 1 + e.state.mode, e.state.trigger, e.state.holder = "override", "endure", "ENEMY" + e.state.effect, e.state.parameter = "HELD_FOCUS_BAND", 256 + b:performMove(b.player, b.enemy, b.player.mon.moves[1]) + b.phase = "messages" + drain(b) + assert(b.enemy.mon.hp == 1 and e.state.seen.endure, "native lethal hit honors held endurance") + if V.generation() == 3 then + b = fresh() + b.player.stages.attack = -2 + e.state.mode, e.state.trigger, e.state.holder = "override", "check", "PLAYER" + e.state.effect = "HELD_RESTORE_STATS" + require("src.battle.HeldItems").onEntry(b, b.player) + b.phase = "messages" + drain(b) + assert(b.player.stages.attack == 0 and b.player.mon.item == nil, "native entry White Herb substitution") + b = fresh() + e.state.mode, e.state.trigger, e.state.holder = "override", "run", "PLAYER" + e.state.effect = "HELD_ESCAPE" + b.menuIndex = 4 + U.tap(game, "a") + assert(b.result == "run" and e.state.seen.run, "native RUN menu uses public held escape") + -- Stop at the in-battle result before finish/afterBattle/save processing. + end + b = fresh() + e.stop() + b:endOfTurn() + assert(e.state.calls == 0 and b.player.mon.hp == 331, "native held unsubscribe") + print("PORTABILITY_HELD_ITEM_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/held_item_authority.lua b/tests/engine/held_item_authority.lua new file mode 100644 index 000000000..793fbe636 --- /dev/null +++ b/tests/engine/held_item_authority.lua @@ -0,0 +1,73 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local Fixture = dofile("tests/held_item/fixture.lua") +local old = V.get() +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e = f.e + local b = f.battle + local holder = b.enemy + -- External/link constructors and replacements bind to the real battle. + local replacement = require("src.battle.BattleState").makeBattler(f.data, holder.mon, false) + b:placeBattler(1, replacement) + f.read("damage", "enemy") + T.eq(e.state.last.battle, b, "placed battler is bound to actual battle") + T.eq(e.state.last.mon.nickname, "ENEMY", "placed holder has actual Pokemon") + b.enemy = require("src.battle.BattleState").makeBattler(f.data, replacement.mon, false) + b:syncSides() + collectgarbage("collect") + f.read("accuracy", "enemy") + T.eq(e.state.last.battle, b, "sync binds external/linked battlers across GC") + -- Field/party reads do not turn into battle comparisons. + f.reset("LEFTOVERS") + local before = e.state.calls + require("src.battle.HeldItems").effect(f.data, f.mon("player"), "damage") + require("src.battle.HeldItems").cleanseTagRate(f.data, { f.mon("player") }, 100) + T.eq(e.state.calls, before, "raw field/party item reads do not dispatch battle hook") + if mode[2] == "unified" then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.mon.hp = 1 + ctx.def.heldEffect = {} + return next(ctx) + end, 100) + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "invalid definition edit recovers native verdict") + off() + T.eq(f.mon("player").hp, 400, "invalid definition edit cannot partially commit Pokemon changes") + T.eq(e.state.last.effect, "HELD_LEFTOVERS", "invalid definition edit does not mutate stored effect") + end + if f.gen == 2 then + b = f.reset("LEFTOVERS") + e.state.mode, e.state.effect, e.state.trigger = "override", "HELD_PORTABLE_CUSTOM", "damage" + T.eq( + f.read("damage"), + "HELD_PORTABLE_CUSTOM", + "Gold retains unknown named effects for native comparisons" + ) + local nativeItem = f.data.items.LEFTOVERS + T.eq(nativeItem.heldEffect, 3, "effect substitution does not rewrite stored attributes") + T.eq(nativeItem.heldParam, 10, "effect substitution does not rewrite stored parameter") + else + b = f.reset("LEFTOVERS") + local nativeItem = f.data.items.ITEM_200 + for _, name in ipairs({ "HELD_QUICK_CLAW", "HELD_BRIGHTPOWDER", "HELD_FOCUS_BAND", "HELD_FLINCH" }) do + for _, parameter in ipairs({ -10, 0, 256, 300 }) do + e.state.mode, e.state.effect, e.state.parameter = "override", name, parameter + local _, actual = f.read("check") + T.eq( + actual, + math.max(0, math.min(256, parameter)) * 100 / 256, + "GBA public byte odds clamp at native boundary" + ) + end + end + T.eq(nativeItem.holdEffect, "LEFTOVERS", "GBA substitution leaves imported held enum unchanged") + T.eq(nativeItem.holdEffectParam, 10, "GBA substitution leaves imported parameter unchanged") + end + f.close() + end +end +V.set(old) +T.finish("held item native ownership and metadata transaction authority") diff --git a/tests/engine/held_item_contract.lua b/tests/engine/held_item_contract.lua new file mode 100644 index 000000000..562e3853f --- /dev/null +++ b/tests/engine/held_item_contract.lua @@ -0,0 +1,43 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/held_item/fixture.lua"), dofile("tests/held_item/cases.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local available = f.gen == 2 or f.gen == 3 + T.eq(f.e.api:supports("hooks", "held_item.trigger"), available, "honest held comparison capability") + if available then + Cases.run(f) + else + f.e.state.mode, f.e.state.effect, f.e.state.parameter = "override", "HELD_BERRY", 7 + f.read("residual") + T.eq(f.e.state.calls, 0, "GB/DS never dispatch unavailable held seam") + end + f.close() + for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#bare.result.errors > 0, "entry failure recorded") + else + T.eq(bare.result.loader.exports.portable_held_item, nil, "genuine no-mod fixture") + end + for _, link in ipairs(Runtime.hooks.chains["held_item.trigger"] or {}) do + T.check(link.owner ~= "portable_held_item", "rollback removes held owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "held_item.trigger", "empty held chain dispatched") + return call(name, ...) + end + bare.mon("player").hp = 100 + bare.residual() + Runtime.call = call + T.eq(bare.mon("player").hp, bare.gen == 1 and 100 or 125, "native no-mod/rollback held behavior") + bare.close() + end + end +end +V.set(old) +T.finish("native held-item public contract and generation boundaries") diff --git a/tests/held_item/cases.lua b/tests/held_item/cases.lua new file mode 100644 index 000000000..b413c7fcd --- /dev/null +++ b/tests/held_item/cases.lua @@ -0,0 +1,340 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local samples = { + { "LEFTOVERS", "residual", "HELD_LEFTOVERS", 10 }, + { "QUICK_CLAW", "priority", "HELD_QUICK_CLAW", f.gen == 3 and 51 or 20 }, + { "BRIGHTPOWDER", "accuracy", "HELD_BRIGHTPOWDER", f.gen == 3 and 25 or 10 }, + { "FOCUS_BAND", "endure", "HELD_FOCUS_BAND", f.gen == 3 and 25 or 10 }, + { "KINGS_ROCK", "flinch", "HELD_FLINCH", f.gen == 3 and 25 or 10 }, + { "SCOPE_LENS", "damage", "HELD_CRITICAL_UP", 0 }, + { "ORAN_BERRY", "residual", "HELD_BERRY", 10 }, + { "PERSIM_BERRY", f.gen == 2 and "status" or "residual", "HELD_HEAL_CONFUSION", 0 }, + { "LEPPA_BERRY", f.gen == 2 and "pp" or "residual", "HELD_RESTORE_PP", 10 }, + { "CHARCOAL", "damage", "HELD_FIRE_BOOST", 10 }, + } + for _, sample in ipairs(samples) do + local item, trigger, name, param = unpack(sample) + f.reset(item) + T.eq(f.read(trigger), f.effect(name), "native reader preserves held effect") + T.eq(e.state.calls, 1, "native held reader dispatches once") + local ctx = e.state.last + T.eq(ctx.effect, name, "shared HELD name") + T.eq(ctx.parameter, param, "shared byte/proportion parameter") + T.eq(ctx.trigger, trigger, "native comparison role") + T.eq(ctx.battle, f.battle, "opaque actual battle controller") + if f.api >= 3 then + T.eq(ctx.item, item, "API-3 canonical item ID") + T.eq(ctx.def.id, item, "API-3 item definition role") + T.eq(ctx.mon.species, "CHARMANDER", "API-3 canonical holder species") + end + rows[#rows + 1] = { + label = item .. ":" .. trigger, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + holder = ctx.mon.nickname, + } + end + f.reset("NO_EFFECT") + e.state.mode, e.state.effect, e.state.parameter = "override", "HELD_BERRY", 7 + local effect, amount = f.read("check") + T.eq(effect, f.effect("HELD_BERRY"), "an ordinary held item may acquire an effect") + T.eq(amount, 7, "ordinary item substitution parameter") + T.check(e.state.last.def ~= nil, "ordinary held item retains definition even without native effect") + rows[#rows + 1] = { label = "ordinary item override", parameter = amount } + f.reset(nil) + e.state.mode, e.state.effect, e.state.parameter, e.state.requiresItem = "override", "HELD_BERRY", 7, false + effect, amount = f.read("check") + T.eq(effect, f.effect("HELD_BERRY"), "empty-hand reads may acquire a public effect") + T.eq(amount, 7, "empty-hand override parameter") + T.eq(e.state.last.def, nil, "empty hand has no item definition") + rows[#rows + 1] = { label = "empty-hand override", parameter = amount } + e.state.requiresItem = true + for _, trigger in ipairs({ + "priority", + "damage", + "endure", + "flinch", + "accuracy", + "confuse", + "residual", + "status", + "pp", + "run", + "check", + }) do + f.reset("LEFTOVERS") + f.read(trigger, "enemy") + T.eq(e.state.last.mon.nickname, "ENEMY", "opponent's actual mon crosses read boundary") + T.eq(e.state.last.trigger, trigger, "arbitrary comparison role passes through") + rows[#rows + 1] = { label = "opponent:" .. trigger, trigger = e.state.last.trigger } + end + for _, chosen in ipairs({ "HELD_BERRY", "HELD_CRITICAL_UP", "HELD_QUICK_CLAW", "HELD_FIRE_BOOST" }) do + f.reset("LEFTOVERS") + e.state.mode, e.state.effect, e.state.parameter = "override", chosen, 128 + local effect, parameter = f.read("damage") + T.eq(effect, f.effect(chosen), "public substitution reaches native effect ID") + local wanted = f.gen == 3 and chosen == "HELD_QUICK_CLAW" and 50 or 128 + T.eq(parameter, wanted, "substitution converts byte odds to GBA native percent") + rows[#rows + 1] = { label = "substitute:" .. chosen, parameter = parameter } + end + for _, mode in ipairs({ "suppress", "invalid", "mutate", "fallback" }) do + f.reset("LEFTOVERS") + e.state.mode = mode + if mode == "invalid" then + e.state.mode, e.state.effect = "override", 7 + end + if mode == "fallback" then + e.state.mode, e.state.effect, e.state.parameter = "override", "HELD_LEFTOVERS", "invalid" + end + if mode == "mutate" then + e.state.effect, e.state.parameter = "HELD_BERRY", "7" + end + local effect, parameter = f.read("residual") + local expected = mode == "mutate" and "HELD_BERRY" or mode == "fallback" and "HELD_LEFTOVERS" or nil + T.eq(effect, f.effect(expected), "return/mutation decision reaches native reader") + T.eq( + parameter, + mode == "mutate" and 7 or mode == "fallback" and 10 or 0, + "fallback preserves native parameter" + ) + rows[#rows + 1] = { label = "decision:" .. mode, parameter = parameter } + end + for _, after in ipairs({ false, true }) do + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + if after then + next(ctx) + end + error("intentional held-item wrapper failure") + end, 100) + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "wrapper error retains native effect") + T.eq(e.state.calls, 1, "wrapper recovery delegates once") + off() + rows[#rows + 1] = { label = "error:" .. tostring(after), calls = e.state.calls } + end + if f.api >= 3 then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + local a, b = next(ctx) + local c, d = next(ctx) + T.eq(a, c, "API-3 replay preserves first effect") + T.eq(b, d, "API-3 replay preserves second result") + return a, b + end, 100) + f.read("residual") + off() + T.eq(e.state.calls, 1, "API-3 replay executes downstream read once") + rows[#rows + 1] = { label = "replay", calls = e.state.calls } + end + for _, mode in ipairs({ "delegate", "suppress", "override" }) do + f.reset("LEFTOVERS") + f.mon("player").hp = 100 + e.state.mode, e.state.trigger = mode, "residual" + e.state.requiresItem = true + e.state.effect, e.state.parameter = "HELD_BERRY", 7 + f.residual() + local healed = f.mon("player").hp - 100 + T.eq( + healed, + mode == "delegate" and 25 or mode == "override" and 7 or 0, + "real residual applies public held decision" + ) + local held = f.mon("player").item + local remains = held ~= nil and held ~= 0 + T.eq(remains, mode ~= "override", "native effect owns item consumption") + T.check(e.state.seen.residual ~= nil, "real residual code dispatches held read") + rows[#rows + 1] = { label = "residual:" .. mode, healed = healed, held = remains } + end + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "priority", "HELD_QUICK_CLAW", 256 + end + T.eq(f.priority(), active, "native move order follows held priority substitution") + rows[#rows + 1] = { label = "priority:" .. tostring(active), first = active } + end + local damages = {} + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "damage", "HELD_NORMAL_BOOST", 100 + end + f.attack() + damages[#damages + 1] = 400 - f.mon("enemy").hp + end + T.check(damages[1] > 0 and damages[2] > damages[1], "real native move damage responds to held type boost") + rows[#rows + 1] = { label = "damage boost", increased = damages[2] > damages[1] } + for _, active in ipairs({ false, true }) do + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "accuracy", "HELD_BRIGHTPOWDER", 256 + end + f.attack() + local missed = f.mon("enemy").hp == 400 + T.eq(missed, active, "native 100-percent move honors held accuracy modifier") + rows[#rows + 1] = { label = "accuracy:" .. tostring(active), missed = missed } + end + for _, active in ipairs({ false, true }) do + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + f.mon("enemy").hp = 1 + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "endure", "HELD_FOCUS_BAND", 256 + end + f.attack() + T.eq(f.mon("enemy").hp, active and 1 or 0, "native lethal move on one HP honors held endurance") + rows[#rows + 1] = { label = "endure:" .. tostring(active), hp = f.mon("enemy").hp } + end + for _, hp in ipairs({ 199, 200 }) do + f.reset("ORAN_BERRY") + f.mon("player").hp = hp + f.residual() + local wanted = hp == 199 or f.gen == 3 + T.eq(f.mon("player").hp - hp, wanted and 10 or 0, "native generation berry threshold") + rows[#rows + 1] = { label = "berry threshold:" .. hp, healed = f.mon("player").hp - hp } + end + if f.gen == 3 then + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.holder = "override", "priority", "ENEMY" + e.state.effect, e.state.parameter = "HELD_MACHO_BRACE", 0 + end + T.eq(f.priority(75, 100), active, "native GBA speed honors held Macho Brace substitution") + rows[#rows + 1] = { label = "macho:" .. tostring(active), first = active } + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect = "override", "run", "HELD_ESCAPE" + end + local escaped = f.runEffect() + T.eq(escaped, active, "native GBA escape honors held read") + rows[#rows + 1] = { label = "escape:" .. tostring(active), escaped = escaped } + end + f.reset("LEFTOVERS") + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_AMULET_COIN" + f.entry() + local prize = f.battle.amuletCoin == true or f.battle.moneyMultiplier == 2 + T.eq(prize, true, "native entry held read sets money multiplier") + rows[#rows + 1] = { label = "entry prize", prize = prize } + f.reset("LEFTOVERS") + f.battle.player.stages.attack = -1 + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_RESTORE_STATS" + f.entry() + T.eq(f.battle.player.stages.attack, 0, "native entry held read restores stat stages") + local held = f.mon("player").item + T.check(held == nil or held == 0, "native entry consumes substituted White Herb") + rows[#rows + 1] = { label = "entry herb", stage = f.battle.player.stages.attack } + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_AMULET_COIN" + f.entry("enemy") + prize = f.battle.amuletCoin == true or f.battle.moneyMultiplier == 2 + T.eq(prize, require("src.core.GameVersion").get() ~= "emerald", "native GBA enemy prize policy") + rows[#rows + 1] = { label = "enemy entry prize", prize = prize } + f.reset("LEFTOVERS") + f.mon("player").hp = 300 + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "flinch", "HELD_SHELL_BELL", 8 + f.attack() + local damage, healed = 400 - f.mon("enemy").hp, f.mon("player").hp - 300 + T.eq(healed, math.max(1, math.floor(damage / 8)), "native Shell Bell read heals from actual dealt damage") + rows[#rows + 1] = { label = "shell bell", correctShare = healed == math.max(1, math.floor(damage / 8)) } + end + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "flinch", "HELD_FLINCH", 256 + end + f.attack() + T.eq(f.flinched(), active, "actual native move honors held King's Rock substitution") + rows[#rows + 1] = { label = "flinch:" .. tostring(active), flinched = f.flinched() } + f.reset("PERSIM_BERRY") + f.confused(3) + if not active then + e.state.mode, e.state.trigger = "suppress", f.gen == 2 and "status" or "residual" + end + f.residual() + T.eq(f.confused(), not active, "actual native residual honors held confusion cure/suppression") + rows[#rows + 1] = { label = "confusion cure:" .. tostring(active), confused = f.confused() } + f.reset("LEPPA_BERRY") + f.pp(0) + if not active then + e.state.mode, e.state.trigger = "suppress", f.gen == 2 and "pp" or "residual" + end + f.residual() + local expected = active and (f.gen == 2 and 5 or 10) or 0 + T.eq(f.pp(), expected, "actual native held PP restore uses generation's own amount") + rows[#rows + 1] = { label = "PP restore:" .. tostring(active), pp = f.pp() } + if f.gen == 2 then + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect = "override", "confuse", "HELD_PREVENT_CONFUSE" + end + local confused = f.inflictConfusion() + T.eq(confused, not active, "native Gold confusion gate honors held prevention") + rows[#rows + 1] = { label = "confusion prevent:" .. tostring(active), confused = confused } + end + end + for _, item in ipairs(f.catalog) do + f.reset(item[1]) + local effect, param = f.read("check") + T.eq(effect, f.backend == "gen1" and item[6] or item[3], "all native GBA enum names round-trip") + T.eq(param, 16, "all native GBA default parameters round-trip without quantization") + rows[#rows + 1] = { + label = "GBA enum:" .. item[3], + effect = e.state.last.effect, + parameter = e.state.last.parameter, + } + end + if f.api >= 3 then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.def.price, ctx.def.heldEffect, ctx.def.heldParameter = 999, "HELD_BERRY", 7 + return next(ctx) + end, 100) + T.eq( + f.read("residual"), + f.effect("HELD_LEFTOVERS"), + "definition edits do not rewrite captured current effect" + ) + off() + T.eq(e.state.last.def.price, 999, "downstream sees committed public definition edits") + local effect, param = f.read("residual") + T.eq( + effect, + f.effect(f.gen == 2 and "HELD_BERRY" or "HELD_LEFTOVERS"), + "definition persistence follows native producer" + ) + T.eq(param, f.gen == 2 and 7 or 10, "definition parameter persistence follows native producer") + rows[#rows + 1] = { + label = "definition metadata", + price = e.state.last.def.price, + parameter = param, + effect = e.state.last.effect, + } + local restore = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.def.price, ctx.def.heldEffect, ctx.def.heldParameter = 100, "HELD_LEFTOVERS", 10 + return next(ctx) + end, 100) + f.read("check") + restore() + end + f.reset("LEFTOVERS") + e.stop() + e.stop() + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "unsubscribe restores native reader") + T.eq(e.state.calls, 0, "unsubscribe removes held callback") + rows[#rows + 1] = { label = "unsubscribed", calls = e.state.calls } + return rows +end +return M diff --git a/tests/held_item/fixture.lua b/tests/held_item/fixture.lua new file mode 100644 index 000000000..67da8a0d7 --- /dev/null +++ b/tests/held_item/fixture.lua @@ -0,0 +1,315 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local items = { + { "NO_EFFECT", 371, "NONE", 0, 0 }, + { "LEFTOVERS", 200, "LEFTOVERS", 3, 10 }, + { "QUICK_CLAW", 183, "QUICK_CLAW", 74, 20 }, + { "BRIGHTPOWDER", 179, "EVASION_UP", 77, 10 }, + { "FOCUS_BAND", 196, "FOCUS_BAND", 79, 10 }, + { "KINGS_ROCK", 187, "FLINCH", 75, 10 }, + { "SCOPE_LENS", 198, "SCOPE_LENS", 73, 0 }, + { "ORAN_BERRY", 139, "RESTORE_HP", 1, 10 }, + { "PERSIM_BERRY", 140, "CURE_CONFUSION", 16, 0 }, + { "LEPPA_BERRY", 138, "RESTORE_PP", 6, 10 }, + { "CHARCOAL", 215, "FIRE_POWER", 58, 10 }, + } + local catalog = {} + local gbaNames = [[ +RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP CURE_CONFUSION CURE_STATUS +CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER CONFUSE_SOUR ATTACK_UP DEFENSE_UP +SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP RANDOM_STAT_UP EVASION_UP RESTORE_STATS +MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER +DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE +FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER +ROCK_POWER GRASS_POWER DARK_POWER FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER +POISON_POWER ICE_POWER GHOST_POWER PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER +UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK +]] + local goldNames = { + [1] = "BERRY", + [16] = "HEAL_CONFUSION", + [73] = "CRITICAL_UP", + [77] = "BRIGHTPOWDER", + [58] = "FIRE_BOOST", + } + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + noMod, + failing, + "portable_held_item", + function(data, gen) + if backend == "gen2" then + data.constants.generation = gen + end + if gen == 3 then + for name in gbaNames:gmatch("%S+") do + local index = #catalog + 1 + local row = { "PORTABLE_HOLD_" .. index, 500 + index, name, 0, 16, index } + catalog[#catalog + 1] = row + items[#items + 1] = row + end + end + for _, row in ipairs(items) do + local name, id, effect, gold, param = unpack(row) + if backend == "gen1" and gen == 3 then + data.gen3Items._byId[id] = { + name = name:gsub("_", " "), + pocket = "ITEMS", + price = 100, + holdEffect = row[6] or ({ + NONE = 0, + LEFTOVERS = 43, + QUICK_CLAW = 26, + EVASION_UP = 22, + FOCUS_BAND = 39, + FLINCH = 30, + SCOPE_LENS = 41, + RESTORE_HP = 1, + CURE_CONFUSION = 8, + RESTORE_PP = 7, + FIRE_POWER = 58, + })[effect], + holdEffectParam = param, + } + else + local key = backend == "gen2" and gen == 3 and ("ITEM_%03d"):format(id) or name + data.items[key] = { id = key, index = id, name = name:gsub("_", " "), price = 100 } + if backend == "gen1" and gen == 2 then + data.items[key].heldEffect = "HELD_" .. (goldNames[gold] or effect) + data.items[key].heldParameter = param + elseif gen == 2 then + data.items[key].heldEffect, data.items[key].heldParam = gold, param + end + if gen >= 3 then + data.items[key].holdEffect, data.items[key].holdEffectParam = effect, param + end + end + end + end + ) + f.catalog = catalog + if backend == "gen1" and f.gen == 3 then + -- Supply native extractor-shaped item data to the real fallback reader. + local Items = require("src.core.game3.items_data") + local previous, close = Items._byId, f.close + Items._byId = f.data.gen3Items._byId + f.close = function() + Items._byId = previous + close() + end + end + local native = f.fresh + local b, step + function f.reset(item) + step = native("TACKLE") + b = step.battle + if backend == "gen1" and f.gen == 2 then + b.random = function() + return 0 + end + end + f.battle = b + for _, side in ipairs({ "player", "enemy" }) do + local mon = backend == "gen1" and f.gen == 2 and b[side] or b[side].mon + mon.hp, mon.maxHp, mon.stats = 400, 400, mon.stats or {} + mon.stats.hp, mon.nickname = 400, side == "player" and "PLAYER" or "ENEMY" + end + b.queue, b.phase, b.turnCount = {}, "menu", 1 + if f.e then + f.e.state.mode, f.e.state.trigger, f.e.state.holder = "delegate", nil, nil + f.e.state.calls, f.e.state.seen, f.e.state.views = 0, {}, {} + end + f.hold("player", item) + f.hold("enemy", nil) + return b + end + function f.mon(side) + return backend == "gen1" and f.gen == 2 and b[side] or b[side].mon + end + function f.hold(side, name) + local id + for _, row in ipairs(items) do + if row[1] == name then + id = row[2] + end + end + local key = name + if f.gen == 3 then + key = backend == "gen1" and (id or 0) or (id and ("ITEM_%03d"):format(id)) + end + f.mon(side).item = key + if backend == "gen1" and f.gen == 3 then + b[side].item = key + end + return key + end + function f.read(trigger, side) + side = side or "player" + if backend == "gen1" and f.gen == 2 then + return b:heldEffect(b[side], trigger) + end + if backend == "gen1" and f.gen == 3 then + return require("src.core.game3.battle.held_items").of(b[side], trigger, b) + end + if f.gen == 2 then + return require("src.battle.HeldItems").effect(f.data, b[side], trigger) + end + return require("src.battle.HoldItems").effectOf(b[side], trigger) + end + function f.residual() + if backend == "gen1" and f.gen == 2 then + b:betweenTurnEffects() + elseif backend == "gen1" and f.gen == 3 then + require("src.core.game3.battle.engine") + return require("src.core.game3.battle.residuals").runTurn( + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + function f.attack() + return step.use() + end + function f.flinched() + if backend == "gen1" and f.gen == 2 then + return not not b:volatile(b.enemy).flinched + end + return not not b.enemy.flinched + end + function f.confused(turns) + if backend == "gen1" and f.gen == 2 then + if turns then + b:volatile(b.player).confuseCount = turns + end + return (b:volatile(b.player).confuseCount or 0) > 0 + elseif backend == "gen1" and f.gen == 3 then + if turns then + b.player.confusionTurns = turns + end + return (b.player.confusionTurns or 0) > 0 + end + if turns then + b.player.confusedTurns = turns + end + return (b.player.confusedTurns or 0) > 0 + end + function f.pp(value) + local mon = f.mon("player") + if backend == "gen1" and f.gen == 3 then + if value then + mon.pp[1] = value + end + return mon.pp[1] + end + if value then + mon.moves[1].pp = value + end + return mon.moves[1].pp + end + function f.inflictConfusion() + if backend == "gen1" then + b:applyConfusion(b.enemy, 3, b.player) + return (b:volatile(b.enemy).confuseCount or 0) > 0 + end + require("src.battle.MoveEffects").primary.CONFUSION_EFFECT(b, b.player, b.enemy) + return (b.enemy.confusedTurns or 0) > 0 + end + function f.priority(playerSpeed, enemySpeed) + playerSpeed, enemySpeed = playerSpeed or 10, enemySpeed or 100 + if backend == "gen1" and f.gen == 2 then + b.player.stats.speed, b.enemy.stats.speed = playerSpeed, enemySpeed + return b:orderOf(f.ids.TACKLE, f.ids.TACKLE) == "player" + elseif backend == "gen1" and f.gen == 3 then + b.player.mon.speed, b.enemy.mon.speed, b.randomTurnNumber = playerSpeed, enemySpeed, 0 + local Engine = require("src.core.game3.battle.engine") + local ad = require("src.core.game3.battle.adapter").new(b) + return Engine.speedOf(b.player, b, ad) > Engine.speedOf(b.enemy, b, ad) + end + b.player.curStats.speed, b.enemy.curStats.speed = playerSpeed, enemySpeed + if f.gen == 3 and b:quickClawWins(b.player) then + return true + end + return require("src.battle.TurnOrder").firstMover( + b.player, + f.data.moves[f.ids.TACKLE], + b.enemy, + f.data.moves[f.ids.TACKLE], + b.rng, + nil, + b, + f.data + ) + end + function f.entry(side) + side = side or "player" + if backend == "gen1" then + return require("src.core.game3.battle.held_items").onSwitchIn( + require("src.core.game3.battle.adapter").new(b), + b[side] + ) + end + return require("src.battle.HeldItems").onEntry(b, b[side]) + end + function f.runEffect() + if backend == "gen1" then + b.player.mon.speed, b.enemy.mon.speed = 10, 100 + b.rng = function(lo, hi) + return hi or lo or 0 + end + return require("src.core.game3.battle.engine").tryFlee( + b, + require("src.core.game3.battle.adapter").new(b), + b.player + ) + end + b.rng = function(lo, hi) + return hi or lo or 0 + end + return b:runRoll(0, 255) + end + function f.effect(kind) + if backend == "gen1" and f.gen == 2 then + return kind + end + if f.gen == 2 then + return ({ + HELD_NONE = 0, + HELD_LEFTOVERS = 3, + HELD_BERRY = 1, + HELD_QUICK_CLAW = 74, + HELD_BRIGHTPOWDER = 77, + HELD_FOCUS_BAND = 79, + HELD_FLINCH = 75, + HELD_CRITICAL_UP = 73, + HELD_FIRE_BOOST = 58, + HELD_HEAL_CONFUSION = 16, + HELD_RESTORE_PP = 6, + HELD_PREVENT_CONFUSE = "PREVENT_CONFUSE", + })[kind] or 0 + end + local key = ({ + HELD_LEFTOVERS = "LEFTOVERS", + HELD_BERRY = "RESTORE_HP", + HELD_QUICK_CLAW = "QUICK_CLAW", + HELD_BRIGHTPOWDER = "EVASION_UP", + HELD_FOCUS_BAND = "FOCUS_BAND", + HELD_FLINCH = "FLINCH", + HELD_CRITICAL_UP = "SCOPE_LENS", + HELD_FIRE_BOOST = "FIRE_POWER", + HELD_HEAL_CONFUSION = "CURE_CONFUSION", + HELD_RESTORE_PP = "RESTORE_PP", + })[kind] + if backend == "gen2" then + return key + end + return key and require("src.core.game3.battle.held_items").HOLD[key] or 0 + end + f.reset("LEFTOVERS") + return f +end +return M diff --git a/tests/held_item/worker.lua b/tests/held_item/worker.lua new file mode 100644 index 000000000..236f909cf --- /dev/null +++ b/tests/held_item/worker.lua @@ -0,0 +1,38 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/held_item/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/held_item/cases.lua").run(f) +f.close() +-- Record an actual remaining native scheduling difference, not an equal +-- contract observation: granting berries even to empty hands can repeat in +-- the source GBA controller's after-action scans. +local policy = Fixture.new(root, backend, version, tonumber(api), format) +policy.e.state.mode, policy.e.state.trigger, policy.e.state.holder = "override", "residual", "PLAYER" +policy.e.state.effect, policy.e.state.parameter, policy.e.state.requiresItem = "HELD_BERRY", 7, false +policy.mon("player").hp = 100 +policy.residual() +local nativePolicy = { + unrestricted_residual_heal = policy.mon("player").hp - 100, + residual_reads = policy.e.state.seen.residual, +} +policy.close() +print("HELD_ITEM_NATIVE_POLICY " .. require("src.link.Json").encode(nativePolicy)) +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + bare.T.check(#bare.result.errors > 0, "entry failure recorded") + else + bare.T.eq(bare.result.loader.exports.portable_held_item, nil, "genuine no-mod fixture") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["held_item.trigger"] or {}) do + bare.T.check(link.owner ~= "portable_held_item", "rollback removes held owner") + end + bare.mon("player").hp = 100 + bare.residual() + bare.T.eq(bare.mon("player").hp, 125, "no-mod/rollback native Leftovers") + bare.close() + rows[#rows + 1] = { label = failing and "rollback" or "no mod", healed = 25 } +end +print("HELD_ITEM_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable held-effect decisions and native residuals") diff --git a/tests/modkit/catalog.lua b/tests/modkit/catalog.lua index 5fce4fd91..427560439 100644 --- a/tests/modkit/catalog.lua +++ b/tests/modkit/catalog.lua @@ -58,8 +58,8 @@ end function Catalog.events() if not events then eventSites = scan({ "src" }, { - 'Runtime%.emit%("([%w%._]+)"', - 'events:emit%("([%w%._]+)"', + 'Runtime%.emit%(%s*"([%w%._]+)"', + 'events:emit%(%s*"([%w%._]+)"', }) events = sortedKeys(eventSites) end @@ -69,8 +69,8 @@ end function Catalog.hooks() if not hooks then hookSites = scan({ "src" }, { - 'Runtime%.call%("([%w%._]+)"', - 'hooks:call%("([%w%._]+)"', + 'Runtime%.call%(%s*"([%w%._]+)"', + 'hooks:call%(%s*"([%w%._]+)"', }) hooks = sortedKeys(hookSites) end diff --git a/tools/held-item-contract-check/README.md b/tools/held-item-contract-check/README.md new file mode 100644 index 000000000..3241947e7 --- /dev/null +++ b/tools/held-item-contract-check/README.md @@ -0,0 +1,31 @@ +# Native held-item contract comparison + +```sh +python3 tools/held-item-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/held-item-contract.json +``` + +Requires Python 3, LuaJIT, a complete host checkout and clean Gen1Recomp at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The source checkout is read only. +No ROM, generated cartridge cache, private mod or network access is needed. + +Runs isolated workers for both real SDKs on Gold/Silver/Crystal and +FireRed/Emerald. Compare API 1/native, API 2/native, API 2/unified and +API 3/unified. The source API 2 lacks a unified opt-in and uses native callbacks. +The same example is loaded unchanged. Workers execute native effect reads, +moves, order/escape/entry handlers and real registered residual phases. +The native producers/effects are not stubbed and Runtime is not called by tests. + +Reports include refs, dirty status, consumer hash, paired observations and +assertion totals. Actual move damage is checked for effect direction rather +than asserting identical formulas. The source GBA residual collector is not +its complete presentation/award controller. See the public documentation for +other native boundaries. + +Native call scheduling is recorded separately as `native_policy`. Unrestricted +Berry grants even to empty hands heal 35 HP in the reference GBA fixture and +7 in the host; this known gap is printed and excluded from equal observations. +Use `--require-native-schedule-parity` as a strict negative control: it currently +fails on FireRed/Emerald. Do not interpret the default read-contract comparison +as proof of identical residual scheduling or arbitrary-mod compatibility. diff --git a/tools/held-item-contract-check/check.py b/tools/held-item-contract-check/check.py new file mode 100644 index 000000000..e2d8240a2 --- /dev/null +++ b/tools/held-item-contract-check/check.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Compare public held-effect reads and real native consumers; report native scheduling separately.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + p.add_argument("--require-native-schedule-parity", action="store_true") + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + policies = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/held_item/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("HELD_ITEM_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("HELD_ITEM_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + policy_lines = [line[len("HELD_ITEM_NATIVE_POLICY "):] for line in run.stdout.splitlines() + if line.startswith("HELD_ITEM_NATIVE_POLICY ")] + assert len(policy_lines) == 1, "missing native policy observation" + policy = json.loads(policy_lines[0]) + policies.append(policy) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native held item decision", + "observations": rows, "native_policy": policy, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"held item mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native held item observations") + if policies[0]["unrestricted_residual_heal"] != policies[1]["unrestricted_residual_heal"]: + print(f"NATIVE SCHEDULE GAP {version}: reference={policies[0]}, host={policies[1]}") + if a.require_native_schedule_parity: + raise AssertionError(f"native held-item scheduling mismatch: {version}") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_held_item/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5767780b08d580cfd34330e65776c7fc6e7015bd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:52:01 +0200 Subject: [PATCH 48/86] Align native GBA held-item action and residual phases --- .github/workflows/held-item-contract.yml | 9 +- docs/api3-portability.md | 2 +- docs/portable-held-items.md | 32 ++- src/battle/BattleState.lua | 193 ++++++++++------ src/battle/EffectRegistry.lua | 4 +- src/battle/GbaItemSchedule.lua | 113 +++++++++ src/battle/HoldItems.lua | 33 ++- src/battle/MoveEffects.lua | 1 + src/battle/Status.lua | 21 +- src/battle/Weather.lua | 21 +- tests/charge_required/fixture.lua | 2 +- tests/drivers/held_item.lua | 7 +- tests/drivers/held_item_scheduling.lua | 113 +++++++++ tests/engine/held_item_scheduling.lua | 11 + tests/held_item/fixture.lua | 55 ++++- tests/held_item/scheduling.lua | 277 +++++++++++++++++++++++ tests/held_item/worker.lua | 8 +- tools/held-item-contract-check/README.md | 13 +- tools/held-item-contract-check/check.py | 4 +- 19 files changed, 801 insertions(+), 118 deletions(-) create mode 100644 src/battle/GbaItemSchedule.lua create mode 100644 tests/drivers/held_item_scheduling.lua create mode 100644 tests/engine/held_item_scheduling.lua create mode 100644 tests/held_item/scheduling.lua diff --git a/.github/workflows/held-item-contract.yml b/.github/workflows/held-item-contract.yml index 30f9eb0c3..52dfcec4c 100644 --- a/.github/workflows/held-item-contract.yml +++ b/.github/workflows/held-item-contract.yml @@ -24,19 +24,20 @@ jobs: run: | luajit tests/engine/held_item_contract.lua luajit tests/engine/held_item_authority.lua + luajit tests/engine/held_item_scheduling.lua luajit tests/engine/api3_compat.lua luajit tests/engine/unified_mod_callbacks.lua - name: Compare both engines in every SDK mode run: | mkdir -p /tmp/held-item-contract python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ - --api 1 --format native --report /tmp/held-item-contract/api1-native.json + --api 1 --format native --require-native-schedule-parity --report /tmp/held-item-contract/api1-native.json python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ - --api 2 --format native --report /tmp/held-item-contract/api2-native.json + --api 2 --format native --require-native-schedule-parity --report /tmp/held-item-contract/api2-native.json python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ - --api 2 --format unified --report /tmp/held-item-contract/api2-unified.json + --api 2 --format unified --require-native-schedule-parity --report /tmp/held-item-contract/api2-unified.json python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ - --api 3 --format unified --report /tmp/held-item-contract/api3.json + --api 3 --format unified --require-native-schedule-parity --report /tmp/held-item-contract/api3.json - uses: actions/upload-artifact@v6 if: always() with: diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f33cbf5e1..ed2698100 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -169,4 +169,4 @@ native ownership gates, API-3 contexts and GBA presentation caching. end-of-turn views, validated damage batches and native faint/EXP authority. [Portable held-item reads](portable-held-items.md) document shared `HELD_*` -names, byte odds, native consumers and the remaining GBA scheduling gap. +names, byte odds, native consumers and the tested GBA item scheduling phases. diff --git a/docs/portable-held-items.md b/docs/portable-held-items.md index 2f92e24cf..c5ff66370 100644 --- a/docs/portable-held-items.md +++ b/docs/portable-held-items.md @@ -75,15 +75,29 @@ below half HP, matching the reference; GBA retains at-half activation. The GBA This is a held-read contract, not complete native battle equivalence. Other move/rule differences and GBA's full Choice Band locking remain separate work. -The source GBA residual scheduler includes extra after-action item scans. A -hook that grants a Berry even to **empty hands** can heal repeatedly there -(reference observation: 35 HP versus the host's 7 for a 7-HP override). The -checker records this as `native_policy` and prints `NATIVE SCHEDULE GAP`; it -is excluded from equal contract observations, never reported as equivalent. -`--require-native-schedule-parity` intentionally fails on that known gap. -The public example defaults to requiring an actual held item, so consuming it -ends the override on both engines. Counting callback invocations, unrestricted -empty-hand grants and identical residual schedules are not established. +The GBA host now separates selective move-end cures/White Herb from normal +item checks. After a completed action it retries the normal scan up to four +times, restarting at the first slot after each successful activation. Called +moves do not add another action sweep; spread moves finish all targets first. +Queued switch-in abilities complete before their normal item sweep. Leftovers +is eligible only in the regular end-of-turn phase. + +The GBA residual path orders Wish before weather, then Ingrain, abilities, +items, Leech Seed and status damage per battler. The post-residual item sweep +precedes Future Sight/Perish Song and the public field-residual hook. Weather +uses all active slots and the GBA sixteenth-max-HP chip; Leech Seed uses its +current source slot, eighth-max-HP drain and Liquid Ooze. Pinch berries respect +stage caps and existing Focus Energy; Leppa restores only up to the move's PP +cap. These changes do not replace the GB/Gen2/DS turn loops. + +The formerly unequal unrestricted 7-HP Berry fixture now heals **35 HP on both +engines**, with six residual reads. Native move/called/spread, PP, cure-before- +poison, Ingrain, Wish/weather, Seed/status, speed-order and four-slot cases run +through the same consumer and native handlers. The strict checker compares +both the raw HP result and read count and is required by CI. This proves the +listed item phases, not all battle rules: Choice Band locking, deferred +ability interactions, Quick Claw turn-RNG ordering and further volatile rules +still need their own cross-engine coverage. ## Validation diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 690025d94..2dd72f4eb 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5662,6 +5662,13 @@ function BattleState:resolveTurn(playerAction) -- switch (src/battle/Gen5MoveRules.lua) if self.bw then order = require("src.battle.Gen5MoveRules").arrange(self, order) end + if GameVersion.isGen3() then + self._gbaResidualPriority = {} + for _, entry in ipairs(order) do + local move = entry[3] and not entry[3].special and self:moveDef(entry[3]) + if move then self._gbaResidualPriority[entry[1].position] = move.priority or 0 end + end + end self.phase = "messages" self.afterQueue = "menu" @@ -5903,8 +5910,8 @@ function BattleState:abilitySwitchIn(battler) -- anything else on the field gets to speak. self:abilityForecast(battler) local act = Abilities.onSwitchIn(battler) - if not act then return end - self:abilitySwitchInEffect(battler, act) + if act then self:abilitySwitchInEffect(battler, act) end + if GameVersion.isGen3() then require("src.battle.GbaItemSchedule").afterAction(self) end end -- The visible half of a switch-in ability, split out so TRACE can run the @@ -6291,7 +6298,8 @@ function BattleState:holdItemTrigger(battler, when) local moves = battler.curMoves or battler.mon.moves local slot = moves and moves[act.slot] if not slot then return false end - local cap = slot.maxPp or slot.pp_max or act.amount + local move = GameVersion.isGen3() and self:moveDef(slot) + local cap = slot.maxPp or slot.pp_max or (move and move.pp) or act.amount slot.pp = math.min(cap, (slot.pp or 0) + act.amount) self:sayNext(Strings("%s's %s\nrestored %s's PP!", who, label, tostring(slot.id or "its move"))) @@ -6307,7 +6315,8 @@ function BattleState:holdItemTrigger(battler, when) if not stat then -- STARF BERRY picks one of the five at random local list = HoldItems.RANDOM_STATS - stat = list[rng(1, #list)] + repeat stat = list[rng(1, #list)] + until not GameVersion.isGen3() or ((battler.stages or {})[stat] or 0) < 6 end self:sayNext(Strings("%s used its\n%s!", who, label)) for _, m in ipairs(MoveEffects.changeStage(self, battler, stat, @@ -6316,7 +6325,8 @@ function BattleState:holdItemTrigger(battler, when) end elseif act.kind == "critUp" then - battler.critStage = (battler.critStage or 0) + act.delta + if GameVersion.isGen3() then battler.focusEnergy = true + else battler.critStage = (battler.critStage or 0) + act.delta end self:sayNext(Strings("%s used its\n%s!", who, label)) self:sayNext(Strings("%s is getting\npumped!", who)) @@ -6340,6 +6350,9 @@ function BattleState:endOfTurn() Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 }) return end + if GameVersion.isGen3() then + require("src.battle.GbaItemSchedule").residuals(self) + else -- sideToxic mirrors w*ToxicCounter: it advances only while the -- battler's badly-poisoned flag (toxicCounter) is set, an item/AI -- cure clears the flag but NOT the side counter, and a fresh Toxic @@ -6430,6 +6443,7 @@ function BattleState:endOfTurn() self:holdItemTrigger(self.enemy, "turn") end self:gen3CountersTick() + end self:applyFieldResiduals() self:tickTokens() self:roamerFlees() @@ -6460,77 +6474,95 @@ end -- THE COUNTERS HOENN'S NEW MOVES SET, ticked once per turn in the order -- HandleBetweenTurnEffects walks them. Each one is a plain countdown; the -- interesting part is what happens when it runs out. -function BattleState:gen3CountersTick() - for _, b in activeBattlers(self) do - if b and b.mon and b.mon.hp > 0 then - -- YAWN: the sleep lands at the END of the turn AFTER the one it was - -- used on, which is the whole reason it is worth using at all. - if b.drowsyTurns then - b.drowsyTurns = b.drowsyTurns - 1 - if b.drowsyTurns <= 0 then - b.drowsyTurns = nil - for _, m in ipairs(StatusRegistry.inflict(self, b, "SLP", {})) do - self:sayNext(m) - end - end - end - if b.tauntTurns then - b.tauntTurns = b.tauntTurns - 1 - if b.tauntTurns <= 0 then - b.tauntTurns = nil - self:sayNext(Strings("%s's taunt\nwore off!", displayName(b))) - end +function BattleState:gen3WishTick(b) + if not (b and b.mon and b.mon.hp > 0) then return end + -- WISH: half of maximum HP, arriving at the end of the turn AFTER the + -- one it was made on -- which is what makes it a switch-in heal. + if b.wishTurns then + b.wishTurns = b.wishTurns - 1 + if b.wishTurns <= 0 then + local heal = b.wishHeal or math.max(1, math.floor(maxHpOf(b) / 2)) + b.wishTurns, b.wishHeal = nil, nil + local max = maxHpOf(b) + if b.mon.hp < max then + b.mon.hp = math.min(max, b.mon.hp + heal) + self:drainNext(b, b.mon.hp) + self:sayNext(Strings("%s's wish\ncame true!", displayName(b))) end - if b.encoreTurns then - b.encoreTurns = b.encoreTurns - 1 - if b.encoreTurns <= 0 then - b.encoreTurns, b.encoreMove = nil, nil - self:sayNext(Strings("%s's ENCORE\nended!", displayName(b))) + end + end +end + +function BattleState:gen3IngrainTick(b) + if not (b and b.mon and b.mon.hp > 0) then return end + -- INGRAIN: a sixteenth back every turn for as long as it stands there + if b.ingrained and b.mon.hp < maxHpOf(b) then + b.mon.hp = math.min(maxHpOf(b), + b.mon.hp + math.max(1, math.floor(maxHpOf(b) / 16))) + self:drainNext(b, b.mon.hp) + self:sayNext(Strings("%s absorbed\nnutrients with its\nroots!", + displayName(b))) + end +end + +function BattleState:gen3CountersTick() + for _, b in activeBattlers(self) do self:gen3BattlerCountersTick(b) end + self:gen3DelayedCountersTick() +end + +function BattleState:gen3BattlerCountersTick(b, scheduled) + if b and b.mon and b.mon.hp > 0 then + -- YAWN: the sleep lands at the END of the turn AFTER the one it was + -- used on, which is the whole reason it is worth using at all. + if b.drowsyTurns then + b.drowsyTurns = b.drowsyTurns - 1 + if b.drowsyTurns <= 0 then + b.drowsyTurns = nil + for _, m in ipairs(StatusRegistry.inflict(self, b, "SLP", {})) do + self:sayNext(m) end end - if b.safeguardTurns then - b.safeguardTurns = b.safeguardTurns - 1 - if b.safeguardTurns <= 0 then - b.safeguardTurns = nil - self:sayNext(Strings("%s's party is no\nlonger protected!", - displayName(b))) - end + end + if b.tauntTurns then + b.tauntTurns = b.tauntTurns - 1 + if b.tauntTurns <= 0 then + b.tauntTurns = nil + self:sayNext(Strings("%s's taunt\nwore off!", displayName(b))) end - -- WISH: half of maximum HP, arriving at the end of the turn AFTER the - -- one it was made on -- which is what makes it a switch-in heal. - if b.wishTurns then - b.wishTurns = b.wishTurns - 1 - if b.wishTurns <= 0 then - local heal = b.wishHeal or math.max(1, math.floor(maxHpOf(b) / 2)) - b.wishTurns, b.wishHeal = nil, nil - local max = maxHpOf(b) - if b.mon.hp < max then - b.mon.hp = math.min(max, b.mon.hp + heal) - self:drainNext(b, b.mon.hp) - self:sayNext(Strings("%s's wish\ncame true!", displayName(b))) - end - end + end + if b.encoreTurns then + b.encoreTurns = b.encoreTurns - 1 + if b.encoreTurns <= 0 then + b.encoreTurns, b.encoreMove = nil, nil + self:sayNext(Strings("%s's ENCORE\nended!", displayName(b))) end - -- INGRAIN: a sixteenth back every turn for as long as it stands there - if b.ingrained and b.mon.hp < maxHpOf(b) then - b.mon.hp = math.min(maxHpOf(b), - b.mon.hp + math.max(1, math.floor(maxHpOf(b) / 16))) - self:drainNext(b, b.mon.hp) - self:sayNext(Strings("%s absorbed\nnutrients with its\nroots!", + end + if b.safeguardTurns then + b.safeguardTurns = b.safeguardTurns - 1 + if b.safeguardTurns <= 0 then + b.safeguardTurns = nil + self:sayNext(Strings("%s's party is no\nlonger protected!", displayName(b))) end - -- UPROAR: three turns of noise, and nobody sleeps through it - if b.uproarTurns then - b.uproarTurns = b.uproarTurns - 1 - if b.uproarTurns <= 0 then - b.uproarTurns = nil - self:sayNext(Strings("%s calmed down.", displayName(b))) - else - self:sayNext(Strings("%s is making\nan UPROAR!", displayName(b))) - end + end + if not scheduled then + self:gen3WishTick(b) + self:gen3IngrainTick(b) + end + -- UPROAR: three turns of noise, and nobody sleeps through it + if b.uproarTurns then + b.uproarTurns = b.uproarTurns - 1 + if b.uproarTurns <= 0 then + b.uproarTurns = nil + self:sayNext(Strings("%s calmed down.", displayName(b))) + else + self:sayNext(Strings("%s is making\nan UPROAR!", displayName(b))) end end end +end + +function BattleState:gen3DelayedCountersTick() -- FUTURE SIGHT and DOOM DESIRE, whose damage was rolled two turns ago and -- lands on whoever is standing there now. for index, side in ipairs(self.sides or {}) do @@ -7708,7 +7740,17 @@ function BattleState:executeAction(user, target, action) end end end + local parent = self._gbaActionFrame + local frame = GameVersion.isGen3() and {} or nil + if frame then self._gbaActionFrame = frame end run() + if frame then + self._gbaActionFrame = parent + local Schedule = require("src.battle.GbaItemSchedule") + if action.id and not frame.moved then Schedule.moveEnd(self) end + -- Switch-in abilities are queued; their completion performs the sweep. + if not isSwitch then Schedule.afterAction(self) end + end -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) self:actNext(function() self:syncShownStatus() end) end @@ -7981,7 +8023,8 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +local function performMove(battle, user, target, moveInst, isCalled, suppressAnimation) + local self = battle local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8236,6 +8279,22 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end end +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) + if not GameVersion.isGen3() then + return performMove(self, user, target, moveInst, isCalled, suppressAnimation) + end + local Schedule = require("src.battle.GbaItemSchedule") + local parent = self._gbaMoveFrame + local frame = {} + if parent then parent.called = true end + self._gbaMoveFrame = frame + if self._gbaActionFrame then self._gbaActionFrame.moved = true end + performMove(self, user, target, moveInst, isCalled, suppressAnimation) + if not frame.finished and not frame.called then Schedule.moveEnd(self) end + self._gbaMoveFrame = parent + if not isCalled and not parent and not self._gbaActionFrame then Schedule.afterAction(self) end +end + function BattleState:continueTrapping(user, target) self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 0f731f0aa..db0c4df98 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -428,6 +428,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- KING'S ROCK flinches the target, SHELL BELL feeds the attacker, and a -- SITRUS BERRY the hit dropped below half goes off immediately rather -- than waiting for the end of the turn. + local gba = require("src.core.GameVersion").isGen3() + if gba then require("src.battle.GbaItemSchedule").moveEnd(battle) end if totalDealt > 0 and battle.shellBellDrain then local flinch = HoldItems.flinchChance(user, move) if flinch > 0 and target.mon.hp > 0 and not target.substituteHP @@ -436,7 +438,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) target.flinched = true end battle:shellBellDrain(user, totalDealt) - if target.mon.hp > 0 then battle:holdItemTrigger(target, "hit") end + if not gba and target.mon.hp > 0 then battle:holdItemTrigger(target, "hit") end end if target.mon.hp <= 0 then diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua new file mode 100644 index 000000000..ce6d584ce --- /dev/null +++ b/src/battle/GbaItemSchedule.lua @@ -0,0 +1,113 @@ +-- Gen-3 item phases. Reads are deliberately not coalesced: a mod may change +-- the answer between move-end, normal turn and after-action comparisons. +local Schedule = {} + +function Schedule.battlers(battle) + local list = {} + for _, b in battle:activeBattlers() do + list[#list + 1] = b + end + return list +end + +function Schedule.moveEnd(battle) + local frame = battle._gbaMoveFrame + if frame then + frame.finished = true + end + for _, b in ipairs(Schedule.battlers(battle)) do + battle:holdItemTrigger(b, "moveEnd") + end +end + +function Schedule.afterAction(battle) + if battle.result then + return + end + -- battle_util.c ItemBattleEffects: restart at the first slot after a + -- successful activation, at most four passes. Leftovers is not eligible. + for _ = 1, 4 do + local did = false + for _, b in ipairs(Schedule.battlers(battle)) do + if battle:holdItemTrigger(b, "action") then + did = true + break + end + end + if not did then + break + end + end +end + +function Schedule.residualOrder(battle) + local list, speeds = Schedule.battlers(battle), {} + local TurnOrder = require("src.battle.TurnOrder") + for _, b in ipairs(list) do + speeds[b] = TurnOrder.effectiveSpeed(b, battle) + end + local priorities = (battle:isDouble() and battle._gbaResidualPriority) or {} + local rng = battle.rng or love.math.random + -- Match the reference's pairwise selection sort and tie rolls. Compute + -- this once, before weather/items can change speed or remove a battler. + for i = 1, #list - 1 do + for j = i + 1, #list do + local a, b = list[i], list[j] + local pa, pb = priorities[a.position] or 0, priorities[b.position] or 0 + local swap + if pa ~= pb then + swap = pa < pb + elseif speeds[a] == speeds[b] then + swap = rng(0, 1) == 1 + else + swap = speeds[a] < speeds[b] + end + if swap then + list[i], list[j] = b, a + end + end + end + return list +end + +function Schedule.residuals(battle) + local order = Schedule.residualOrder(battle) + -- Wish is a field phase, before weather; Ingrain belongs to each holder. + for _, b in ipairs(order) do + battle:gen3WishTick(b) + end + require("src.battle.Weather").upkeep(battle, order) + for _, b in ipairs(order) do + if battle.result then + break + end + if b.mon.hp > 0 then + battle:gen3IngrainTick(b) + battle:abilityEndOfTurn(b) + battle:holdItemTrigger(b, "turn") + local opponent = battle:foesOf(b)[1] + local messages, seedSource = require("src.battle.Status").residual(b, opponent, battle) + for _, message in ipairs(messages) do + battle:sayNext(message) + end + if #messages > 0 then + battle:drainNext() + end + if seedSource then + seedSource.destinyBond, seedSource.grudge = nil, nil + battle:onFaint(seedSource) + end + if b.mon.hp <= 0 then + b.destinyBond, b.grudge = nil, nil + battle:onFaint(b) + else + battle:gen3BattlerCountersTick(b, true) + end + end + end + -- The native fainted-action/item sweep precedes delayed attacks and Perish. + Schedule.afterAction(battle) + battle:gen3DelayedCountersTick() +end + +return Schedule diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index 1a5090c43..160f52f9a 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -314,6 +314,7 @@ end -- { kind = "critUp", delta = 2 } LANSAT BERRY -- { kind = "restoreStats" } WHITE HERB function HoldItems.trigger(battler, when) + local gba = require("src.core.GameVersion").isGen3() local effect, param = HoldItems.effectOf(battler, when == "entry" and "check" or "residual") if not effect then return nil end local mon = battler.mon @@ -323,6 +324,13 @@ function HoldItems.trigger(battler, when) if effect == "DOUBLE_PRIZE" then return { kind = "prize", consume = false } end if effect ~= "RESTORE_STATS" then return nil end end + -- MOVEEND_ITEM_EFFECTS_ALL is a selective cure/herb scan, not the normal + -- HP/PP/pinch check. In particular it must not add a fifth Berry grant + -- before the four after-action passes. + if when == "moveEnd" and not (CURE_STATUS[effect] or effect == "CURE_STATUS" + or effect == "CURE_CONFUSION" or effect == "CURE_ATTRACT" or effect == "RESTORE_STATS") then + return nil + end -- LEFTOVERS, the one that is never eaten. Its parameter is 10 and it is -- NOT a percentage: ItemBattleEffects' LEFTOVERS arm reads maxHP / 16 and @@ -341,7 +349,7 @@ function HoldItems.trigger(battler, when) if effect == "RESTORE_HP" then if mon.hp * 2 > max then return nil end if mon.hp >= max then return nil end - return { kind = "heal", effect = effect, amount = math.max(1, param), + return { kind = "heal", effect = effect, amount = math.max(gba and 0 or 1, param), consume = true } end @@ -352,13 +360,15 @@ function HoldItems.trigger(battler, when) if mon.hp * 2 > max then return nil end if mon.hp >= max then return nil end return { kind = "heal", effect = effect, - amount = math.max(1, math.floor(max / (param > 0 and param or 8))), - confuse = HoldItems.dislikesFlavour(battler, flavourStat), + amount = math.max(1, math.floor(max / (gba and math.max(1, param) or (param > 0 and param or 8)))), + confuse = HoldItems.dislikesFlavour(battler, flavourStat) + and (not gba or (require("src.battle.Abilities").of(battler) ~= "OWN_TEMPO" + and not battler.confusedTurns)), consume = true } end if effect == "RESTORE_PP" then - if when ~= "turn" then return nil end + if when ~= "turn" and when ~= "action" then return nil end -- the caller finds the empty move; nil here means every move has PP local slot for i, m in ipairs(battler.curMoves or mon.moves or {}) do @@ -366,10 +376,10 @@ function HoldItems.trigger(battler, when) end if not slot then return nil end return { kind = "restorePP", effect = effect, slot = slot, - amount = math.max(1, param), consume = true } + amount = math.max(gba and 0 or 1, param), consume = true } end - if when ~= "turn" and when ~= "entry" then return nil end + if when ~= "turn" and when ~= "entry" and when ~= "action" and when ~= "moveEnd" then return nil end local cured = CURE_STATUS[effect] if cured then @@ -401,19 +411,24 @@ function HoldItems.trigger(battler, when) -- the pinch berries, at a quarter of maximum HP or less local pinchStat = PINCH_STAT[effect] - local divisor = (param and param > 0) and param or 4 + local divisor = gba and math.max(1, param) or ((param > 0) and param or 4) if pinchStat then - if mon.hp * divisor > max then return nil end + if mon.hp * divisor > max or (gba and ((battler.stages or {})[pinchStat] or 0) >= 6) then return nil end return { kind = "statUp", effect = effect, stat = pinchStat, delta = 1, consume = true } end if effect == "RANDOM_STAT_UP" then if mon.hp * divisor > max then return nil end + local eligible = false + for _, stat in ipairs(HoldItems.RANDOM_STATS) do + if ((battler.stages or {})[stat] or 0) < 6 then eligible = true break end + end + if gba and not eligible then return nil end return { kind = "statUp", effect = effect, stat = nil, delta = 2, consume = true } end if effect == "CRITICAL_UP" then - if mon.hp * divisor > max then return nil end + if mon.hp * divisor > max or (gba and battler.focusEnergy) then return nil end return { kind = "critUp", effect = effect, delta = 2, consume = true } end return nil diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5c26301a4..77f64664f 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -270,6 +270,7 @@ MoveEffects.primary = { if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true + if require("src.core.GameVersion").isGen3() then target.leechSeedSource = user end return { Strings("%s\nwas seeded!", displayName(target)) } end, diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..b8c1aa792 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -313,13 +313,30 @@ function Status.residual(battler, opponent, battle) -- mon had already moved, drop the flag before it leaks into next turn battler.skipMove = nil if mon.hp <= 0 then return msgs end + local gba = require("src.core.GameVersion").isGen3() + local seedSource + if gba and battler.leechSeeded then + local source = battler.leechSeedSource or opponent + -- Resolve the slot again: a switched-out seeder does not receive HP. + if source and source.position and battle.battlerAt then source = battle:battlerAt(source.position) end + if source and source.mon.hp > 0 then + local dmg = math.min(mon.hp, math.max(1, math.floor(mon.stats.hp / 8))) + mon.hp = mon.hp - dmg + if require("src.battle.Abilities").of(battler) == "LIQUID_OOZE" then + source.mon.hp = math.max(0, source.mon.hp - dmg) + if source.mon.hp == 0 then seedSource = source end + else source.mon.hp = math.min(source.mon.stats.hp, source.mon.hp + dmg) end + msgs[#msgs + 1] = Strings("LEECH SEED saps\n%s!", name(battler)) + end + end + if mon.hp <= 0 then return msgs, seedSource end local record = Status.recordFor(battleStatuses(battle), mon.status) if record and record.residual then for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do msgs[#msgs + 1] = m end end - if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then + if not gba and battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then -- the shared Toxic counter multiplies (and advances on) the seed -- drain too -- the Gen 1 Leech Seed glitch -- (HandlePoisonBurnLeechSeed_DecreaseOwnHP) @@ -333,7 +350,7 @@ function Status.residual(battler, opponent, battle) opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler))) end - return msgs + return msgs, seedSource end return Status diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..69c797d24 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -286,9 +286,9 @@ local function maxHpOf(battler) return (mon.stats and mon.stats.hp) or mon.maxHp or 8 end -local function weatherDamage(battle, battler, weather) +local function weatherDamage(battle, battler, weather, gba) if not (battler and battler.mon) or battler.mon.hp <= 0 then return end - if underground(battler) then return end + if underground(battler) or (gba and battler.hiddenAs == "underwater") then return end -- SAND VEIL: a CACNEA stands in its own sandstorm untouched. Gen 1 and -- Gen 2 answer false here, so nothing about the older sandstorm changes. if require("src.battle.Abilities") @@ -303,19 +303,22 @@ local function weatherDamage(battle, battler, weather) battler.isPlayer) -- GetEighthMaxHP is GetQuarterMaxHP followed by one more shift, each step -- floored at 1: floor(floor(maxHP / 4) / 2), minimum 1 - local eighth = math.max(1, math.floor(math.floor(maxHpOf(battler) / 4) / 2)) + local eighth = math.max(1, math.floor(maxHpOf(battler) / (gba and 16 or 8))) battle:applyDamage(battler, eighth) battle:sayNext(weather == Weather.HAIL and Strings("%s is\npelted by HAIL!", displayName(battler)) or Strings("The SANDSTORM hits\n%s!", displayName(battler))) battle:drainNext() - if battler.mon.hp <= 0 then battle:onFaint(battler) end + if battler.mon.hp <= 0 then + if gba then battler.destinyBond, battler.grudge = nil, nil end + battle:onFaint(battler) + end end -- HandleWeather, called once per turn from HandleBetweenTurnEffects after -- HandleFutureSight and before HandleWrap. The count is decremented first: -- on zero the "ended" line prints and NO sandstorm damage is dealt. -function Weather.upkeep(battle) +function Weather.upkeep(battle, order) local field = battle and battle.field local weather = field and field.weather if not weather then return end @@ -342,8 +345,12 @@ function Weather.upkeep(battle) -- HandleWeather damages the player's side first and the enemy's second. -- That order is the serial-connection one (hSerialConnectionStatus), not a -- speed check, so a single-player battle is always player-then-enemy. - weatherDamage(battle, battle.player, weather) - weatherDamage(battle, battle.enemy, weather) + if order then + for _, b in ipairs(order) do weatherDamage(battle, b, weather, true) end + else + weatherDamage(battle, battle.player, weather) + weatherDamage(battle, battle.enemy, weather) + end end return Weather diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua index 71488abc1..d10027b54 100644 --- a/tests/charge_required/fixture.lua +++ b/tests/charge_required/fixture.lua @@ -101,7 +101,7 @@ function M.new(root, backend, version, api, format, noMod, failing, consumer, pr } end end - if prepare then prepare(data, gen, backend) end + if prepare then prepare(data, gen, backend, rom, names) end local files, Json = {}, require("src.link.Json") for _, name in ipairs({ "main.lua", "manifest.json" }) do local f = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) diff --git a/tests/drivers/held_item.lua b/tests/drivers/held_item.lua index b8258ce47..f839e9a97 100644 --- a/tests/drivers/held_item.lua +++ b/tests/drivers/held_item.lua @@ -78,14 +78,19 @@ return function(game) local b = fresh(240) e.state.mode, e.state.trigger, e.state.holder = mode, "residual", "PLAYER" e.state.effect, e.state.parameter = "HELD_BERRY", 7 + local lost = 0 + local off = e.api.events:on("battle.damage_dealt", function(ctx) + if ctx.target.mon.nickname == "PLAYER" then lost = lost + ctx.damage end + end) U.tap(game, "a") assert(b.phase == "moveSelect", "native FIGHT did not open move selection") U.tap(game, "a") drain(b) + off() local ctx = assert(e.state.selected, "native player's held read missing") assert(ctx.mon.species == "MEW" and ctx.item == "LEFTOVERS", "portable imported held IDs") assert( - b.player.mon.hp == ctx.mon.hp + (mode == "override" and 7 or 0), + b.player.mon.hp == 240 - lost + (mode == "override" and 7 or 0), "native input/HP follows held decision" ) assert((b.player.mon.item ~= nil) == (mode ~= "override"), "native held consumption") diff --git a/tests/drivers/held_item_scheduling.lua b/tests/drivers/held_item_scheduling.lua new file mode 100644 index 000000000..ec085f2f0 --- /dev/null +++ b/tests/drivers/held_item_scheduling.lua @@ -0,0 +1,113 @@ +-- Imported-data native input/held-effect/result-queue smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_held_item, "install held-item example") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle, leftovers, splash + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + end + if def.name == "SPLASH" then + splash = id + end + end + for id, def in pairs(game.data.items) do + if def.name == "LEFTOVERS" then + leftovers = id + break + end + end + assert(species and tackle and leftovers and splash, "imported native metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh(hp) + local p = P.new(game.data, species, 30) + p.hp, p.stats.hp, p.nickname, p.item = hp or 300, 500, "PLAYER", leftovers + p.moves = { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp, b.enemy.mon.nickname = 500, 500, "ENEMY" + b.enemy.mon.item, b.enemy.mon.heldItem = leftovers, nil + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + e.state.mode, e.state.trigger, e.state.holder, e.state.requiresItem = "delegate", nil, nil, true + e.state.calls, e.state.seen, e.state.views, e.state.selected = 0, {}, {}, nil + return b + end + local function drain(b) + for _ = 1, 4000 do + if b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native held-item result queue did not settle") + end + assert(V.generation() == 3, "GBA scheduling smoke only") + local b = fresh(100) + b.player.mon.item, b.enemy.mon.item = nil, nil + b.enemy.mon.moves = { { id = splash, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + e.state.mode, e.state.trigger, e.state.holder = "override", "residual", "PLAYER" + e.state.effect, e.state.parameter, e.state.requiresItem = "HELD_BERRY", 7, false + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT missing") + U.tap(game, "a") + drain(b) + assert(b.turnCount == 1, "exactly one native turn") + -- Four scans after each action; normal turn check and four post-turn scans. + assert(b.player.mon.hp == 191, "native action/residual grants must heal 13*7") + assert(e.state.seen.residual >= 13, "native held reads missing") + + b = fresh(20) + b.player.mon.status = "PSN" + e.state.mode, e.state.trigger, e.state.holder = "override", "residual", "PLAYER" + e.state.effect, e.state.parameter = "HELD_HEAL_POISON", 0 + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b.player.mon.hp == 20 and not b.player.mon.status, "cure must precede lethal poison") + assert(not b.player.faintQueued and not b.result, "cured holder must remain in battle") + assert(not b.player.mon.item, "native item consumed") + + b = fresh(100) + b.player.mon.item, b.enemy.mon.item = nil, nil + e.stop() + b:endOfTurn() + assert(b.player.mon.hp == 100 and e.state.calls == 0, "unsubscribe restores native empty hands") + print("PORTABILITY_HELD_ITEM_SCHEDULING_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/held_item_scheduling.lua b/tests/engine/held_item_scheduling.lua new file mode 100644 index 000000000..1ced9d567 --- /dev/null +++ b/tests/engine/held_item_scheduling.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/held_item/fixture.lua") +local Cases = dofile("tests/held_item/scheduling.lua") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + Cases.run(f) + f.close() + end +end +require("tests.modkit").finish("native GBA held-item scheduling") diff --git a/tests/held_item/fixture.lua b/tests/held_item/fixture.lua index 67da8a0d7..d3f001c13 100644 --- a/tests/held_item/fixture.lua +++ b/tests/held_item/fixture.lua @@ -41,11 +41,26 @@ UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK noMod, failing, "portable_held_item", - function(data, gen) + function(data, gen, _, rom, names) if backend == "gen2" then data.constants.generation = gen end if gen == 3 then + for _, move in ipairs({ { "SURF", 57, 95, 8, 0, "NO_ADDITIONAL_EFFECT" }, + { "MIRROR_MOVE", 119, 0, 0, 9, "MIRROR_MOVE_EFFECT" } }) do + local name, number, power, target, effect, hostEffect = unpack(move) + if backend == "gen1" then + data.gen3Moves._rom[number] = { effect = effect, power = power, type = 0, + accuracy = 100, pp = 10, target = target, priority = 0, flags = 51 } + data.gen3Pokemon._moveNames[number] = name + rom[number], names[number] = data.gen3Moves._rom[number], name + else + local id = ("MOVE_%03d"):format(number) + data.moves[id] = { id = id, index = number, name = name, power = power, + type = "NORMAL", accuracy = 100, pp = 10, target = target, + priority = 0, effect = hostEffect, kingsRockAffected = true } + end + end for name in gbaNames:gmatch("%S+") do local index = #catalog + 1 local row = { "PORTABLE_HOLD_" .. index, 500 + index, name, 0, 16, index } @@ -104,9 +119,25 @@ UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK end local native = f.fresh local b, step - function f.reset(item) + function f.reset(item, double) step = native("TACKLE") b = step.battle + if double then + local copy = require("src.mods.Merge").deepCopy + if backend == "gen1" then + b = require("src.core.game3.battle.state").new({ double = true, wild = true, + playerParty = { b.player.mon, copy(b.player.mon) }, + foeParty = { b.enemy.mon, copy(b.enemy.mon) } }) + b.rng = function(lo) return lo or 0 end + else + local extra = native("TACKLE").battle + b.playerParty, b.enemyParty = { b.player.mon, extra.player.mon }, { b.enemy.mon, extra.enemy.mon } + f.game.save.party, b.double = b.playerParty, true + b:placeBattler(2, extra.player) + b:placeBattler(3, extra.enemy) + b:syncSides() + end + end if backend == "gen1" and f.gen == 2 then b.random = function() return 0 @@ -172,8 +203,24 @@ UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK b:endOfTurn() end end - function f.attack() - return step.use() + function f.attack(called) + return step.use(called) + end + function f.resolve(name, execute) + local number = ({ TACKLE = 33, SURF = 57, MIRROR_MOVE = 119 })[name] + if backend == "gen1" then + b.player.mon.moves, b.player.mon.pp = { number }, { 10 } + return require("src.core.game3.battle.engine").resolveMove(b.player, b.enemy, number, 1, + require("src.core.game3.battle.adapter").new(b), b, {}, {}) + end + local action = { id = ("MOVE_%03d"):format(number), pp = 10 } + b.player.mon.moves, b.player.curMoves = { action }, { action } + if execute then return b:executeAction(b.player, b.enemy, action) end + return b:performMove(b.player, b.enemy, action) + end + function f.actor(id) + if backend == "gen1" then return require("src.core.game3.battle.state").battler(b, id) end + return b:battlerAt(id) end function f.flinched() if backend == "gen1" and f.gen == 2 then diff --git a/tests/held_item/scheduling.lua b/tests/held_item/scheduling.lua new file mode 100644 index 000000000..a8e232900 --- /dev/null +++ b/tests/held_item/scheduling.lua @@ -0,0 +1,277 @@ +local M = {} +function M.run(f) + if f.gen ~= 3 then + return {} + end + local T, rows, trace = f.T, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(next, ctx) + if ctx.trigger == "residual" then + trace[#trace + 1] = ctx.mon.nickname + end + return next(ctx) + end, 100) + local function reset(item) + f.reset(item) + trace = {} + end + local function observe(label, expected) + for key, value in pairs(expected) do + local got = key == "reads" and table.concat(trace, ",") + or key == "player" and f.mon("player").hp + or key == "enemy" and f.mon("enemy").hp + or key == "pp" and f.pp() + T.eq(got, value, "native schedule " .. label .. ":" .. key) + end + local row = { label = label } + for key, value in pairs(expected) do + row[key] = value + end + rows[#rows + 1] = row + end + local function grant(effect, parameter, holder) + local s = f.e.state + s.mode, s.trigger, s.holder, s.requiresItem = "override", "residual", holder, false + s.effect, s.parameter = effect, parameter + end + local function speed(player, enemy) + if f.backend == "gen1" then + f.mon("player").speed, f.mon("enemy").speed = player, enemy + else + f.battle.player.curStats.speed, f.battle.enemy.curStats.speed = player, enemy + end + end + local function poison() + f.mon("player").status = "PSN" + if f.backend == "gen1" then + f.battle.player.status = "PSN" + end + end + local function synthetic(effect) + for _, row in ipairs(f.catalog) do + if row[3] == effect then + return row[1] + end + end + error("missing effect " .. effect) + end + reset() + speed(100, 10) + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.residual() + observe("residual retry", { player = 135, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" }) + + reset() + speed(100, 10) + f.mon("player").hp, f.mon("enemy").hp = 100, 100 + grant("HELD_BERRY", 7) + f.residual() + observe( + "first successful slot restarts", + { player = 135, enemy = 107, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.attack() + observe("move then after action", { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" }) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.attack(true) + observe("called move has no separate action", { player = 100, reads = "PLAYER,ENEMY" }) + + reset("LEPPA_BERRY") + f.pp(1) + f.attack() + observe("PP restores after move", { pp = 10 }) + T.check(not f.mon("player").item or f.mon("player").item == 0, "native Leppa consumed") + + reset() + f.pp(0) + grant("HELD_RESTORE_PP", 16, "PLAYER") + f.residual() + observe("PP restoration respects move cap", { pp = 10 }) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.mon("player").status = "PAR" + if f.backend == "gen1" then + f.battle.player.status = "PAR" + end + f.resolve("TACKLE", true) + observe( + "canceled action still checks items", + { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + reset(synthetic("CURE_PSN")) + f.mon("player").hp = 20 + poison() + f.residual() + observe("cure precedes lethal poison", { player = 20 }) + T.check(not f.mon("player").status, "native poison cure") + + reset("ORAN_BERRY") + f.mon("player").hp = 20 + poison() + f.residual() + observe("HP berry cannot revive poison faint", { player = 0 }) + T.check(not f.mon("player").item or f.mon("player").item == 0, "berry consumed before poison faint") + + reset() + f.mon("player").hp = 200 + poison() + grant("HELD_BERRY", 7, "PLAYER") + f.residual() + -- Normal Berry triggers at 200, poison subtracts 50, then four retries. + observe("normal item before chip and retries after", { player = 185 }) + + reset("LEFTOVERS") + f.mon("player").hp = 20 + if f.backend == "gen1" then + f.battle.player.expIngrain = true + else + f.battle.player.ingrained = true + end + poison() + f.residual() + observe("Ingrain item then poison", { player = 20 }) + + reset("LEFTOVERS") + f.mon("player").hp, f.mon("enemy").hp = 200, 200 + poison() + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, f.battle.enemy + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, f.battle.enemy + end + f.residual() + observe("item then seed then poison", { player = 125, enemy = 250 }) + + reset() + speed(100, 10) + f.mon("player").hp, f.mon("enemy").hp = 100, 20 + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, f.battle.enemy + f.battle.player.ability = "LIQUID_OOZE" + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, f.battle.enemy + f.battle.player.abilityOverride = "LIQUID_OOZE" + end + f.residual() + observe("Liquid Ooze faints seed source", { player = 50, enemy = 0 }) + if f.backend == "gen2" then + T.check(f.battle.enemy.faintQueued, "seed source uses native faint queue") + end + + reset("LEFTOVERS") + speed(100, 10) + f.mon("player").hp = 20 + if f.backend == "gen1" then + f.battle.playerSide.tokens = { { id = "EXP_WISH", turns = 1 } } + f.battle.weather, f.battle.weatherTurns = "SAND", 2 + else + f.battle.player.wishTurns = 1 + f.battle.field.weather, f.battle.field.weatherTurns = "SANDSTORM", 2 + end + f.residual() + observe("Wish before weather and Leftovers", { player = 220, enemy = 375 }) + + for _, speeds in ipairs({ + { 100, 10, "PLAYER,ENEMY,PLAYER,ENEMY" }, + { 10, 100, "ENEMY,PLAYER,PLAYER,ENEMY" }, + { 50, 50, "PLAYER,ENEMY,PLAYER,ENEMY" }, + }) do + reset() + speed(speeds[1], speeds[2]) + f.residual() + observe("residual speed " .. speeds[1] .. ":" .. speeds[2], { reads = speeds[3] }) + end + + for _, effect in ipairs({ "ATTACK_UP", "RANDOM_STAT_UP", "CRITICAL_UP" }) do + reset(synthetic(effect)) + f.mon("player").hp = 10 + local p = f.battle.player + for _, stat in ipairs({ "attack", "defense", "speed", "spatk", "spdef", "spAtk", "spDef" }) do + p.stages[stat] = 6 + end + p.focusEnergy = true + local held = f.mon("player").item + f.residual() + T.eq(f.mon("player").item, held, "ineligible pinch effect is not consumed: " .. effect) + observe("ineligible " .. effect, { player = 10 }) + end + reset() + f.mon("player").hp = 100 + speed(100, 10) + grant("HELD_BERRY", 0, "PLAYER") + f.residual() + observe( + "zero heal still activates bounded retries", + { player = 100, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + if f.backend == "gen1" then + f.battle.player.expLastTakenMove = 33 + else + f.battle.enemy.lastMove = f.ids.TACKLE + end + f.resolve("MIRROR_MOVE", true) + observe( + "nested Mirror Move has one action sweep", + { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + for _, spread in ipairs({ false, true }) do + f.reset(nil, true) + trace = {} + for id = 0, 3 do + local actor = f.actor(id) + actor.mon.hp, actor.mon.maxHp = 400, 400 + actor.mon.stats = actor.mon.stats or {} + actor.mon.stats.hp = 400 + actor.mon.nickname = ({ "PLAYER", "ENEMY", "ALLY", "FOE" })[id + 1] + end + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.resolve(spread and "SURF" or "TACKLE", true) + local selective = "PLAYER,ENEMY,ALLY,FOE," + observe( + spread and "spread has one action sweep" or "double action slot order", + { player = 128, reads = selective .. (spread and selective or "") .. "PLAYER,PLAYER,PLAYER,PLAYER" } + ) + end + f.reset(nil, true) + trace = {} + for id = 0, 3 do + local actor = f.actor(id) + actor.mon.hp, actor.mon.maxHp = 100, 400 + actor.mon.stats = actor.mon.stats or {} + actor.mon.stats.hp = 400 + actor.mon.nickname = ({ "PLAYER", "ENEMY", "ALLY", "FOE" })[id + 1] + if f.backend == "gen1" then + actor.mon.speed = 100 - id * 10 + else + actor.curStats.speed = 100 - id * 10 + end + end + grant("HELD_BERRY", 7) + f.residual() + observe( + "double residual retries first slot", + { player = 135, enemy = 107, reads = "PLAYER,ENEMY,ALLY,FOE,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + T.eq(f.actor(2).mon.hp, 107, "ally receives normal item phase") + T.eq(f.actor(3).mon.hp, 107, "second foe receives normal item phase") + + off() + f.e.state.requiresItem = true + return rows +end +return M diff --git a/tests/held_item/worker.lua b/tests/held_item/worker.lua index 236f909cf..b8a7f9b92 100644 --- a/tests/held_item/worker.lua +++ b/tests/held_item/worker.lua @@ -2,11 +2,11 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, format = unpack(arg) local Fixture = dofile(root .. "/tests/held_item/fixture.lua") local f = Fixture.new(root, backend, version, tonumber(api), format) -local rows = dofile(root .. "/tests/held_item/cases.lua").run(f) +local rows = dofile(root .. "/tests/held_item/scheduling.lua").run(f) +for _, row in ipairs(dofile(root .. "/tests/held_item/cases.lua").run(f)) do rows[#rows + 1] = row end f.close() --- Record an actual remaining native scheduling difference, not an equal --- contract observation: granting berries even to empty hands can repeat in --- the source GBA controller's after-action scans. +-- Keep the raw native policy in the report as well as the paired action/ +-- residual cases. Strict CI compares the complete record, including reads. local policy = Fixture.new(root, backend, version, tonumber(api), format) policy.e.state.mode, policy.e.state.trigger, policy.e.state.holder = "override", "residual", "PLAYER" policy.e.state.effect, policy.e.state.parameter, policy.e.state.requiresItem = "HELD_BERRY", 7, false diff --git a/tools/held-item-contract-check/README.md b/tools/held-item-contract-check/README.md index 3241947e7..4d201a033 100644 --- a/tools/held-item-contract-check/README.md +++ b/tools/held-item-contract-check/README.md @@ -23,9 +23,10 @@ than asserting identical formulas. The source GBA residual collector is not its complete presentation/award controller. See the public documentation for other native boundaries. -Native call scheduling is recorded separately as `native_policy`. Unrestricted -Berry grants even to empty hands heal 35 HP in the reference GBA fixture and -7 in the host; this known gap is printed and excluded from equal observations. -Use `--require-native-schedule-parity` as a strict negative control: it currently -fails on FireRed/Emerald. Do not interpret the default read-contract comparison -as proof of identical residual scheduling or arbitrary-mod compatibility. +Native call scheduling is also recorded as `native_policy`. Unrestricted +7-HP Berry grants now heal 35 HP on both GBA engines, with six residual reads. +`--require-native-schedule-parity` checks the entire policy record (HP and read +count) and is required in all four CI modes. Shared cases additionally cover +real called/spread actions, canceled actions, PP/cures, residual ordering and +four active slots. These assertions are not a proof of arbitrary-mod or +complete battle-rule equivalence. diff --git a/tools/held-item-contract-check/check.py b/tools/held-item-contract-check/check.py index e2d8240a2..8bbb0a9f2 100644 --- a/tools/held-item-contract-check/check.py +++ b/tools/held-item-contract-check/check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compare public held-effect reads and real native consumers; report native scheduling separately.""" +"""Compare public held-effect reads, real native consumers and native scheduling.""" import argparse import hashlib import json @@ -53,7 +53,7 @@ def main(): "checks": run.stdout.splitlines()[-1]}) assert observations[0] == observations[1], f"held item mismatch: {version}" print(f"PASS {version}: {len(observations[0])} matching native held item observations") - if policies[0]["unrestricted_residual_heal"] != policies[1]["unrestricted_residual_heal"]: + if policies[0] != policies[1]: print(f"NATIVE SCHEDULE GAP {version}: reference={policies[0]}, host={policies[1]}") if a.require_native_schedule_parity: raise AssertionError(f"native held-item scheduling mismatch: {version}") From 2dc581b05c9081f918ddf3166b8c15f269b8bfaa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:54:13 +0200 Subject: [PATCH 49/86] Keep single-battle round ordering independent of slot IDs --- src/battle/BattleState.lua | 2 +- tests/held_item/scheduling.lua | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2dd72f4eb..447827c16 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5662,7 +5662,7 @@ function BattleState:resolveTurn(playerAction) -- switch (src/battle/Gen5MoveRules.lua) if self.bw then order = require("src.battle.Gen5MoveRules").arrange(self, order) end - if GameVersion.isGen3() then + if GameVersion.isGen3() and self:isDouble() then self._gbaResidualPriority = {} for _, entry in ipairs(order) do local move = entry[3] and not entry[3].special and self:moveDef(entry[3]) diff --git a/tests/held_item/scheduling.lua b/tests/held_item/scheduling.lua index a8e232900..13d3d6907 100644 --- a/tests/held_item/scheduling.lua +++ b/tests/held_item/scheduling.lua @@ -270,6 +270,12 @@ function M.run(f) T.eq(f.actor(2).mon.hp, 107, "ally receives normal item phase") T.eq(f.actor(3).mon.hp, 107, "second foe receives normal item phase") + if f.backend == "gen2" then + reset() + T.eq(f.battle.player.position, nil, "native single constructor has no slot ID") + f.battle:resolveTurn(f.battle.player.curMoves[1]) + T.check(#f.battle.queue > 0, "native single round start builds action queue without slot IDs") + end off() f.e.state.requiresItem = true return rows From c3030dc3052f7ceb3f36f039f616afc4bf59f613 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:56:52 +0200 Subject: [PATCH 50/86] Resolve single-battle Leech Seed healing against the current occupant --- src/battle/Status.lua | 6 +++++- tests/held_item/scheduling.lua | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/battle/Status.lua b/src/battle/Status.lua index b8c1aa792..98c828a88 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -318,7 +318,11 @@ function Status.residual(battler, opponent, battle) if gba and battler.leechSeeded then local source = battler.leechSeedSource or opponent -- Resolve the slot again: a switched-out seeder does not receive HP. - if source and source.position and battle.battlerAt then source = battle:battlerAt(source.position) end + if source and source.position ~= nil and battle.battlerAt then + source = battle:battlerAt(source.position) + elseif source and source.isPlayer ~= nil then + source = source.isPlayer and battle.player or battle.enemy + end if source and source.mon.hp > 0 then local dmg = math.min(mon.hp, math.max(1, math.floor(mon.stats.hp / 8))) mon.hp = mon.hp - dmg diff --git a/tests/held_item/scheduling.lua b/tests/held_item/scheduling.lua index 13d3d6907..c0b00f2c6 100644 --- a/tests/held_item/scheduling.lua +++ b/tests/held_item/scheduling.lua @@ -152,6 +152,22 @@ function M.run(f) f.residual() observe("item then seed then poison", { player = 125, enemy = 250 }) + reset("LEFTOVERS") + f.mon("player").hp, f.mon("enemy").hp = 200, 100 + local previous = f.battle.enemy + local replacement = require("src.mods.Merge").deepCopy(previous.mon) + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, previous + f.battle.enemy = require("src.core.game3.battle.state").makeBattler(replacement, "enemy", { id = 1 }) + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, previous + f.battle.enemy = require("src.battle.BattleState").makeBattler(f.data, replacement, false, nil, f.battle) + f.battle:syncSides() + end + f.residual() + T.eq(previous.mon.hp, 100, "withdrawn seed source does not receive healing") + observe("seed heals current occupant", { player = 175, enemy = 150 }) + reset() speed(100, 10) f.mon("player").hp, f.mon("enemy").hp = 100, 20 From 38a33ed20d0d50899886699b473f8db81cff81e8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:57:39 +0200 Subject: [PATCH 51/86] Preserve native single-battle Wish side order --- src/battle/GbaItemSchedule.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index ce6d584ce..6fce5d666 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -73,7 +73,7 @@ end function Schedule.residuals(battle) local order = Schedule.residualOrder(battle) -- Wish is a field phase, before weather; Ingrain belongs to each holder. - for _, b in ipairs(order) do + for _, b in ipairs(battle:isDouble() and order or Schedule.battlers(battle)) do battle:gen3WishTick(b) end require("src.battle.Weather").upkeep(battle, order) From 532d6ce343c6f01d4d789cb9d26bb3450d108337 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:31:20 +0200 Subject: [PATCH 52/86] Add portable Gen-2 held-item definitions and native contracts --- .github/workflows/held-content-contract.yml | 40 +++++ docs/api3-portability.md | 3 + docs/portable-held-content.md | 73 +++++++++ examples/portable_held_content/main.lua | 50 +++++++ examples/portable_held_content/manifest.json | 8 + src/battle/HeldItemRead.lua | 3 + src/battle/HeldItems.lua | 32 ++-- src/battle/HoldItems.lua | 3 + src/mods/Capabilities.lua | 8 +- src/mods/HeldItemContent.lua | 150 +++++++++++++++++++ src/mods/Loader.lua | 8 +- src/mods/Schemas.lua | 11 ++ src/mods/UnifiedContent.lua | 14 ++ src/mods/UnifiedSchemas.lua | 1 + tests/charge_required/fixture.lua | 3 +- tests/drivers/held_content.lua | 103 +++++++++++++ tests/engine/held_item_content.lua | 24 +++ tests/engine/held_item_content_authority.lua | 143 ++++++++++++++++++ tests/engine/mod_supports.lua | 6 +- tests/held_content/cases.lua | 86 +++++++++++ tests/held_content/fixture.lua | 102 +++++++++++++ tests/held_content/names.lua | 9 ++ tests/held_content/worker.lua | 6 + tools/held-content-contract-check/README.md | 24 +++ tools/held-content-contract-check/check.py | 52 +++++++ 25 files changed, 943 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/held-content-contract.yml create mode 100644 docs/portable-held-content.md create mode 100644 examples/portable_held_content/main.lua create mode 100644 examples/portable_held_content/manifest.json create mode 100644 src/mods/HeldItemContent.lua create mode 100644 tests/drivers/held_content.lua create mode 100644 tests/engine/held_item_content.lua create mode 100644 tests/engine/held_item_content_authority.lua create mode 100644 tests/held_content/cases.lua create mode 100644 tests/held_content/fixture.lua create mode 100644 tests/held_content/names.lua create mode 100644 tests/held_content/worker.lua create mode 100644 tools/held-content-contract-check/README.md create mode 100644 tools/held-content-contract-check/check.py diff --git a/.github/workflows/held-content-contract.yml b/.github/workflows/held-content-contract.yml new file mode 100644 index 000000000..04c318c30 --- /dev/null +++ b/.github/workflows/held-content-contract.yml @@ -0,0 +1,40 @@ +name: portable held-item definitions +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: held-content-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Validate native definitions and authority + run: | + luajit tests/engine/held_item_content.lua + luajit tests/engine/held_item_content_authority.lua + luajit tests/engine/mod_supports.lua + - name: Compare both SDKs in all four modes + run: | + mkdir -p /tmp/held-content + python3 tools/held-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 1 --format native --report /tmp/held-content/api1.json + python3 tools/held-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format native --report /tmp/held-content/api2-native.json + python3 tools/held-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format unified --report /tmp/held-content/api2-unified.json + python3 tools/held-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 3 --format unified --report /tmp/held-content/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: held-content-contract + path: /tmp/held-content + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index ed2698100..8de4a8a71 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -170,3 +170,6 @@ end-of-turn views, validated damage batches and native faint/EXP authority. [Portable held-item reads](portable-held-items.md) document shared `HELD_*` names, byte odds, native consumers and the tested GBA item scheduling phases. + +See [portable held-item definitions](portable-held-content.md) for the shared +Gen-2 `mod.content.held_items` registry, native write-back and generation bounds. diff --git a/docs/portable-held-content.md b/docs/portable-held-content.md new file mode 100644 index 000000000..cf398d9b1 --- /dev/null +++ b/docs/portable-held-content.md @@ -0,0 +1,73 @@ +# Portable Gen-2 held-item definitions + +`mod.content.held_items` declares held behavior independently of an item's +price, field use or other metadata. It implements Gen1Recomp's Gen-2 registry +in native API 1/2 and unified API 2/3. The reference exposes the unified +contract through API 3; its API 2 remains native. The registry is unavailable on GB, GBA +and DS, matching the reference's generation boundary. Use `mod:supports`: + +```lua +local mod = ... +if mod:supports("content", "held_items") then + mod.content.held_items:override("LEFTOVERS", { + heldEffect = "HELD_BERRY", + heldParameter = 7, + }) +end +``` + +The unchanged `examples/portable_held_content` consumer defaults to this +example. It supplies a declarative Berry effect; native code owns timing, +eligibility, healing, messages and consumption. It does not need a battle +hook or an engine-module import. Its optional `case.txt` exercises other +public operations in the shared tests; omit it for the normal example. + +Definitions use `heldEffect` (a string such as `HELD_LEFTOVERS`, `HELD_BERRY`, +`HELD_QUICK_CLAW`, `HELD_NONE` or a mod-defined name) and optional +`heldParameter` (an integer from 0 through 255, omitted means native zero). +Parameter meanings follow the effect; Gen-2 probabilities use byte odds. +Unknown effect names can be interpreted through `held_item.trigger`. +API 2/3 reject invalid known fields; API 1 retains its warning-based legacy +validation behavior. This does not make arbitrary metadata gameplay fields. + +`get`, `each`, `register`, `override`, `patch` and `remove` follow the normal +record registry rules. A vanilla row is already registered, so use `override` +or `patch`. Removing it clears the native held attributes, without removing +the item itself. `register` supplies a definition, not an item: add the item +through `mod.content.items` as well. A definition for an absent item does not +create native inventory content. New Gen-2 unified items can carry their +native byte `index`; occupied indices and new items using index zero are +refused. Changing indices in other generations remains outside +this adapter. + +The loader snapshots the native held attributes before mod entry, folds +registered definitions independently, and applies them after the normal item +merge. Ordinary item edits such as price patches therefore compose with held +definitions. Failed entries leave no held writes or exports. Content freezes +after load; unified reads are detached, and late writes cannot mutate the +native definitions. A later load retires only this bridge's previous held +writes before seeding its next base. No mod means no native attribute writes +or extra generated-data namespace. + +Explicit held definitions also take precedence over the host's Dragon Fang +cleanup and Polished's name-based modern berry routing. Unmodified cartridge +items keep their existing policies. The common reference matrix covers Gold, +Silver and Crystal; host-only tests also cover Prism/Polished and unavailable +GB/GBA/DS accessors. Foreign read-only dataset views do not acquire a live +held-definition writer from this change. + +## Validation + +```sh +luajit tests/engine/held_item_content.lua +luajit tests/engine/held_item_content_authority.lua +python3 tools/held-content-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified +``` + +The reference is pinned clean to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both real SDKs load the same +consumer; the reference's actual Game2 held-definition bridge is called after +the registry merge, followed by the actual native held readers and residual +handlers. This is not a claim of complete battle-rule, save or pixel parity. +No ROM, generated cache, save or private mod is included. diff --git a/examples/portable_held_content/main.lua b/examples/portable_held_content/main.lua new file mode 100644 index 000000000..4f2faf8f2 --- /dev/null +++ b/examples/portable_held_content/main.lua @@ -0,0 +1,50 @@ +local mod = ... +-- Older SDKs expose the generation and registry, without supports(). +local available = mod.supports and mod:supports("content", "held_items") + or (not mod.supports and mod.generation == 2 and mod.content.held_items ~= nil) +local state = { available = available, calls = 0 } +mod.exports.api, mod.exports.state = mod, state +if not state.available then + return +end +local held = mod.content.held_items +local mode = mod:read("case.txt") or "berry" +if mode == "berry" or mode == "rollback" or mode == "stack" then + held:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 7 }) +elseif mode == "patch" then + held:patch("LEFTOVERS", { heldParameter = 13 }) +elseif mode == "remove" then + held:remove("LEFTOVERS") +elseif mode == "register" then + mod.content.items:register( + "PORTABLE_ITEM", + { id = "PORTABLE_ITEM", index = 240, name = "PORTABLE ITEM", price = 100 } + ) + held:register("PORTABLE_ITEM", { heldEffect = "HELD_BERRY", heldParameter = 11 }) +elseif mode == "omitted" then + held:override("LEFTOVERS", { heldEffect = "HELD_NONE" }) +elseif mode == "custom" then + held:override("LEFTOVERS", { heldEffect = "HELD_PORTABLE", heldParameter = 9 }) + mod.hooks:wrap("held_item.trigger", function(next, ctx) + state.calls = state.calls + 1 + if ctx.effect == "HELD_PORTABLE" then + return "HELD_BERRY", ctx.parameter + end + return next(ctx) + end) +elseif mode == "price" then + mod.content.items:patch("LEFTOVERS", { price = 999 }) + held:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 17 }) +elseif mode == "modern" then + held:override("BERRY", { heldEffect = "HELD_BERRY", heldParameter = 7 }) +elseif mode == "dragon" then + held:override("DRAGON_FANG", { heldEffect = "HELD_NONE", heldParameter = 0 }) +elseif mode == "delete_parameter" then + held:patch("LEFTOVERS", { heldParameter = mod.DELETE }) +elseif mode == "invalid" then + held:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 256 }) +end +state.initial = held:get("LEFTOVERS") +if mode == "rollback" then + error("intentional held-content entry rollback") +end diff --git a/examples/portable_held_content/manifest.json b/examples/portable_held_content/manifest.json new file mode 100644 index 000000000..67667b03c --- /dev/null +++ b/examples/portable_held_content/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_held_content", + "name": "Portable Held Item Definitions", + "version": "1.0.0", + "api": 3, + "games": ["gen2"], + "entry": "main.lua" +} diff --git a/src/battle/HeldItemRead.lua b/src/battle/HeldItemRead.lua index 4d4bf74fc..aa4df994d 100644 --- a/src/battle/HeldItemRead.lua +++ b/src/battle/HeldItemRead.lua @@ -61,6 +61,9 @@ end function M.goldNative(name) return goldIds[name] or name end +function M.goldPublic(effect) + return gold[effect] or effect +end function M.writeDefinition(view, key, value) view[key] = value local def = definitions[view] diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 02f7a16ac..eeb89e762 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -107,21 +107,25 @@ end local function nativeEffect(data, holder) local def, id = itemDef(data, holder) if type(def) ~= "table" then return HeldItems.EFFECT.NONE, 0 end - -- Modern Polished berries are handled by HoldItems; their enum values - -- otherwise collide with GSC Leftovers/PP/status effects here. - if require('src.inventory.PolishedBerries').held(def,monOf(holder)) then - return HeldItems.EFFECT.NONE,0 - end + -- An explicit public held definition takes precedence over the host's + -- cartridge-name special cases. Unmodified items retain those policies. + if not require("src.mods.HeldItemContent").changed(def) then + -- Modern Polished berries are handled by HoldItems; their enum values + -- otherwise collide with GSC Leftovers/PP/status effects here. + if require('src.inventory.PolishedBerries').held(def,monOf(holder)) then + return HeldItems.EFFECT.NONE,0 + end - -- Intentional Gen II cleanup approved for the recomp ruleset: retail - -- Crystal accidentally assigns HELD_DRAGON_BOOST to Dragon Scale and leaves - -- Dragon Fang with HELD_NONE. Treat Dragon Fang as the Dragon-type booster - -- and Dragon Scale as a normal held item instead of reproducing that bug. - local key = norm(ItemEffects.alias(id, def) or def.key or def.name or id) - if key == "DRAGON_FANG" then - return HeldItems.EFFECT.DRAGON_BOOST, 10 - elseif key == "DRAGON_SCALE" then - return HeldItems.EFFECT.NONE, 0 + -- Intentional Gen II cleanup approved for the recomp ruleset: retail + -- Crystal accidentally assigns HELD_DRAGON_BOOST to Dragon Scale and leaves + -- Dragon Fang with HELD_NONE. Treat Dragon Fang as the Dragon-type booster + -- and Dragon Scale as a normal held item instead of reproducing that bug. + local key = norm(ItemEffects.alias(id, def) or def.key or def.name or id) + if key == "DRAGON_FANG" then + return HeldItems.EFFECT.DRAGON_BOOST, 10 + elseif key == "DRAGON_SCALE" then + return HeldItems.EFFECT.NONE, 0 + end end return tonumber(def.heldEffect) or require("src.battle.HeldItemRead").goldNative(def.heldEffect) or HeldItems.EFFECT.NONE, diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index 160f52f9a..d029430d2 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -51,6 +51,9 @@ end local function nativeEffect(battler) local def = HoldItems.defOf(battler) if not def then return nil end + -- A Gen-2 held_items definition owns this item's GSC behavior; do not also + -- dispatch Polished's name-based modern berry in the second item engine. + if require("src.mods.HeldItemContent").changed(def) then return nil end local effect,param=require('src.inventory.PolishedBerries').held(def,battler.mon) if effect then return effect,param,def end local effect = def.holdEffect diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 297087daa..793a3bad1 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -92,7 +92,13 @@ function Capabilities.supports(surface, name, version) 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 + local spec = Schemas.REGISTRIES[name] + if spec then + if spec.generations and not spec.generations:find(tostring(info.generation), 1, true) then + return false, "content capability " .. name .. " has no native target in " .. version + end + return true + end elseif surface == "world" then if world[name] then return true end else diff --git a/src/mods/HeldItemContent.lua b/src/mods/HeldItemContent.lua new file mode 100644 index 000000000..7fb898d2d --- /dev/null +++ b/src/mods/HeldItemContent.lua @@ -0,0 +1,150 @@ +-- Gen-2 held definitions are a projection of two native item attributes. +-- Keep their fold separate from items, then apply it after all item writes. +local Merge = require("src.mods.Merge") +local Names = require("src.battle.HeldItemRead") +local M = {} +local previous = setmetatable({}, { __mode = "k" }) +local explicit = setmetatable({}, { __mode = "k" }) + +local function finite(value, seen) + if type(value) == "number" then + assert(value == value and math.abs(value) < math.huge, "invalid content: non-finite number") + elseif type(value) == "table" and value ~= Merge.DELETE then + seen = seen or {} + assert(not seen[value], "invalid content: cyclic content") + seen[value] = true + for key, part in pairs(value) do + finite(key, seen) + finite(part, seen) + end + seen[value] = nil + end +end + +local function identifier(id) + assert(type(id) == "string" and id ~= "", "invalid content: registry id is required") +end + +function M.record(def) + if type(def) ~= "table" or def.heldEffect == nil then + return nil + end + return { heldEffect = Names.goldPublic(def.heldEffect), heldParameter = def.heldParam or 0 } +end + +function M.changed(def) + return explicit[def] == true +end + +function M.prepare(loader, data) + local undo = previous[data] or {} + for i = #undo, 1, -1 do + local entry = undo[i] + local def = entry.def + if def.heldEffect == entry.effect and def.heldParam == entry.parameter then + def.heldEffect, def.heldParam = entry.beforeEffect, entry.beforeParameter + end + explicit[def] = nil + end + if data then + previous[data] = nil + end + local base = {} + if loader:_generation() == 2 then + for id, def in pairs(data and data.items or {}) do + base[id] = M.record(def) + end + end + loader._heldItemBase = base + loader.content.held_items.base = function() + return base + end +end + +function M.apply(loader, data) + if not data or loader:_generation() ~= 2 then + return + end + local registry, undo = loader.content.held_items, {} + for _, id in ipairs(registry.order) do + local def = data.items and data.items[id] + if registry.ops[id] and type(def) == "table" then + local row = registry:get(id) + local effect = row and Names.goldNative(row.heldEffect) or nil + local parameter = row and (row.heldParameter or 0) or nil + undo[#undo + 1] = { + def = def, + beforeEffect = def.heldEffect, + beforeParameter = def.heldParam, + effect = effect, + parameter = parameter, + } + def.heldEffect, def.heldParam = effect, parameter + explicit[def] = true + end + end + previous[data] = undo +end + +-- Native unsupported writes follow the reference's reported-drop policy. +-- Unified APIs refuse them and return detached definition records. +function M.api(loader, mod, native, generation, unified) + local available = generation == 2 and loader._heldItemBase ~= nil + local reason = not available and "unsupported: held_items has no native target in this dataset" or nil + local warned = false + local out = { + available = available, + supports = function() + return available, reason + end, + } + out.get = function(_, id) + if not available then + return nil, reason + end + if unified then + identifier(id) + end + local value = native:get(id) + return unified and Merge.deepCopy(value) or value + end + out.has = function(_, id) + return out:get(id) ~= nil + end + out.each = function() + if not available then + return function() end + end + local nextRow = native:each() + return function() + local id, value = nextRow() + if id ~= nil then + return id, unified and Merge.deepCopy(value) or value + end + end + end + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + out[verb] = function(_, id, value) + if not available then + if unified then + return nil, reason + end + if not warned then + require("src.core.Logger").warn("[%s] %s", mod.manifest.id, reason) + warned = true + end + return nil + end + if unified then + identifier(id) + finite(value) + value = Merge.deepCopy(value) + end + local result = native[verb](native, id, value) + return unified and out:get(id) or result + end + end + return out +end + +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index d14e195b5..b6415635e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -726,7 +726,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -759,6 +759,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "held_items" then + return require("src.mods.HeldItemContent").api(loader, mod, api, loader:_generation(), false) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1410,6 +1414,7 @@ function Loader:load(data) end end end + require("src.mods.HeldItemContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1637,6 +1642,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.HeldItemContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index d9e994c1a..28bf2b46d 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -485,6 +485,17 @@ R.items = { example = 'mod.content.items:patch("POTION", { price = 100 })', } +-- Virtual native projection. HeldItemContent seeds its base before mod entry +-- and writes only these attributes back after the normal items merge. +R.held_items = { + semantics = "record", generations = "2", + fields = { + heldEffect = f.str, + heldParameter = f.opt(f.int(0, 255)), + }, + example = 'mod.content.held_items:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 7 })', +} + R.maps = { semantics = "record", target = "maps", fields = { diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index dde1870a8..36de491a7 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -282,6 +282,17 @@ local function writeRecord(ctx, kind, id, value, old, mode) if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end end elseif kind == "items" then + if value.index ~= nil and ctx.gen ~= 2 then + unsupported("explicit item indices require a native generation-specific adapter") + end + if value.index ~= nil and value.index ~= DELETE then + if value.index == 0 and not old then unsupported("item index zero is reserved") end + for key, row in ctx.loader.content.items:each() do + if row.index == value.index and ctx.ids:read("items", key) ~= id then + unsupported("item index is already assigned: " .. tostring(value.index)) + end + end + end if value.machine and value.machine ~= DELETE then out.machine.move = resolved(ctx, "moves", value.machine.move) end @@ -391,6 +402,9 @@ local function protect(fn) end function U.new(loader, mod, native, gen) local out = {}; for name, handle in pairs(native) do out[name] = handle end + if native.held_items then + out.held_items = require("src.mods.HeldItemContent").api(loader, mod, native.held_items, gen, true) + end for kind, shape in pairs(Shapes) do local registry = loader.content[kind] if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua index 10d2f6bb2..54d4894b3 100644 --- a/src/mods/UnifiedSchemas.lua +++ b/src/mods/UnifiedSchemas.lua @@ -40,6 +40,7 @@ local U = { } }, items = { fields = { id = id, name = f.str, price = f.int(0), + index = f.opt(f.int(0, 255)), 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), diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua index d10027b54..88b0687e5 100644 --- a/tests/charge_required/fixture.lua +++ b/tests/charge_required/fixture.lua @@ -1,5 +1,5 @@ local M = {} -function M.new(root, backend, version, api, format, noMod, failing, consumer, prepare) +function M.new(root, backend, version, api, format, noMod, failing, consumer, prepare, prepareFiles) consumer = consumer or "portable_charge_required" local T = require("tests.modkit") local V = require("src.core.GameVersion") @@ -115,6 +115,7 @@ function M.new(root, backend, version, api, format, noMod, failing, consumer, pr files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] .. '\nerror("intentional charge entry failure")' end + if prepareFiles then prepareFiles(files, data, gen) end if noMod then files = {} end diff --git a/tests/drivers/held_content.lua b/tests/drivers/held_content.lua new file mode 100644 index 000000000..1fcbe6376 --- /dev/null +++ b/tests/drivers/held_content.lua @@ -0,0 +1,103 @@ +-- Imported-data native input/held-effect/result-queue smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_held_content, "install held-definition example") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle, leftovers + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + break + end + end + for id, def in pairs(game.data.items) do + if def.name == "LEFTOVERS" then + leftovers = id + break + end + end + assert(species and tackle and leftovers, "imported native metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh(hp) + local p = P.new(game.data, species, 30) + p.hp, p.stats.hp, p.nickname, p.item = hp or 300, 500, "PLAYER", leftovers + p.moves = { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp, b.enemy.mon.nickname = 500, 500, "ENEMY" + b.enemy.mon.item, b.enemy.mon.heldItem = leftovers, nil + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + return b + end + local function drain(b) + for _ = 1, 4000 do + if b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native held-item result queue did not settle") + end + assert(V.generation() == 2, "Gen-2 held definitions only") + assert(e.state.available, "public held definitions unavailable") + local held = e.api.content.held_items + local record = assert(held:get("LEFTOVERS")) + assert(record.heldEffect == "HELD_BERRY" and record.heldParameter == 7, "public definition missing") + local b = fresh(100) + local lost = 0 + local off = e.api.events:on("battle.damage_dealt", function(ctx) + if ctx.target.mon.nickname == "PLAYER" then + lost = lost + ctx.damage + end + end) + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT missing") + U.tap(game, "a") + drain(b) + off() + assert(b.turnCount == 1 and lost > 0, "actual native turn and opponent damage missing") + assert(b.player.mon.hp == 100 - lost + 7, "native registered definition must heal exactly seven") + assert(not b.player.mon.item, "native registered Berry must consume actual held item") + record.heldParameter = 222 + assert(held:get("LEFTOVERS").heldParameter == 7, "unified definition read must be detached") + local ok = pcall(function() + held:patch("LEFTOVERS", { heldParameter = 9 }) + end) + assert(not ok, "native content must remain frozen") + print("PORTABILITY_HELD_CONTENT_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/held_item_content.lua b/tests/engine/held_item_content.lua new file mode 100644 index 000000000..216d8ebdc --- /dev/null +++ b/tests/engine/held_item_content.lua @@ -0,0 +1,24 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local Fixture, Cases = dofile("tests/held_content/fixture.lua"), dofile("tests/held_content/cases.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + if V.generation(version) == 2 then + Cases.run(".", "gen2", version, mode[1], mode[2]) + else + local f = Fixture.new(".", "gen2", version, mode[1], mode[2], "berry") + T.eq(f.e.api:supports("content", "held_items"), false, "honest generation-specific held registry") + local held = f.e.api.content.held_items + T.eq(held.available, false, "unsupported held accessor is unavailable") + T.eq(held:get("LEFTOVERS"), nil, "unsupported held accessor returns no records") + T.eq(held:each()(), nil, "unsupported held accessor is empty") + held:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 7 }) + T.eq(f.data.items.LEFTOVERS.heldEffect, 3, "unsupported definition never changes native item data") + T.eq(next(f.result.loader.content.held_items.ops), nil, "unsupported writes add no operations") + f.close() + end + end +end +V.set(old) +T.finish("held_items public definitions and native generation boundaries") diff --git a/tests/engine/held_item_content_authority.lua b/tests/engine/held_item_content_authority.lua new file mode 100644 index 000000000..ed4c234c6 --- /dev/null +++ b/tests/engine/held_item_content_authority.lua @@ -0,0 +1,143 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local Copy, Json = require("src.mods.Merge").deepCopy, require("src.link.Json") +local old = V.get() +V.set("gold") +local function data() + local d = T.fixtures.fresh() + d.items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", price = 100, heldEffect = 3, heldParam = 0 } + d.items.BERRY = { id = "BERRY", name = "BERRY", price = 50, heldEffect = 1, heldParam = 10 } + return d +end +-- A native Data:load recreates engine registration inputs before a new +-- loader. Retain only item objects here to stress held-write retirement. +local function reloadInputs(d) + local fresh = T.fixtures.fresh() + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "items" then + d[root] = fresh[root] + end + end +end +local function loadNone(d) + reloadInputs(d) + return T.sdk.loadNone({ data = d }) +end +local function load(d, source, api, format) + reloadInputs(d) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "probe", + version = "1", + api = api, + games = { "gen2" }, + content_format = format, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "local mod=...; mod.exports.api=mod; " .. source, + } + return T.sdk.loadMods({ "mods/probe" }, { data = d, fs = T.sdk.memfs(files), dev = false }) +end +for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local api, format = unpack(mode) + local d, before = data() + before = Copy(d.items) + local none = loadNone(d) + T.same(d.items, before, "no-mod load preserves every native item attribute") + T.eq(rawget(d, "heldItems"), nil, "virtual registry adds no generated-data namespace") + none.release() + local r = load( + d, + 'mod.content.held_items:override("LEFTOVERS", {heldEffect="HELD_BERRY", heldParameter=7})', + api, + format + ) + T.eq(#r.errors, 0, "held writer loads") + T.eq(d.items.LEFTOVERS.heldEffect, 1, "only public held name converts to native effect") + T.eq(d.items.LEFTOVERS.heldParam, 7, "native parameter written") + T.eq(d.items.LEFTOVERS.price, 100, "other attributes preserved") + local h = r.loader.exports.probe.api.content.held_items + T.raises(function() + h:patch("LEFTOVERS", { heldParameter = 8 }) + end, "frozen", "late writes cannot alter frozen content") + if format == "unified" then + local record = h:get("LEFTOVERS") + record.heldParameter = 111 + for _, row in h:each() do + row.heldEffect = "HELD_NONE" + end + T.eq(h:get("LEFTOVERS").heldParameter, 7, "get is detached") + T.eq(d.items.LEFTOVERS.heldEffect, 1, "enumeration is detached from native effects") + T.raises(function() + h:get(nil) + end, "registry id", "unified read validates its ID") + local invalid = { heldEffect = "HELD_BERRY" } + invalid.loop = invalid + T.raises(function() + h:override("LEFTOVERS", invalid) + end, "cyclic", "cyclic definitions refused before write") + end + if format == "unified" then + local items = r.loader.exports.probe.api.content.items + -- Index checks happen before the freeze guard; neither failure may write. + local _, reason = items:register("CONFLICT", { id = "CONFLICT", name = "CONFLICT", price = 1, index = 0 }) + T.check(type(reason) == "string" and reason:find("reserved", 1, true), "new item cannot claim NONE index") + end + r.release() + local clean = loadNone(d) + T.same(d.items, before, "reloading same dataset without mod restores only held writes") + clean.release() + local removed = load(d, 'mod.content.held_items:remove("LEFTOVERS")', api, format) + T.eq(d.items.LEFTOVERS.heldEffect, nil, "remove clears native effect") + T.eq(d.items.LEFTOVERS.heldParam, nil, "remove clears native parameter") + removed.release() + clean = loadNone(d) + T.same(d.items, before, "tombstone reload restores native definition") + clean.release() + if format == "unified" then + d.items.BERRY.index = 240 + local rejected = load( + d, + 'local ok, reason=mod.content.items:register("CONFLICT", {id="CONFLICT", name="CONFLICT", price=1, index=240}); mod.exports.refusal=reason; assert(not ok)', + api, + format + ) + T.eq(#rejected.errors, 0, "unsupported index is reported without corrupting the entry") + T.check( + rejected.loader.exports.probe.refusal:find("already assigned", 1, true), + "occupied native index refused" + ) + T.eq(d.items.CONFLICT, nil, "collision cannot create native item") + rejected.release() + d.items.BERRY.index = nil + end + if api >= 2 then + for _, bad in ipairs({ + "heldParameter=-1", + "heldParameter=256", + "heldParameter=0.5", + 'heldParameter="7"', + "heldParameter=0/0", + "heldParameter=math.huge", + "heldEffect=4", + "heldEffect={}", + }) do + local failed = load( + d, + 'mod.content.held_items:override("LEFTOVERS", {heldEffect="HELD_BERRY", heldParameter=7}); ' + .. 'mod.content.held_items:patch("BERRY", {' + .. bad + .. "})", + api, + format + ) + T.check(#failed.errors > 0, "invalid definition rejected: " .. bad) + T.same(d.items, before, "entry validation rolls back all of its held writes") + T.eq(failed.loader.exports.probe, nil, "failed entry exports retired") + failed.release() + end + end +end +V.set(old) +T.finish("held_items authority, reload, freeze and transactional rollback") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 98972a584..f899902e6 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -70,9 +70,11 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': shared simulation/world call sites available') local registries = true for name in pairs(Schemas.REGISTRIES) do - registries = registries and yes(api, 'content', name) + local expected = name ~= 'held_items' or GameVersion.generation(version) == 2 + registries = registries and (expected and yes(api, 'content', name) + or not expected and no(api, 'content', name)) end - T.check(registries, version .. ': all API-2 registration surfaces available') + T.check(registries, version .. ': registration availability follows native generation') T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', version .. ': additive native flight interface is reported when installed') T.check(yes(api, 'hooks', 'core.logic_speed'), diff --git a/tests/held_content/cases.lua b/tests/held_content/cases.lua new file mode 100644 index 000000000..b19b0b8dc --- /dev/null +++ b/tests/held_content/cases.lua @@ -0,0 +1,86 @@ +local C = {} +function C.run(root, backend, version, api, format) + local Fixture = dofile(root .. "/tests/held_content/fixture.lua") + local rows = {} + if require("src.core.GameVersion").generation(version) ~= 2 then + local f = Fixture.new(root, backend, version, tonumber(api), format, "berry") + local h = f.e.api.content.held_items + f.T.eq(f.e.state.available, false, "reference has no held registry for this generation") + f.T.eq(h:get("LEFTOVERS"), nil, "unavailable registry has no definitions") + h:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 7 }) + f.T.eq(f.data.items.LEFTOVERS.heldEffect, 3, "unavailable writes leave attributes intact") + f.close() + return { { mode = "unavailable", available = false } } + end + for _, mode in ipairs({ + "none", + "berry", + "patch", + "remove", + "register", + "omitted", + "custom", + "price", + "stack", + "modern", + "dragon", + "delete_parameter", + "rollback", + "invalid", + "no-mod", + }) do + local f = Fixture.new(root, backend, version, tonumber(api), format, mode) + local failed = mode == "rollback" or (mode == "invalid" and tonumber(api) >= 2) + f.T.eq(#f.result.errors > 0, failed, "held definition entry outcome " .. mode) + local itemId = mode == "register" and "PORTABLE_ITEM" + or mode == "modern" and "BERRY" + or mode == "dragon" and "DRAGON_FANG" + or "LEFTOVERS" + local hp, item = f.run(itemId) + local expected = ({ + berry = 107, + register = 111, + custom = 109, + price = 117, + remove = 100, + omitted = 100, + dragon = 100, + modern = 107, + stack = 109, + invalid = tonumber(api) == 1 and 356 or 125, + })[mode] or 125 + f.T.eq(hp, expected, "actual held definition residual " .. mode) + local row = { mode = mode, hp = hp, held = item ~= nil, failed = failed } + if f.e then + f.T.eq(f.e.state.available, true, "native held registry available") + local record = f.e.api.content.held_items:get(itemId) + row.record = record + if tonumber(api) >= 3 and record then + record.heldParameter = 222 + f.T.check(f.e.api.content.held_items:get(itemId).heldParameter ~= 222, "API-3 read is detached") + row.record = f.e.api.content.held_items:get(itemId) + end + if mode == "register" then + f.T.eq(f.data.items.PORTABLE_ITEM.index, 240, "common item index retained") + end + if mode == "dragon" then + local effect, parameter = f.read("DRAGON_FANG") + f.T.eq(effect, "HELD_NONE", "explicit public definition overrides host name policy") + row.nativeEffect, row.parameter = effect, parameter + end + local count = 0 + for id, value in f.e.api.content.held_items:each() do + f.T.check(type(id) == "string" and type(value.heldEffect) == "string", "public held enumeration") + count = count + 1 + end + row.count = count + if mode == "price" then + f.T.eq(f.data.items.LEFTOVERS.price, 999, "item price composes with held fields") + end + end + rows[#rows + 1] = row + f.close() + end + return rows +end +return C diff --git a/tests/held_content/fixture.lua b/tests/held_content/fixture.lua new file mode 100644 index 000000000..d3ebee657 --- /dev/null +++ b/tests/held_content/fixture.lua @@ -0,0 +1,102 @@ +local F = {} +function F.new(root, backend, version, api, format, mode) + local snapshot + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + mode == "no-mod", + mode == "rollback" or (mode == "invalid" and api >= 2), + "portable_held_content", + function(data, gen) + data.items = data.items or {} + if backend == "gen2" then + data.constants.generation = gen + end + for id, effect in pairs({ + LEFTOVERS = 3, + BERRY = 1, + QUICK_CLAW = 74, + BRIGHTPOWDER = 77, + FOCUS_BAND = 79, + DRAGON_FANG = 0, + DRAGON_SCALE = 64, + }) do + local native = backend == "gen1" and gen == 2 + data.items[id] = { + id = id, + name = id:gsub("_", " "), + price = 100, + heldEffect = native and dofile(root .. "/tests/held_content/names.lua")[effect] or effect, + } + if id == "BERRY" then + data.items[id].key = "ORAN_BERRY" + end + data.items[id][native and "heldParameter" or "heldParam"] = id == "BERRY" and 10 or 0 + end + if backend == "gen1" and gen == 2 then + local Items = require("src.core.gen2.ItemEffects") + data.gen2HeldItems = Items.heldItemsFrom(data.items) + snapshot = Items.heldSnapshot(data.gen2HeldItems) + end + end, + function(files) + local Json = require("src.link.Json") + local path = "mods/portable_held_content/" + local manifest = Json.decode(files[path .. "manifest.json"]) + manifest.games = { "gen1", "gen2", "gen3" } + if require("src.core.GameVersion").generation() >= 4 then + manifest.games = { version } + end + manifest.content_format = format + files[path .. "manifest.json"] = Json.encode(manifest) + files[path .. "case.txt"] = mode + if mode == "stack" then + files["mods/held_overlay/manifest.json"] = Json.encode({ + id = "held_overlay", + name = "Held overlay", + version = "1.0.0", + api = api, + games = { "gen2" }, + gen2compat = api < 3 and true or nil, + content_format = format, + dependencies = { "portable_held_content" }, + entry = "main.lua", + }) + files["mods/held_overlay/main.lua"] = + 'local mod=...; mod.content.held_items:patch("LEFTOVERS", {heldParameter=9})' + end + end + ) + if backend == "gen1" and f.gen == 2 then + -- The production Game2 load bridge runs after the real registry merge. + require("src.core.gen2.ItemEffects").applyHeldItems(f.data, snapshot) + end + function f.run(item) + local step = f.fresh("TACKLE") + f.battle = step.battle + local p = backend == "gen1" and f.gen == 2 and f.battle.player or f.battle.player.mon + p.hp, p.stats.hp, p.maxHp, p.item = 100, 400, 400, item or "LEFTOVERS" + f.battle.queue, f.battle.nextInsert = {}, 0 + if backend == "gen1" and f.gen == 2 then + f.battle:betweenTurnEffects() + else + f.battle:endOfTurn() + end + return p.hp, p.item + end + function f.read(item) + local step = f.fresh("TACKLE") + if backend == "gen1" then + step.battle.player.item = item + return step.battle:heldEffect(step.battle.player) + end + step.battle.player.mon.item = item + local effect, amount = require("src.battle.HeldItems").effect(f.data, step.battle.player) + return require("src.battle.HeldItemRead").goldPublic(effect), amount + end + return f +end +return F diff --git a/tests/held_content/names.lua b/tests/held_content/names.lua new file mode 100644 index 000000000..0ad38a18c --- /dev/null +++ b/tests/held_content/names.lua @@ -0,0 +1,9 @@ +return { + [0] = "HELD_NONE", + [1] = "HELD_BERRY", + [3] = "HELD_LEFTOVERS", + [64] = "HELD_DRAGON_BOOST", + [74] = "HELD_QUICK_CLAW", + [77] = "HELD_BRIGHTPOWDER", + [79] = "HELD_FOCUS_BAND", +} diff --git a/tests/held_content/worker.lua b/tests/held_content/worker.lua new file mode 100644 index 000000000..23e699e7e --- /dev/null +++ b/tests/held_content/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/held_content/cases.lua").run(root, backend, version, tonumber(api), format) +print("HELD_CONTENT_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native held definition registrations") diff --git a/tools/held-content-contract-check/README.md b/tools/held-content-contract-check/README.md new file mode 100644 index 000000000..ef30c9680 --- /dev/null +++ b/tools/held-content-contract-check/README.md @@ -0,0 +1,24 @@ +# Native Gen-2 held-definition contracts + +Run from the host checkout with Python 3, LuaJIT and a clean pinned Gen1: + +```sh +python3 tools/held-content-contract-check/check.py \ + --gen1 /path/to/gen1 --api 3 --format unified --report /tmp/held-content.json +``` + +The source must be at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Test API 1/native, API 2/native, API 2/unified and API 3/unified. Both SDKs load +an unchanged consumer. The reference API 2 has no unified opt-in; its native +records are compared with both host API-2 formats. API 3 is the common unified +contract. Gold/Silver/Crystal execute native held definitions +through the production merge/write-back and native residual paths. Red, +FireRed and Emerald verify unavailable accessors without manufacturing a +consumer for generations the reference does not support. + +Cases include overrides, parameter patches/deletion, tombstones, registered +items with explicit indices, custom effect names, independent item-price +patches, dependency-ordered contributions, detached enumeration, invalid +entries, rollback and a genuine no-mod load. Reports preserve actual native +outcomes, SDK mode, engine refs and dirty status. No ROM or private mod is +needed. The source is read-only; these are not complete playthrough tests. diff --git a/tools/held-content-contract-check/check.py b/tools/held-content-contract-check/check.py new file mode 100644 index 000000000..ba61ae2a7 --- /dev/null +++ b/tools/held-content-contract-check/check.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare unchanged Gen-2 held definition consumers and actual native effects.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2 / "tests/held_content/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("HELD_CONTENT_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("HELD_CONTENT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, "observations": rows}) + assert observations[0] == observations[1], f"held definition mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native held definition observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() From 159896e6e5d19e5cd98d6c57d811789cd329a332 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:41:03 +0200 Subject: [PATCH 53/86] Route portable held definitions to native imported item IDs --- docs/portable-held-content.md | 4 ++ src/mods/HeldItemContent.lua | 90 ++++++++++++++++++++++++++++------ src/mods/Loader.lua | 3 ++ tests/held_content/cases.lua | 2 +- tests/held_content/fixture.lua | 31 +++++++++--- 5 files changed, 107 insertions(+), 23 deletions(-) diff --git a/docs/portable-held-content.md b/docs/portable-held-content.md index cf398d9b1..d650a1f38 100644 --- a/docs/portable-held-content.md +++ b/docs/portable-held-content.md @@ -26,6 +26,10 @@ Definitions use `heldEffect` (a string such as `HELD_LEFTOVERS`, `HELD_BERRY`, `HELD_QUICK_CLAW`, `HELD_NONE` or a mod-defined name) and optional `heldParameter` (an integer from 0 through 255, omitted means native zero). Parameter meanings follow the effect; Gen-2 probabilities use byte odds. +Common item IDs are resolved to native import keys (for example `LEFTOVERS` +to `ITEM_146`). The new held registry accepts either spelling. Native Gen-2 +`items` operations also accept common aliases while retaining native record +shapes; unrelated generations keep their existing native item accessors. Unknown effect names can be interpreted through `held_item.trigger`. API 2/3 reject invalid known fields; API 1 retains its warning-based legacy validation behavior. This does not make arbitrary metadata gameplay fields. diff --git a/src/mods/HeldItemContent.lua b/src/mods/HeldItemContent.lua index 7fb898d2d..f46c55871 100644 --- a/src/mods/HeldItemContent.lua +++ b/src/mods/HeldItemContent.lua @@ -2,6 +2,7 @@ -- Keep their fold separate from items, then apply it after all item writes. local Merge = require("src.mods.Merge") local Names = require("src.battle.HeldItemRead") +local Ids = require("src.mods.UnifiedIds") local M = {} local previous = setmetatable({}, { __mode = "k" }) local explicit = setmetatable({}, { __mode = "k" }) @@ -36,6 +37,47 @@ function M.changed(def) return explicit[def] == true end +local function resolve(loader, id) + if type(id) ~= "string" then + return nil + end + local ids = Ids.new(loader) + local public = ids:entries("items") + local canonical = public[id] ~= nil and id or ids:read("items", id) + local group = public[canonical] + assert(group ~= false, "ambiguous item ID: " .. tostring(id)) + return group +end + +-- A Gen-2 native item key may be ITEM_146 while a portable mod says +-- LEFTOVERS. Keep raw record shapes, but accept either key for operations. +function M.itemApi(loader, native, generation) + if generation ~= 2 then + return native + end + local out = {} + for key, value in pairs(native) do + out[key] = value + end + out.get = function(_, id) + local group = resolve(loader, id) + return native:get(group and group.key or id) + end + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + out[verb] = function(_, id, value) + local group, result = resolve(loader, id) + if not group then + return native[verb](native, id, value) + end + for _, key in ipairs(group.keys) do + result = native[verb](native, key, value) + end + return result + end + end + return out +end + function M.prepare(loader, data) local undo = previous[data] or {} for i = #undo, 1, -1 do @@ -51,8 +93,10 @@ function M.prepare(loader, data) end local base = {} if loader:_generation() == 2 then - for id, def in pairs(data and data.items or {}) do - base[id] = M.record(def) + for id, group in pairs(Ids.new(loader):entries("items")) do + if group then + base[id] = M.record(loader.content.items:get(group.key)) + end end end loader._heldItemBase = base @@ -67,20 +111,30 @@ function M.apply(loader, data) end local registry, undo = loader.content.held_items, {} for _, id in ipairs(registry.order) do - local def = data.items and data.items[id] - if registry.ops[id] and type(def) == "table" then - local row = registry:get(id) - local effect = row and Names.goldNative(row.heldEffect) or nil - local parameter = row and (row.heldParameter or 0) or nil - undo[#undo + 1] = { - def = def, - beforeEffect = def.heldEffect, - beforeParameter = def.heldParam, - effect = effect, - parameter = parameter, - } - def.heldEffect, def.heldParam = effect, parameter - explicit[def] = true + local group = resolve(loader, id) + for _, key in ipairs(group and group.keys or { id }) do + local def = data.items and data.items[key] + if registry.ops[id] and type(def) == "table" then + local row = registry:get(id) + local was = loader._heldItemBase[id] + local changed = row + and (not was or row.heldEffect ~= was.heldEffect or row.heldParameter ~= was.heldParameter) + or not row and was ~= nil + local effect, parameter = def.heldEffect, def.heldParam + if changed then + effect = row and Names.goldNative(row.heldEffect) or nil + parameter = row and (row.heldParameter or 0) or nil + end + undo[#undo + 1] = { + def = def, + beforeEffect = def.heldEffect, + beforeParameter = def.heldParam, + effect = effect, + parameter = parameter, + } + def.heldEffect, def.heldParam = effect, parameter + explicit[def] = true + end end end previous[data] = undo @@ -105,6 +159,8 @@ function M.api(loader, mod, native, generation, unified) if unified then identifier(id) end + local group = resolve(loader, id) + id = group and group.id or id local value = native:get(id) return unified and Merge.deepCopy(value) or value end @@ -140,6 +196,8 @@ function M.api(loader, mod, native, generation, unified) finite(value) value = Merge.deepCopy(value) end + local group = resolve(loader, id) + id = group and group.id or id local result = native[verb](native, id, value) return unified and out:get(id) or result end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index b6415635e..fc860df91 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -762,6 +762,9 @@ function Loader:_contentApi(mod, registry, deprecation) if registry.name == "held_items" then return require("src.mods.HeldItemContent").api(loader, mod, api, loader:_generation(), false) end + if registry.name == "items" then + return require("src.mods.HeldItemContent").itemApi(loader, api, loader:_generation()) + end return api end diff --git a/tests/held_content/cases.lua b/tests/held_content/cases.lua index b19b0b8dc..30dbd6e7b 100644 --- a/tests/held_content/cases.lua +++ b/tests/held_content/cases.lua @@ -75,7 +75,7 @@ function C.run(root, backend, version, api, format) end row.count = count if mode == "price" then - f.T.eq(f.data.items.LEFTOVERS.price, 999, "item price composes with held fields") + f.T.eq(f.data.items[f.nativeItem("LEFTOVERS")].price, 999, "item price composes with held fields") end end rows[#rows + 1] = row diff --git a/tests/held_content/fixture.lua b/tests/held_content/fixture.lua index d3ebee657..0d9395952 100644 --- a/tests/held_content/fixture.lua +++ b/tests/held_content/fixture.lua @@ -25,16 +25,28 @@ function F.new(root, backend, version, api, format, mode) DRAGON_SCALE = 64, }) do local native = backend == "gen1" and gen == 2 - data.items[id] = { - id = id, + local key = backend == "gen2" + and gen == 2 + and ({ + LEFTOVERS = "ITEM_146", + BERRY = "ITEM_173", + QUICK_CLAW = "ITEM_073", + BRIGHTPOWDER = "ITEM_003", + FOCUS_BAND = "ITEM_112", + DRAGON_FANG = "ITEM_144", + DRAGON_SCALE = "ITEM_151", + })[id] + or id + data.items[key] = { + id = key, name = id:gsub("_", " "), price = 100, heldEffect = native and dofile(root .. "/tests/held_content/names.lua")[effect] or effect, } if id == "BERRY" then - data.items[id].key = "ORAN_BERRY" + data.items[key].key = "ORAN_BERRY" end - data.items[id][native and "heldParameter" or "heldParam"] = id == "BERRY" and 10 or 0 + data.items[key][native and "heldParameter" or "heldParam"] = id == "BERRY" and 10 or 0 end if backend == "gen1" and gen == 2 then local Items = require("src.core.gen2.ItemEffects") @@ -74,11 +86,18 @@ function F.new(root, backend, version, api, format, mode) -- The production Game2 load bridge runs after the real registry merge. require("src.core.gen2.ItemEffects").applyHeldItems(f.data, snapshot) end + function f.nativeItem(item) + if backend ~= "gen2" or f.gen ~= 2 then + return item + end + local id = require("src.mods.UnifiedIds").new(f.result.loader):resolve("items", item) + return id and id.key or item + end function f.run(item) local step = f.fresh("TACKLE") f.battle = step.battle local p = backend == "gen1" and f.gen == 2 and f.battle.player or f.battle.player.mon - p.hp, p.stats.hp, p.maxHp, p.item = 100, 400, 400, item or "LEFTOVERS" + p.hp, p.stats.hp, p.maxHp, p.item = 100, 400, 400, f.nativeItem(item or "LEFTOVERS") f.battle.queue, f.battle.nextInsert = {}, 0 if backend == "gen1" and f.gen == 2 then f.battle:betweenTurnEffects() @@ -93,7 +112,7 @@ function F.new(root, backend, version, api, format, mode) step.battle.player.item = item return step.battle:heldEffect(step.battle.player) end - step.battle.player.mon.item = item + step.battle.player.mon.item = f.nativeItem(item) local effect, amount = require("src.battle.HeldItems").effect(f.data, step.battle.player) return require("src.battle.HeldItemRead").goldPublic(effect), amount end From b24674aa65a22ecd02f6da8b7fd82236975abbbb Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:46:33 +0200 Subject: [PATCH 54/86] Preserve native item keys when display names collide --- docs/portable-held-content.md | 4 +++- src/mods/HeldItemContent.lua | 6 ++++++ tests/engine/held_item_content_authority.lua | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/portable-held-content.md b/docs/portable-held-content.md index d650a1f38..d06a3aa2d 100644 --- a/docs/portable-held-content.md +++ b/docs/portable-held-content.md @@ -29,7 +29,9 @@ Parameter meanings follow the effect; Gen-2 probabilities use byte odds. Common item IDs are resolved to native import keys (for example `LEFTOVERS` to `ITEM_146`). The new held registry accepts either spelling. Native Gen-2 `items` operations also accept common aliases while retaining native record -shapes; unrelated generations keep their existing native item accessors. +shapes. Exact native keys remain usable even when display names collide; +ambiguous common names are refused. Unrelated generations keep their existing +native item accessors. Unknown effect names can be interpreted through `held_item.trigger`. API 2/3 reject invalid known fields; API 1 retains its warning-based legacy validation behavior. This does not make arbitrary metadata gameplay fields. diff --git a/src/mods/HeldItemContent.lua b/src/mods/HeldItemContent.lua index f46c55871..dbe44cd12 100644 --- a/src/mods/HeldItemContent.lua +++ b/src/mods/HeldItemContent.lua @@ -60,11 +60,17 @@ function M.itemApi(loader, native, generation) out[key] = value end out.get = function(_, id) + if type(id) == "string" and loader.content.items:get(id) ~= nil then + return native:get(id) + end local group = resolve(loader, id) return native:get(group and group.key or id) end for _, verb in ipairs({ "register", "override", "patch", "remove" }) do out[verb] = function(_, id, value) + if type(id) == "string" and loader.content.items:get(id) ~= nil then + return native[verb](native, id, value) + end local group, result = resolve(loader, id) if not group then return native[verb](native, id, value) diff --git a/tests/engine/held_item_content_authority.lua b/tests/engine/held_item_content_authority.lua index ed4c234c6..3ffa50703 100644 --- a/tests/engine/held_item_content_authority.lua +++ b/tests/engine/held_item_content_authority.lua @@ -112,6 +112,21 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, rejected.release() d.items.BERRY.index = nil end + if format == "native" then + d.items.ITEM_001 = { id = "ITEM_001", name = "AMBIGUOUS", price = 1, heldEffect = 0, heldParam = 0 } + d.items.ITEM_002 = { id = "ITEM_002", name = "AMBIGUOUS", price = 2, heldEffect = 0, heldParam = 0 } + local raw = load( + d, + 'assert(mod.content.items:get("ITEM_001").price==1); mod.content.items:patch("ITEM_001", {price=7}); assert(not pcall(function() mod.content.held_items:get("AMBIGUOUS") end))', + api, + format + ) + T.eq(#raw.errors, 0, "native IDs remain addressable when presentation names are ambiguous") + T.eq(d.items.ITEM_001.price, 7, "raw native key patched") + T.eq(d.items.ITEM_002.price, 2, "distinct same-name item preserved") + raw.release() + d.items.ITEM_001, d.items.ITEM_002 = nil, nil + end if api >= 2 then for _, bad in ipairs({ "heldParameter=-1", From 4557463ace6c6c1b1e64e3e50b07d07e8ab86e42 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:54:21 +0200 Subject: [PATCH 55/86] Preserve large native item indices in unchanged unified round trips --- docs/portable-held-content.md | 2 ++ src/mods/HeldItemContent.lua | 6 ++++- src/mods/UnifiedContent.lua | 1 + src/mods/UnifiedSchemas.lua | 2 +- tests/engine/held_item_content_authority.lua | 28 ++++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/portable-held-content.md b/docs/portable-held-content.md index d06a3aa2d..78bf7b87b 100644 --- a/docs/portable-held-content.md +++ b/docs/portable-held-content.md @@ -33,6 +33,8 @@ shapes. Exact native keys remain usable even when display names collide; ambiguous common names are refused. Unrelated generations keep their existing native item accessors. Unknown effect names can be interpreted through `held_item.trigger`. +Native effect numbers without a known GSC name are not advertised as named +base definitions; explicit portable definitions can still replace them. API 2/3 reject invalid known fields; API 1 retains its warning-based legacy validation behavior. This does not make arbitrary metadata gameplay fields. diff --git a/src/mods/HeldItemContent.lua b/src/mods/HeldItemContent.lua index dbe44cd12..24db2c463 100644 --- a/src/mods/HeldItemContent.lua +++ b/src/mods/HeldItemContent.lua @@ -30,7 +30,11 @@ function M.record(def) if type(def) ~= "table" or def.heldEffect == nil then return nil end - return { heldEffect = Names.goldPublic(def.heldEffect), heldParameter = def.heldParam or 0 } + local name = Names.goldPublic(def.heldEffect) + if type(name) ~= "string" then + return nil + end + return { heldEffect = name, heldParameter = def.heldParam or 0 } end function M.changed(def) diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 36de491a7..5266ac455 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -286,6 +286,7 @@ local function writeRecord(ctx, kind, id, value, old, mode) unsupported("explicit item indices require a native generation-specific adapter") end if value.index ~= nil and value.index ~= DELETE then + check(value.index <= 255, "Gen-2 item index must be a byte") if value.index == 0 and not old then unsupported("item index zero is reserved") end for key, row in ctx.loader.content.items:each() do if row.index == value.index and ctx.ids:read("items", key) ~= id then diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua index 54d4894b3..57e594879 100644 --- a/src/mods/UnifiedSchemas.lua +++ b/src/mods/UnifiedSchemas.lua @@ -40,7 +40,7 @@ local U = { } }, items = { fields = { id = id, name = f.str, price = f.int(0), - index = f.opt(f.int(0, 255)), + index = f.opt(f.int(0, 65535)), 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), diff --git a/tests/engine/held_item_content_authority.lua b/tests/engine/held_item_content_authority.lua index 3ffa50703..819b49223 100644 --- a/tests/engine/held_item_content_authority.lua +++ b/tests/engine/held_item_content_authority.lua @@ -58,6 +58,11 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, T.eq(d.items.LEFTOVERS.heldParam, 7, "native parameter written") T.eq(d.items.LEFTOVERS.price, 100, "other attributes preserved") local h = r.loader.exports.probe.api.content.held_items + T.eq( + require("src.mods.HeldItemContent").record({ heldEffect = 999, heldParam = 0 }), + nil, + "unknown native enum cannot leak a number into the named definition contract" + ) T.raises(function() h:patch("LEFTOVERS", { heldParameter = 8 }) end, "frozen", "late writes cannot alter frozen content") @@ -154,5 +159,28 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, end end end +-- Reading a GBA index above the Gen-2 byte range must still permit an +-- unchanged read/modify/write round trip; only explicit index edits are gated. +V.set("firered") +do + local d = T.fixtures.fresh() + d.items.ITEM_321 = { id = "ITEM_321", index = 321, name = "PORTABLE TRINKET", price = 100 } + local files = { + ["mods/gba/manifest.json"] = Json.encode({ + id = "gba", + name = "gba", + version = "1", + api = 3, + entry = "main.lua", + games = { "gen3" }, + }), + ["mods/gba/main.lua"] = 'local mod=...; local row=assert(mod.content.items:get("PORTABLE_TRINKET")); assert(row.index==321); row.price=123; assert(mod.content.items:override("PORTABLE_TRINKET",row))', + } + local r = T.sdk.loadMods({ "mods/gba" }, { data = d, fs = T.sdk.memfs(files), dev = false }) + T.eq(#r.errors, 0, "large GBA native index survives unchanged unified round trip") + T.eq(d.items.ITEM_321.index, 321, "GBA index preserved") + T.eq(d.items.ITEM_321.price, 123, "GBA unrelated metadata remains writable") + r.release() +end V.set(old) T.finish("held_items authority, reload, freeze and transactional rollback") From a09595b62aff00c6b8171d62a07683bb0449a524 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:55:19 +0200 Subject: [PATCH 56/86] Test Gen-2 item index bounds through every strict content adapter --- tests/engine/held_item_content_authority.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/engine/held_item_content_authority.lua b/tests/engine/held_item_content_authority.lua index 819b49223..dc941bda9 100644 --- a/tests/engine/held_item_content_authority.lua +++ b/tests/engine/held_item_content_authority.lua @@ -133,6 +133,15 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, d.items.ITEM_001, d.items.ITEM_002 = nil, nil end if api >= 2 then + local badIndex = load( + d, + 'mod.content.items:register("OUTSIDE", {id="OUTSIDE", name="OUTSIDE", price=1, index=256})', + api, + format + ) + T.check(#badIndex.errors > 0, "Gen-2 native item index remains byte bounded") + T.eq(d.items.OUTSIDE, nil, "out-of-range index cannot create an item") + badIndex.release() for _, bad in ipairs({ "heldParameter=-1", "heldParameter=256", From 3d95e9a8952e89826700a3a28010221bd8ac9e7e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:59:37 +0200 Subject: [PATCH 57/86] Keep empty held patches from changing native item routing --- examples/portable_held_content/main.lua | 2 ++ src/mods/HeldItemContent.lua | 16 ++++++++++++++-- tests/engine/held_item_content.lua | 6 ++++++ tests/held_content/fixture.lua | 4 ++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/examples/portable_held_content/main.lua b/examples/portable_held_content/main.lua index 4f2faf8f2..f2f7445f4 100644 --- a/examples/portable_held_content/main.lua +++ b/examples/portable_held_content/main.lua @@ -35,6 +35,8 @@ elseif mode == "custom" then elseif mode == "price" then mod.content.items:patch("LEFTOVERS", { price = 999 }) held:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 17 }) +elseif mode == "noop_modern" then + held:patch("BERRY", {}) elseif mode == "modern" then held:override("BERRY", { heldEffect = "HELD_BERRY", heldParameter = 7 }) elseif mode == "dragon" then diff --git a/src/mods/HeldItemContent.lua b/src/mods/HeldItemContent.lua index 24db2c463..31e1ec610 100644 --- a/src/mods/HeldItemContent.lua +++ b/src/mods/HeldItemContent.lua @@ -131,6 +131,16 @@ function M.apply(loader, data) and (not was or row.heldEffect ~= was.heldEffect or row.heldParameter ~= was.heldParameter) or not row and was ~= nil local effect, parameter = def.heldEffect, def.heldParam + local declared = false + for _, op in ipairs(registry.ops[id]) do + local value = op.value + if + op.op ~= "patch" + or (type(value) == "table" and (value.heldEffect ~= nil or value.heldParameter ~= nil)) + then + declared = true + end + end if changed then effect = row and Names.goldNative(row.heldEffect) or nil parameter = row and (row.heldParameter or 0) or nil @@ -142,8 +152,10 @@ function M.apply(loader, data) effect = effect, parameter = parameter, } - def.heldEffect, def.heldParam = effect, parameter - explicit[def] = true + if changed and declared then + def.heldEffect, def.heldParam = effect, parameter + end + explicit[def] = declared or nil end end end diff --git a/tests/engine/held_item_content.lua b/tests/engine/held_item_content.lua index 216d8ebdc..ac389ee44 100644 --- a/tests/engine/held_item_content.lua +++ b/tests/engine/held_item_content.lua @@ -20,5 +20,11 @@ for _, version in ipairs(V.ORDER) do end end end +do + local f = Fixture.new(".", "gen2", "polishedcrystal", 3, "unified", "noop_modern") + local hp = f.run("BERRY", 200) + T.eq(hp, 210, "empty held patch retains native modern berry threshold at half HP") + f.close() +end V.set(old) T.finish("held_items public definitions and native generation boundaries") diff --git a/tests/held_content/fixture.lua b/tests/held_content/fixture.lua index 0d9395952..7eea38988 100644 --- a/tests/held_content/fixture.lua +++ b/tests/held_content/fixture.lua @@ -93,11 +93,11 @@ function F.new(root, backend, version, api, format, mode) local id = require("src.mods.UnifiedIds").new(f.result.loader):resolve("items", item) return id and id.key or item end - function f.run(item) + function f.run(item, hp) local step = f.fresh("TACKLE") f.battle = step.battle local p = backend == "gen1" and f.gen == 2 and f.battle.player or f.battle.player.mon - p.hp, p.stats.hp, p.maxHp, p.item = 100, 400, 400, f.nativeItem(item or "LEFTOVERS") + p.hp, p.stats.hp, p.maxHp, p.item = hp or 100, 400, 400, f.nativeItem(item or "LEFTOVERS") f.battle.queue, f.battle.nextInsert = {}, 0 if backend == "gen1" and f.gen == 2 then f.battle:betweenTurnEffects() From 373f07d5afa4bfc9b08788e35d5e860ce69449be Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:00:58 +0200 Subject: [PATCH 58/86] Reserve the NONE index for existing Gen-2 items too --- docs/portable-held-content.md | 3 +-- src/mods/UnifiedContent.lua | 2 +- tests/engine/held_item_content_authority.lua | 5 +++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/portable-held-content.md b/docs/portable-held-content.md index 78bf7b87b..bcf681995 100644 --- a/docs/portable-held-content.md +++ b/docs/portable-held-content.md @@ -44,8 +44,7 @@ or `patch`. Removing it clears the native held attributes, without removing the item itself. `register` supplies a definition, not an item: add the item through `mod.content.items` as well. A definition for an absent item does not create native inventory content. New Gen-2 unified items can carry their -native byte `index`; occupied indices and new items using index zero are -refused. Changing indices in other generations remains outside +native byte `index`; occupied indices and reassigning NONE's zero index are refused. Changing indices in other generations remains outside this adapter. The loader snapshots the native held attributes before mod entry, folds diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 5266ac455..0f23b6f70 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -287,7 +287,7 @@ local function writeRecord(ctx, kind, id, value, old, mode) end if value.index ~= nil and value.index ~= DELETE then check(value.index <= 255, "Gen-2 item index must be a byte") - if value.index == 0 and not old then unsupported("item index zero is reserved") end + if value.index == 0 and (not old or old.index ~= 0) then unsupported("item index zero is reserved") end for key, row in ctx.loader.content.items:each() do if row.index == value.index and ctx.ids:read("items", key) ~= id then unsupported("item index is already assigned: " .. tostring(value.index)) diff --git a/tests/engine/held_item_content_authority.lua b/tests/engine/held_item_content_authority.lua index dc941bda9..c02cf5220 100644 --- a/tests/engine/held_item_content_authority.lua +++ b/tests/engine/held_item_content_authority.lua @@ -88,6 +88,11 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, -- Index checks happen before the freeze guard; neither failure may write. local _, reason = items:register("CONFLICT", { id = "CONFLICT", name = "CONFLICT", price = 1, index = 0 }) T.check(type(reason) == "string" and reason:find("reserved", 1, true), "new item cannot claim NONE index") + local _, existingReason = items:patch("LEFTOVERS", { index = 0 }) + T.check( + type(existingReason) == "string" and existingReason:find("reserved", 1, true), + "existing item cannot claim NONE index" + ) end r.release() local clean = loadNone(d) From 70a21663173eb9555dc8ffc24c5c2c16027f5531 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:25:23 +0200 Subject: [PATCH 59/86] Add portable Gen-2 mart definitions and native bargain transactions --- .github/workflows/mart-content-contract.yml | 40 ++++ docs/api3-portability.md | 3 + docs/portable-mart-content.md | 50 +++++ examples/portable_mart_content/main.lua | 34 +++ examples/portable_mart_content/manifest.json | 1 + src/import/RomExtractorGen2.lua | 38 +++- src/inventory/Gen2MartStock.lua | 14 ++ src/mods/Loader.lua | 5 + src/mods/MartContent.lua | 200 +++++++++++++++++ src/mods/Schemas.lua | 7 + src/mods/UnifiedContent.lua | 3 + src/script/Commands.lua | 7 +- src/script/Gen2Commands.lua | 7 +- src/script/Gen2ScriptVM.lua | 2 +- src/ui/ShopMenu.lua | 36 ++- src/world/OverworldController.lua | 3 +- tests/drivers/mart_content.lua | 95 ++++++++ tests/engine/mart_content.lua | 10 + tests/engine/mart_content_authority.lua | 218 +++++++++++++++++++ tests/engine/mod_supports.lua | 2 +- tests/mart_content/cases.lua | 67 ++++++ tests/mart_content/fixture.lua | 126 +++++++++++ tests/mart_content/worker.lua | 6 + tools/mart-content-contract-check/README.md | 24 ++ tools/mart-content-contract-check/check.py | 52 +++++ 25 files changed, 1024 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/mart-content-contract.yml create mode 100644 docs/portable-mart-content.md create mode 100644 examples/portable_mart_content/main.lua create mode 100644 examples/portable_mart_content/manifest.json create mode 100644 src/inventory/Gen2MartStock.lua create mode 100644 src/mods/MartContent.lua create mode 100644 tests/drivers/mart_content.lua create mode 100644 tests/engine/mart_content.lua create mode 100644 tests/engine/mart_content_authority.lua create mode 100644 tests/mart_content/cases.lua create mode 100644 tests/mart_content/fixture.lua create mode 100644 tests/mart_content/worker.lua create mode 100644 tools/mart-content-contract-check/README.md create mode 100644 tools/mart-content-contract-check/check.py diff --git a/.github/workflows/mart-content-contract.yml b/.github/workflows/mart-content-contract.yml new file mode 100644 index 000000000..431461950 --- /dev/null +++ b/.github/workflows/mart-content-contract.yml @@ -0,0 +1,40 @@ +name: portable mart content +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: mart-content-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Validate native definitions and authority + run: | + luajit tests/engine/mart_content.lua + luajit tests/engine/mart_content_authority.lua + luajit tests/engine/mod_supports.lua + - name: Compare both SDKs in all four modes + run: | + mkdir -p /tmp/mart-content + python3 tools/mart-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 1 --format native --report /tmp/mart-content/api1.json + python3 tools/mart-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format native --report /tmp/mart-content/api2-native.json + python3 tools/mart-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format unified --report /tmp/mart-content/api2-unified.json + python3 tools/mart-content-contract-check/check.py --gen1 .contract-reference/gen1 --api 3 --format unified --report /tmp/mart-content/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: mart-content-contract + path: /tmp/mart-content + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 8de4a8a71..08a2cd5ae 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -173,3 +173,6 @@ names, byte odds, native consumers and the tested GBA item scheduling phases. See [portable held-item definitions](portable-held-content.md) for the shared Gen-2 `mod.content.held_items` registry, native write-back and generation bounds. + +See [portable mart definitions](portable-mart-content.md) for Gen-2 ordinary +and bargain shelves, common item IDs, native purchases and shared contracts. diff --git a/docs/portable-mart-content.md b/docs/portable-mart-content.md new file mode 100644 index 000000000..5d6e39dea --- /dev/null +++ b/docs/portable-mart-content.md @@ -0,0 +1,50 @@ +# Portable Gen-2 mart definitions + +Gen1Recomp's public `mod.content.marts` contract is available for Gen-2 games +through native API 1/2 and unified API 2/3. API 3 is the common unified entry; +the reference's API 2 remains native. Other generations report this registry +unavailable through `mod:supports("content", "marts")` and the unified accessor. +This does not add a GBA or DS mart-definition contract. + +```lua +local mod = ... +if not mod:supports("content", "marts") then return end +mod.content.marts:patch("0", { "ULTRA_BALL", "POTION" }) +mod.content.marts:patch("bargain", { { item = "NUGGET", price = 500 } }) +``` + +IDs `"0"` through `"33"` are the ROM script mart numbers, not `MART_*` names. +Ordinary shelves are ordered lists of common item IDs; prices come from the +item definitions. `"bargain"` is an ordered list of `{ item, price }` rows with +integer prices from 0 through 65535. Its native shop ignores the script's mart +number and sells one of each item, tracking sold items on the save like the +reference. Daily stock reset is outside this contract. + +`get`, `each`, `register`, `override`, `patch` and `remove` are supported. +Every patch replaces the whole shelf, including `{}`. `remove` empties the +native shelf while `get` returns nil. Append/prepend wrappers and sparse lists +are rejected. Registering an existing shelf requires an explicit override. +API-2/3 schema errors fail the entry and roll back earlier operations; native +API 1 retains the SDK's warning policy for schema errors. Structural list and +ID checks are always enforced. Public reads are detached and writes freeze +after load. Common item names resolve through the mounted data, including +imported `ITEM_*` keys; no cartridge-specific item-number table is used. + +The registry is a virtual view over `data.map_scripts.marts`; it introduces no +new generated-data namespace. Only successful contributions are applied after +ordinary item merges. Unmodified shelves retain their native table identity, +and the next load retires matching writes owned by this bridge. The ROM VM +preserves the shop type when lowering `pokemart`, and the native bargain UI +uses separate prices without changing item definitions or ordinary shops. + +New imports retain mart IDs on fallback clerk markers so they also resolve +the current shelf. Older imported marker-only fallbacks need reimport for +those annotations; the ordinary ROM-script path uses the live pool directly. +Fresh imports also read the ROM's optional `BargainShopData`; a mod can supply +bargain rows when an older cache lacks it. This change does not claim native +pixel equivalence or implement every special shop presentation. + +See `examples/portable_mart_content`, `tools/mart-content-contract-check/` +and `tests/drivers/mart_content.lua`. The imported Gold smoke requires an +isolated test identity, starts an in-memory New Game, runs actual lowered +script coroutines and UI input, and exits without a normal SAVE action. diff --git a/examples/portable_mart_content/main.lua b/examples/portable_mart_content/main.lua new file mode 100644 index 000000000..9001837cf --- /dev/null +++ b/examples/portable_mart_content/main.lua @@ -0,0 +1,34 @@ +local mod = ... +mod.exports.api = mod +local available = mod.supports and mod:supports("content", "marts") + or (not mod.supports and mod.generation == 2 and mod.content.marts ~= nil) +mod.exports.available = available == true +if not available then + return +end +local m = mod.content.marts +local mode = mod:read("case.txt") or "replace" +if mode == "replace" or mode == "rollback" or mode == "stack" then + m:patch("0", { "ULTRA_BALL" }) + m:patch("bargain", { { item = "NUGGET", price = 500 } }) +elseif mode == "empty" then + m:patch("0", {}) + m:patch("bargain", {}) +elseif mode == "remove" then + m:remove("0") + m:remove("bargain") +elseif mode == "register" then + m:remove("33") + m:register("33", { "ULTRA_BALL", "POTION" }) +elseif mode == "price" then + mod.content.items:patch("POTION", { price = 7 }) +elseif mode == "zero" or mode == "maximum" then + m:override("bargain", { { item = "NUGGET", price = mode == "zero" and 0 or 65535 } }) +elseif mode == "delete" then + m:override("0", mod.DELETE) +elseif mode == "invalid" then + m:patch("0", { [2] = "POTION" }) +end +if mode == "rollback" then + error("intentional mart entry rollback") +end diff --git a/examples/portable_mart_content/manifest.json b/examples/portable_mart_content/manifest.json new file mode 100644 index 000000000..f191f0a17 --- /dev/null +++ b/examples/portable_mart_content/manifest.json @@ -0,0 +1 @@ +{"id":"portable_mart_content","name":"Portable mart content","version":"1.0.0","api":3,"games":["gen2"],"entry":"main.lua"} diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..79dc65a28 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3340,6 +3340,25 @@ function RomExtractorGen2:gen2Marts() end end) end + local bargain = self:symbol("BargainShopData") + if bargain and self.rom then + local ok, rows = pcall(function() + local stock = {} + local size = math.min(self.rom:byte(bargain.bank, bargain.address), 32) + for slot = 0, size - 1 do + local address = bargain.address + 1 + slot * 3 + local item = self.rom:byte(bargain.bank, address) + if item == 0xFF then break end + stock[#stock + 1] = { + item = string.format("ITEM_%03d", item), + price = self.rom:word(bargain.bank, address + 1), + } + end + return stock + end) + if ok then out.bargain = rows + else Logger.warn("gen2 bargain stock unavailable: %s", tostring(rows)) end + end self._marts = out return out end @@ -3355,8 +3374,9 @@ function RomExtractorGen2:gen2MartStock(bank, address) if op == self:opcode("pokemart") then local dialog = self.rom:byte(bank, address + offset + 1) local index = self.rom:word(bank, address + offset + 2) - if dialog <= GEN2_MART_TYPE_MAX and marts[index] and #marts[index] > 0 then - return marts[index] + local stock = dialog == 2 and marts.bargain or marts[index] + if dialog <= GEN2_MART_TYPE_MAX and stock and #stock > 0 then + return stock, index, dialog end end end @@ -6779,7 +6799,7 @@ function RomExtractorGen2:extractMapsFromRom() -- opcode and rows 10-12 its operands. 957 objects here: the -- jumptext(faceplayer) NPCs whose dialogue nothing ever read, the -- jumpstd bookshelves, the berry trees, the mart clerks. - local inlineText, inlineStd, inlineMart, silent, machineItem + local inlineText, inlineStd, inlineMart, inlineMartId, inlineMartType, silent, machineItem -- THE SHARED PLACEHOLDER OBJECT. Forty-nine of Prism's objects -- point their script at ObjectEvent (00:$1AD1), the home-bank stub -- whose whole body is `jumptextfaceplayer ObjectEventText` @@ -6813,7 +6833,8 @@ function RomExtractorGen2:extractMapsFromRom() quantity = 1 elseif op == self:opcode("pokemart") then local marts = self:gen2Marts() - inlineMart = marts[a2] or marts[a2 + row[12] * 256] + inlineMartId, inlineMartType = a2 + row[12] * 256, a1 + inlineMart = inlineMartType == 2 and marts.bargain or marts[inlineMartId] end end -- Crystal's itemball field is a POINTER to `itemball ITEM` -- @@ -6861,7 +6882,8 @@ function RomExtractorGen2:extractMapsFromRom() -- scriptAddress is row[10] + row[11] * 256, so the mart index -- the command's second operand takes is simply row[11] local marts = self:gen2Marts() - inlineMart = marts[row[11]] + inlineMartId, inlineMartType = row[11], row[10] + inlineMart = inlineMartType == 2 and marts.bargain or marts[inlineMartId] silent = inlineMart == nil elseif script and kind == GEN2_OBJECT_KIND_ITEMBALL then item = self.rom:byte(bank, scriptAddress) @@ -6967,8 +6989,8 @@ function RomExtractorGen2:extractMapsFromRom() and self.rom:byte(bank, scriptAddress + 1) == GEN2_STD_POKECENTER_NURSE then marker = { nurse = true } else - local stock = self:gen2MartStock(bank, scriptAddress) - if stock then marker = { mart = stock } end + local stock, martId, martType = self:gen2MartStock(bank, scriptAddress) + if stock then marker = { mart = stock, martId = martId, martType = martType } end end -- Scan opening bytecode for `special ` (op $0F, dw index). @@ -6996,6 +7018,7 @@ function RomExtractorGen2:extractMapsFromRom() local entry = textPointers[mapId][textConst] or { text = textConst } entry.nurse = marker.nurse entry.mart = marker.mart + entry.martId, entry.martType = marker.martId, marker.martType if marker.special then entry.special = marker.special entry.specialId = marker.specialId @@ -7041,6 +7064,7 @@ function RomExtractorGen2:extractMapsFromRom() spokenObjects = spokenObjects + 1 elseif inlineMart then entry.mart = inlineMart + entry.martId, entry.martType = inlineMartId, inlineMartType serviceObjects = serviceObjects + 1 elseif inlineStd == GEN2_STD_POKECENTER_NURSE then entry.nurse = true diff --git a/src/inventory/Gen2MartStock.lua b/src/inventory/Gen2MartStock.lua new file mode 100644 index 000000000..c1e67b5fc --- /dev/null +++ b/src/inventory/Gen2MartStock.lua @@ -0,0 +1,14 @@ +-- The ROM's mart number is zero based. Type 2 uses its independent priced +-- rows and deliberately ignores that number, as BargainShop does. +local M = {} +function M.get(data, index, martType) + local pool = data.map_scripts and data.map_scripts.marts + return pool and pool[martType == 2 and "bargain" or index], { bargain = martType == 2 } +end +function M.entry(data, entry) + if entry and entry.martId ~= nil and require("src.core.GameVersion").generation() == 2 then + return M.get(data, entry.martId, entry.martType) + end + return entry and entry.mart +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index fc860df91..daec6027c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -762,6 +762,9 @@ function Loader:_contentApi(mod, registry, deprecation) if registry.name == "held_items" then return require("src.mods.HeldItemContent").api(loader, mod, api, loader:_generation(), false) end + if registry.name == "marts" then + return require("src.mods.MartContent").api(loader, mod, api, loader:_generation(), false) + end if registry.name == "items" then return require("src.mods.HeldItemContent").itemApi(loader, api, loader:_generation()) end @@ -1418,6 +1421,7 @@ function Loader:load(data) end end require("src.mods.HeldItemContent").prepare(self, data) + require("src.mods.MartContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1646,6 +1650,7 @@ function Loader:load(data) end end require("src.mods.HeldItemContent").apply(self, data) + require("src.mods.MartContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/MartContent.lua b/src/mods/MartContent.lua new file mode 100644 index 000000000..2ad13a609 --- /dev/null +++ b/src/mods/MartContent.lua @@ -0,0 +1,200 @@ +-- Gen-2 public shelves project onto the native, zero-based script pool. +-- Keep the registry virtual: importing and running unmodified data needs no +-- extra namespace, and only successful entries write into the mounted pool. +local Merge = require("src.mods.Merge") +local Ids = require("src.mods.UnifiedIds") +local M = {} +local previous = setmetatable({}, { __mode = "k" }) + +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function identifier(id) + assert( + type(id) == "string" + and (id == "bargain" or id:match("^%d+$") and tonumber(id) <= 33 and tostring(tonumber(id)) == id), + "marts: expected id 0..33 or bargain" + ) +end + +local function ordered(value) + assert(type(value) == "table", "marts: expected ordered stock list") + local count = 0 + for key in pairs(value) do + assert(type(key) == "number" and key >= 1 and key % 1 == 0, "marts: expected contiguous stock list") + count = count + 1 + end + for i = 1, count do + assert(value[i] ~= nil, "marts: expected contiguous stock list") + end +end + +local function item(loader, id, writing) + if not writing then + return Ids.new(loader):read("items", id) + end + -- Existing raw keys remain authoritative even when display names collide. + if loader.content.items:get(id) ~= nil then + return id + end + local group, reason = Ids.new(loader):resolve("items", id) + if group then + return group.key + end + assert(not reason or not reason:find("ambiguous", 1, true), reason) + -- Forward references are checked after all entries have merged. + return id +end + +local function convert(loader, id, value, writing) + if value == nil or value == Merge.DELETE then + return value + end + ordered(value) + local out = {} + for i, row in ipairs(value) do + if id == "bargain" then + assert(type(row) == "table" and row ~= Merge.DELETE, "marts: expected bargain item/price row") + for key in pairs(row) do + assert(key == "item" or key == "price", "marts: unknown bargain field " .. tostring(key)) + end + out[i] = { item = item(loader, row.item, writing), price = row.price } + else + out[i] = item(loader, row, writing) + end + end + return out +end + +function M.prepare(loader, data) + for _, undo in ipairs(data and previous[data] or {}) do + if undo.target[undo.key] == undo.value and equal(undo.value, undo.snapshot) then + undo.target[undo.key] = undo.before + end + end + if data then + previous[data] = nil + end + local base = {} + if data and loader:_generation() == 2 then + local marts = data.map_scripts and data.map_scripts.marts + if marts then + for index = 0, 33 do + if type(marts[index]) == "table" then + base[tostring(index)] = Merge.deepCopy(marts[index]) + end + end + if type(marts.bargain) == "table" then + base.bargain = Merge.deepCopy(marts.bargain) + end + end + end + loader.content.marts.base = function() + return base + end +end + +function M.apply(loader, data) + local registry = loader.content.marts + if not data or loader:_generation() ~= 2 or next(registry.ops) == nil then + return + end + data.map_scripts = data.map_scripts or {} + data.map_scripts.marts = data.map_scripts.marts or {} + local target, undo = data.map_scripts.marts, {} + for _, id in ipairs(registry.order) do + if registry.ops[id] then + local value = convert(loader, id, registry:get(id), true) + registry.cache[id] = { value = value } + local key = id == "bargain" and id or tonumber(id) + local shelf = Merge.deepCopy(value or {}) + undo[#undo + 1] = { + target = target, + key = key, + before = target[key], + value = shelf, + snapshot = Merge.deepCopy(shelf), + } + target[key] = shelf + end + end + previous[data] = undo +end + +function M.api(loader, mod, native, generation, unified) + local available = generation == 2 + local reason = not available and "unsupported: marts has no native target in this dataset" or nil + local out = { + available = available, + supports = function() + return available, reason + end, + } + out.get = function(_, id) + if not available then + return nil, reason + end + if unified then + identifier(id) + end + return convert(loader, id, native:get(id), false) + end + out.has = function(_, id) + return out:get(id) ~= nil + end + out.each = function() + if not available then + return function() end + end + local nextRow = native:each() + return function() + local id, value = nextRow() + if id then + return id, convert(loader, id, value, false) + end + end + end + local warned = false + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + out[verb] = function(_, id, value) + if not available then + if not unified and not warned then + warned = true + loader.errors[#loader.errors + 1] = mod.manifest.id .. ": marts has no native target" + end + return nil, reason + end + identifier(id) + value = convert(loader, id, value, true) + -- Unlike record patches, even {} replaces the whole ordered shelf. + local operation = verb == "patch" and "override" or verb + native[operation](native, id, value) + return out:get(id) + end + end + return out +end + +return M diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 28bf2b46d..20e5ae266 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -496,6 +496,13 @@ R.held_items = { example = 'mod.content.held_items:override("LEFTOVERS", { heldEffect = "HELD_BERRY", heldParameter = 7 })', } +local martKeys = { bargain = f.list(f.rec { item = f.id("items"), price = f.int(0, 65535) }) } +for index = 0, 33 do martKeys[tostring(index)] = f.list(f.id("items")) end +R.marts = { + semantics = "record", generations = "2", keys = martKeys, keysClosed = true, + example = 'mod.content.marts:patch("0", { "ULTRA_BALL" })', +} + R.maps = { semantics = "record", target = "maps", fields = { diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 0f23b6f70..69955d9b0 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -406,6 +406,9 @@ function U.new(loader, mod, native, gen) if native.held_items then out.held_items = require("src.mods.HeldItemContent").api(loader, mod, native.held_items, gen, true) end + if native.marts then + out.marts = require("src.mods.MartContent").api(loader, mod, native.marts, gen, true) + end for kind, shape in pairs(Shapes) do local registry = loader.content[kind] if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3032dc380..4e84e00b8 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1493,14 +1493,15 @@ end function Commands.open_mart(ctx, textConst) local ow = ctx.overworld local entry = ctx.game.data:textEntry(ow.map.def.label, textConst) - if not entry or not entry.mart then + local stock, opts = require("src.inventory.Gen2MartStock").entry(ctx.game.data, entry) + if not stock then Logger.warn("open_mart: no mart on %s/%s", ow.map.def.label, tostring(textConst)) return end local runner = ctx.runner - Screens.push(ctx.game, "ShopMenu", entry.mart, function() + Screens.push(ctx.game, "ShopMenu", stock, function() runner:resume() - end) + end, opts) runner:yield() end diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..e5e73eac7 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -1877,16 +1877,15 @@ end -- `pokemart MARTTYPE_*, MART_*`: the item list lives in map_scripts.marts, -- read straight out of the ROM's Marts pointer table. -function Commands.g2_mart(ctx, index) - local pool = ctx.game.data.map_scripts - local stock = pool and pool.marts and pool.marts[index] +function Commands.g2_mart(ctx, index, martType) + local stock, opts = require("src.inventory.Gen2MartStock").get(ctx.game.data, index, martType) if type(stock) ~= "table" or #stock == 0 then Logger.warn("gen2 script: no mart %s", tostring(index)) return end local runner = ctx.runner local Screens = require("src.ui.Screens") - Screens.push(ctx.game, "ShopMenu", stock, function() runner:resume() end) + Screens.push(ctx.game, "ShopMenu", stock, function() runner:resume() end, opts) runner:yield() end diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..e2a7638fd 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -786,7 +786,7 @@ L.waitsfx = function(_, s) emit(s, { "g2_nop" }) end -- `pokemart MARTTYPE_*, MART_*`: the second operand indexes the Marts pointer -- table the extractor stamps into map_scripts.marts. -L.pokemart = function(ir, s) emit(s, { "g2_mart", ir[3] }) end +L.pokemart = function(ir, s) emit(s, { "g2_mart", ir[3], ir[2] }) end -- The extractor resolves both pointers into tables at import time: `elevator` -- into its floor list, `loadmenu` into its MenuData labels. `verticalmenu` -- runs the armed menu and drops the 1-based choice (0 = cancelled) into the diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 7265b74fd..cf1cdc1fb 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -94,15 +94,17 @@ local function price(game, amount) return sign .. tostring(amount) end -local function buy(game, stock) +local function buy(game, stock, bargain, onQuit) local items = {} - for _, id in ipairs(stock) do + for _, row in ipairs(stock) do + local id = bargain and row.item or row local def = game.data.items[id] if def then table.insert(items, { value = id, label = def.name, - right = price(game, def.price), + right = price(game, bargain and row.price or def.price), + unitPrice = bargain and row.price or nil, }) end end @@ -115,22 +117,28 @@ local function buy(game, stock) dialogue = true, money = function() return game.save.money end, footer = greet, + onCancel = onQuit, onChoose = function(item) local def = game.data.items[item.value] - if game.save.money < def.price then + local unitPrice = item.unitPrice or def.price + if bargain and (game.save.bargainShop or {})[item.value] then + list.footer = Strings("SOLD OUT") + return + end + if game.save.money < unitPrice then list.footer = notEnough return end - local affordable = math.min(99, math.floor(game.save.money / math.max(1, def.price))) + local affordable = bargain and 1 or math.min(99, math.floor(game.save.money / math.max(1, unitPrice))) game.stack:push(QuantityBox.new(game, { max = affordable, - unitPrice = def.price, + unitPrice = unitPrice, onDone = function(qty) if not qty then list.footer = greet return end - local cost = qty * def.price + local cost = qty * unitPrice -- _PokemartTellBuyPriceText + yes/no confirm list.footer = line(game, "buyTotal", Strings("%s?\nThat will be\n%s. OK?", def.name, @@ -142,6 +150,10 @@ local function buy(game, stock) list.footer = greet return end + if bargain and (game.save.bargainShop or {})[item.value] then + list.footer = Strings("SOLD OUT") + return + end if game.save.money < cost then list.footer = notEnough return @@ -153,6 +165,10 @@ local function buy(game, stock) return end require("src.core.Sound").play(game.data, "Purchase") + if bargain then + game.save.bargainShop = game.save.bargainShop or {} + game.save.bargainShop[item.value] = true + end game.save.money = game.save.money - cost list.footer = line(game, "boughtBag", txt(game, "_PokemartBoughtItemText", @@ -162,7 +178,8 @@ local function buy(game, stock) })) end, }) - game.stack:push(list) + if not bargain then game.stack:push(list) end + return list end local function sell(game) @@ -294,7 +311,8 @@ function ShopMenu.coverWith(game, screen) game.stack:push(screen) end -function ShopMenu.new(game, stock, onQuit) +function ShopMenu.new(game, stock, onQuit, opts) + if opts and opts.bargain then return buy(game, stock, true, onQuit) end local counter = gen3Counter(game) if counter then local fireRed = require("src.core.GameVersion").get() == "firered" diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index dfcc054ca..7d5d4d74b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -11053,7 +11053,8 @@ function OverworldState:talkTo(npc) if entry.mart then npc:facePlayer(self.player) Game.stack:push(TextBox.new(Game, Strings("Hi there!\nMay I help you?"), function() - Screens.push(Game, "ShopMenu", entry.mart) + local stock, opts = require("src.inventory.Gen2MartStock").entry(Game.data, entry) + Screens.push(Game, "ShopMenu", stock, nil, opts) unfreeze() end)) return diff --git a/tests/drivers/mart_content.lua b/tests/drivers/mart_content.lua new file mode 100644 index 000000000..ce6b4007b --- /dev/null +++ b/tests/drivers/mart_content.lua @@ -0,0 +1,95 @@ +-- Imported Gold, actual ROM-IR lowering, script coroutine and UI input. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == "gold", "this imported-data smoke uses Gold") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + title.onNewGame() + U.wait(60) + local world = assert(game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_mart_content, "install public mart example") + assert(e.available, "public mart registry unavailable") + local keys = {} + for id, def in pairs(game.data.items) do + if def.name == "ULTRA BALL" or def.name == "ULTRA_BALL" then + keys.ball = id + end + if def.name == "NUGGET" then + keys.nugget = id + end + end + assert(keys.ball and keys.nugget, "imported item names missing") + assert(game.data.map_scripts.marts[0][1] == keys.ball, "public ID did not reach native stock") + local unit = game.data.items[keys.ball].price + local Runner, VM = require("src.script.ScriptRunner"), require("src.script.Gen2ScriptVM") + require("src.script.Gen2Commands") + local function run(kind) + game.stack.states = { world } + local runner = Runner.new(game, world) + world.runner = runner + local scripts = game.data.map_scripts.scripts + local label = "__portable_mart_" .. kind + scripts[label] = { { "pokemart", kind, 0 }, { "end" } } + local rows = assert(VM.compile(game.data, label)) + runner:run(rows) + assert(runner:isRunning(), "native shop failed to yield") + return runner + end + local function tap(key) + U.tap(game, key) + U.wait(20) + end + game.save.money, game.save.inventory, game.save.bagOrder, game.save.bargainShop = 10000, {}, {}, {} + local runner = run(0) + for _ = 1, 4 do + tap("a") + end + assert(game.save.inventory[keys.ball] == 1, "native ordinary buy did not grant item") + assert(game.save.money == 10000 - unit, "ordinary buy did not use native item price") + tap("b") + tap("b") + assert(not runner:isRunning(), "ordinary close did not resume real script coroutine") + local balance = game.save.money + runner = run(2) + for _ = 1, 3 do + tap("a") + end + assert(game.save.inventory[keys.nugget] == 1, "native bargain did not grant item") + assert(game.save.money == balance - 500, "native bargain ignored explicit price") + assert(game.save.bargainShop[keys.nugget], "native bargain did not record sold item") + tap("a") + assert( + game.save.money == balance - 500 and game.save.inventory[keys.nugget] == 1, + "sold-out item bought twice" + ) + tap("b") + assert(not runner:isRunning(), "bargain close did not resume real script coroutine") + runner = run(2) + tap("a") + assert( + game.save.money == balance - 500 and game.save.inventory[keys.nugget] == 1, + "reopened bargain lost sold state" + ) + tap("b") + assert(not runner:isRunning(), "reopened bargain remained blocked") + local row = e.api.content.marts:get("0") + row[1] = "CORRUPTED" + assert(e.api.content.marts:get("0")[1] == "ULTRA_BALL", "public read was not detached") + assert(not pcall(function() + e.api.content.marts:patch("0", {}) + end), "late public write was not frozen") + print("PORTABILITY_MART_CONTENT_PASS gold") + love.event.quit(0) +end diff --git a/tests/engine/mart_content.lua b/tests/engine/mart_content.lua new file mode 100644 index 000000000..f1f537409 --- /dev/null +++ b/tests/engine/mart_content.lua @@ -0,0 +1,10 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local old = V.get() +for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, version in ipairs(V.ORDER) do + dofile("tests/mart_content/cases.lua").run(".", "gen2", version, mode[1], mode[2]) + end +end +V.set(old) +T.finish("public mart content and native purchases") diff --git a/tests/engine/mart_content_authority.lua b/tests/engine/mart_content_authority.lua new file mode 100644 index 000000000..28c406398 --- /dev/null +++ b/tests/engine/mart_content_authority.lua @@ -0,0 +1,218 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local F = dofile("tests/mart_content/fixture.lua") +local Copy = require("src.mods.Merge").deepCopy +local old = V.get() +local function fresh(api, format, body) + return F.new(".", "gen2", "gold", api, format, "none", body) +end +for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local api, format = unpack(mode) + local f = F.new(".", "gen2", "gold", api, format, "replace") + local m = f.e.api.content.marts + local pool = f.data.map_scripts.marts + T.same(pool[0], { f.key("ULTRA_BALL") }, "public item ID reaches native pool key") + T.eq(f.data.gen2Marts, nil, "virtual registry creates no foreign namespace") + T.raises(function() + m:patch("0", { "POTION" }) + end, "frozen", "late shelf writes are frozen") + local stock = m:get("0") + stock[1] = "CORRUPTED" + for _, row in m:each() do + row[1] = "CORRUPTED" + end + T.same(m:get("0"), { "ULTRA_BALL" }, "public reads cannot mutate the native pool") + local before = pool[1] + pool[1] = { "UNRELATED" } + f.close() + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "items" and root ~= "map_scripts" then + f.data[root] = nil + end + end + local r = T.sdk.loadNone({ data = f.data }) + T.same(pool[0], { f.key("POTION"), f.key("ANTIDOTE") }, "reload retires previous shelf writes") + T.eq(f.data.map_scripts.marts, pool, "reload preserves mounted pool identity") + T.same(pool[1], { "UNRELATED" }, "reload preserves unrelated native shelves") + T.eq(before, nil, "fixture side shelf was originally absent") + r.release() + local no = F.new(".", "gen2", "gold", api, format, "no-mod") + T.same(no.shelf("0"), { no.key("POTION"), no.key("ANTIDOTE") }, "no-mod native stock unchanged") + T.same( + no.shelf("bargain"), + { { item = no.key("NUGGET"), price = 4500 } }, + "no-mod native bargain price unchanged" + ) + no.close() + for _, id in ipairs({ '"00"', '"-1"', '"34"', '"MART_CHERRYGROVE"', '"generation"', '""', "0", "nil" }) do + local bad = fresh( + api, + format, + 'mod.content.marts:patch("0", {"ULTRA_BALL"}); mod.content.marts:patch(' .. id .. ", {})" + ) + T.check(#bad.result.errors > 0, "invalid mart ID rejects entry " .. id) + T.same(bad.shelf("0"), { bad.key("POTION"), bad.key("ANTIDOTE") }, "ID failure rolls earlier write back") + bad.close() + end + for _, value in ipairs({ '{[2]="POTION"}', '{__append={"POTION"}}', '{[0]="POTION"}', '{other="POTION"}' }) do + local bad = fresh(api, format, 'mod.content.marts:patch("0", ' .. value .. ")") + T.check(#bad.result.errors > 0, "non-contiguous and wrapper stock rejected") + T.eq(next(bad.result.loader.content.marts.ops), nil, "invalid stock leaves no registry operations") + bad.close() + end + if api >= 2 then + for _, value in ipairs({ + '{{item="NUGGET",price=-1}}', + '{{item="NUGGET",price=65536}}', + '{{item="NUGGET",price=0/0}}', + '{{item="NUGGET",price=math.huge}}', + '{{item="NUGGET"}}', + "{{price=1}}", + '{{item="NUGGET",price=1,typo=true}}', + }) do + local bad = fresh( + api, + format, + 'mod.content.marts:patch("0", {"ULTRA_BALL"}); mod.content.marts:patch("bargain", ' .. value .. ")" + ) + T.check(#bad.result.errors > 0, "invalid bargain row rejects entire entry") + T.same( + bad.shelf("bargain"), + { { item = bad.key("NUGGET"), price = 4500 } }, + "invalid bargain row cannot alter native prices" + ) + T.same( + bad.shelf("0"), + { bad.key("POTION"), bad.key("ANTIDOTE") }, + "bargain failure rolls ordinary shelf back" + ) + bad.close() + end + end + local forward = fresh( + api, + format, + [[mod.content.marts:patch("0",{"CUSTOM_MART"}); + mod.content.items:register("ITEM_240",{id="ITEM_240",index=240,name="CUSTOM MART",price=300})]] + ) + T.eq(#forward.result.errors, 0, "forward common item reference resolves after ordinary item merge") + T.same(forward.shelf("0"), { "ITEM_240" }, "forward reference reaches actual imported key") + T.same(forward.e.api.content.marts:get("0"), { "CUSTOM_MART" }, "forward public read uses common name") + forward.close() +end +-- Native script dispatch and ordinary UI input, including close/resume. +do + local f = F.new(".", "gen2", "gold", 3, "unified", "replace") + local Stack = require("src.core.StateStack") + local game = { data = f.data, save = { money = 10000, inventory = {}, bagOrder = {} } } + game.stack = setmetatable({}, { __index = Stack }) + game.stack:init() + local button + game.input = { + wasPressed = function(_, key) + return button == key + end, + } + local yielded, resumed = 0, 0 + local runner = { + yield = function() + yielded = yielded + 1 + end, + resume = function() + resumed = resumed + 1 + end, + } + local function press(key) + button = key + game.stack:top():update(1 / 60) + button = nil + for _ = 1, 20 do + local top = game.stack:top() + if top and top.update then + top:update(1 / 60) + end + end + end + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2_mart({ game = game, runner = runner }, 0, 0) + T.eq(yielded, 1, "ordinary native shop yields runner") + press("a") + press("a") + press("a") + press("a") + T.eq(game.save.inventory[f.key("ULTRA_BALL")], 1, "native shop input grants item") + T.eq(game.save.money, 8800, "native shop input charges ordinary item price") + press("b") + press("b") + T.eq(resumed, 1, "ordinary shop close resumes runner exactly once") + Commands.g2_mart({ game = game, runner = runner }, 33, 2) + T.eq(yielded, 2, "bargain native command selects independent stock") + press("a") + press("a") + press("a") + T.eq(game.save.inventory[f.key("NUGGET")], 1, "bargain input grants one item") + T.eq(game.save.money, 8300, "bargain input charges its own price") + press("a") + T.eq(game.save.money, 8300, "sold-out input charges nothing") + T.eq(game.save.inventory[f.key("NUGGET")], 1, "sold-out input grants nothing") + press("b") + T.eq(resumed, 2, "bargain close resumes runner exactly once") + T.eq(game.stack:top(), nil, "native shop closes all its owned screens") + f.close() +end +-- Real ROM IR lowering must retain the type that chooses the separate prices. +V.set("gold") +local data = { map_scripts = { scripts = { shop = { { "pokemart", 2, 33 }, { "end" } } } } } +local rows = require("src.script.Gen2ScriptVM").compile(data, "shop") +local seen = false +for _, row in ipairs(rows or {}) do + if row[1] == "g2_mart" then + T.same(row, { "g2_mart", 33, 2 }, "ROM type survives native script lowering") + seen = true + end +end +T.check(seen, "compiled native shop command exists") +local Extract = require("src.import.RomExtractorGen2") +local bytes = { + [0x4000] = 0x02, + [0x4001] = 0x40, + [0x4002] = 2, + [0x4003] = 18, + [0x4004] = 9, + [0x4100] = 2, + [0x4101] = 36, + [0x4102] = 0xf4, + [0x4103] = 1, + [0x4104] = 2, + [0x4105] = 0xff, + [0x4106] = 0xff, +} +local rom = { + byte = function(_, _, address) + assert(bytes[address], "out of fixture ROM") + return bytes[address] + end, +} +rom.word = function(self, bank, address) + return self:byte(bank, address) + 256 * self:byte(bank, address + 1) +end +local extract = setmetatable( + { symbols = { Marts = { 1, 0x4000 }, BargainShopData = { 1, 0x4100 } }, rom = rom }, + { __index = Extract } +) +local marts = extract:gen2Marts() +T.same(marts[0], { "ITEM_018", "ITEM_009" }, "real ROM reader keeps ordinary zero-based rows") +T.same( + marts.bargain, + { { item = "ITEM_036", price = 500 }, { item = "ITEM_002", price = 65535 } }, + "real ROM reader retains independent 16-bit bargain prices" +) +local Stock = require("src.inventory.Gen2MartStock") +local native = { map_scripts = { marts = marts } } +local shelf, opts = Stock.get(native, 33, 2) +T.eq(shelf, marts.bargain, "bargain ignores ordinary script mart number") +T.eq(opts.bargain, true, "bargain carries native transaction policy") +T.eq(Stock.entry(native, { martId = 0, mart = { "STALE" } }), marts[0], "annotated fallback uses live shelf") +V.set(old) +T.finish("mart ownership, rollback, reload, validation and native import/VM") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index f899902e6..f15031ed1 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -70,7 +70,7 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': shared simulation/world call sites available') local registries = true for name in pairs(Schemas.REGISTRIES) do - local expected = name ~= 'held_items' or GameVersion.generation(version) == 2 + local expected = (name ~= 'held_items' and name ~= 'marts') or GameVersion.generation(version) == 2 registries = registries and (expected and yes(api, 'content', name) or not expected and no(api, 'content', name)) end diff --git a/tests/mart_content/cases.lua b/tests/mart_content/cases.lua new file mode 100644 index 000000000..5b8a8da0f --- /dev/null +++ b/tests/mart_content/cases.lua @@ -0,0 +1,67 @@ +local C = {} +function C.run(root, backend, version, api, format) + local Fixture = dofile(root .. "/tests/mart_content/fixture.lua") + local rows = {} + local V = require("src.core.GameVersion") + if V.generation(version) ~= 2 then + local f = Fixture.new(root, backend, version, api, format, "none") + f.T.eq(f.e.available, false, "mart contract unavailable outside Gen 2") + f.close() + return { { mode = "unavailable", available = false } } + end + for _, mode in ipairs({ + "none", + "replace", + "empty", + "remove", + "register", + "price", + "zero", + "maximum", + "delete", + "stack", + "rollback", + "invalid", + "no-mod", + }) do + local f = Fixture.new(root, backend, version, api, format, mode) + f.T.eq( + #f.result.errors > 0, + mode == "rollback" or mode == "invalid" or (mode == "delete" and api >= 2), + "mart entry outcome " .. mode + ) + local row = { mode = mode, failed = #f.result.errors > 0 } + local m = f.e and f.e.api.content.marts + if m then + row.stock = m:get("0") + row.bargain = m:get("bargain") + row.last = m:get("33") + local count = 0 + for id in m:each() do + f.T.check(id == "0" or id == "33" or id == "bargain", "enumeration excludes metadata") + count = count + 1 + end + row.count = count + if api == 3 and row.stock then + row.stock[1] = "CORRUPTED" + row.stock = m:get("0") + f.T.check(row.stock[1] ~= "CORRUPTED", "unified read detached") + end + end + row.money, row.countBought, row.sold, row.unitPrice = f.purchase(false, 2) + row.bargainMoney, row.bargainCount, row.bargainSold, row.bargainPrice = f.purchase(true, 1) + local native = f.shelf("0") + if mode == "empty" or mode == "remove" or (mode == "delete" and api == 1) then + f.T.same(native, {}, "empty and tombstoned shelves stay empty") + end + if mode == "replace" then + f.T.eq(row.money, 97600, "normal purchase uses item price") + f.T.eq(row.bargainMoney, 99500, "bargain has its own price") + f.T.eq(row.bargainCount, 1, "bargain item bought only once") + end + rows[#rows + 1] = row + f.close() + end + return rows +end +return C diff --git a/tests/mart_content/fixture.lua b/tests/mart_content/fixture.lua new file mode 100644 index 000000000..e9c59ec33 --- /dev/null +++ b/tests/mart_content/fixture.lua @@ -0,0 +1,126 @@ +local F = {} +function F.new(root, backend, version, api, format, mode, source) + local T, V = require("tests.modkit"), require("src.core.GameVersion") + V.set(version) + local gen, Json = V.generation(), require("src.link.Json") + local keys = { POTION = 18, ANTIDOTE = 9, ULTRA_BALL = 2, NUGGET = 36 } + local data = { items = {}, constants = { generation = gen }, text = {} } + local function key(id) + return backend == "gen2" and gen == 2 and ("ITEM_%03d"):format(keys[id]) or id + end + for id, index in pairs(keys) do + data.items[key(id)] = + { id = key(id), index = index, name = id:gsub("_", " "), price = 1200, pocket = "ITEM" } + end + local shelves = { + [0] = { key("POTION"), key("ANTIDOTE") }, + [33] = { key("POTION") }, + bargain = { { item = key("NUGGET"), price = 4500 } }, + } + if backend == "gen1" then + data.gen2Marts = { + generation = 2, + source = "fixture", + lists = { [1] = shelves[0], [34] = shelves[33] }, + bargain = shelves.bargain, + } + else + data.map_scripts = { marts = shelves } + end + local files, name = {}, "portable_mart_content" + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/" .. name .. "/" .. path, "rb")) + files["mods/" .. name .. "/" .. path] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/" .. name .. "/manifest.json"]) + manifest.api, manifest.games, manifest.content_format = api, { version }, format + manifest.gen2compat = api < 3 and true or nil + files["mods/" .. name .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. name .. "/case.txt"] = mode + if source then + files["mods/" .. name .. "/main.lua"] = "local mod=...; mod.exports.api=mod; " .. source + end + if mode == "stack" then + files["mods/mart_overlay/manifest.json"] = Json.encode({ + id = "mart_overlay", + name = "Mart overlay", + version = "1.0.0", + api = api, + games = { version }, + gen2compat = api < 3 and true or nil, + content_format = format, + dependencies = { name }, + entry = "main.lua", + }) + files["mods/mart_overlay/main.lua"] = 'local mod=...; mod.content.marts:patch("0", {"ANTIDOTE"})' + end + if mode == "no-mod" then + files = {} + end + local r = T.sdk.loadMods(mode == "no-mod" and {} or { "mods/" .. name }, { + data = data, + fs = T.sdk.memfs(files), + generation = gen, + dev = false, + }) + local f = { T = T, result = r, data = data, key = key, gen = gen, backend = backend, mode = mode } + f.e = r.loader.exports[name] + function f.shelf(id) + if backend == "gen1" then + local Mart = require("src.ui.gen2.MartMenu") + return id == "bargain" and Mart.bargainRows(data.gen2Marts) + or Mart.inventory(data.gen2Marts, tonumber(id)) + end + return require("src.inventory.Gen2MartStock").get(data, tonumber(id), id == "bargain" and 2 or 0) + end + function f.purchase(bargain, qty) + local save = { player = { money = 100000 }, money = 100000, inventory = {}, bagOrder = {} } + local game = { data = data, save = save } + local id, cost + if backend == "gen1" then + local Mart = require("src.ui.gen2.MartMenu") + local menu = Mart.new(game, { martId = 0, martType = bargain and "BARGAIN" or "STANDARD" }) + local entry = menu.entries[1] + if not entry then + return nil + end + id, cost = entry.id, entry.price * qty + menu.qtyItem, menu.qty = entry, qty + menu:completePurchase(cost) + if bargain then + menu:offerToBuy() + end + return save.player.money, save.inventory[id], (save.bargainShop or {})[id] == true, entry.price + end + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + local stock = f.shelf(bargain and "bargain" or "0") + if not stock or #stock == 0 then + return nil + end + local menu = require("src.ui.ShopMenu").new(game, stock, nil, { bargain = bargain }) + game.stack:push(menu) + if not bargain then + menu.items[1].onSelect() + menu = game.stack:top() + end + local entry = menu.items[1] + id = entry.value + menu.onChoose(entry) + local quantity = game.stack:top() + quantity.onDone(qty) + local choice = game.stack:top() + choice.onChoose(true) + if bargain then + menu.onChoose(entry) + end + local unit = bargain and stock[1].price or data.items[id].price + return save.money, save.inventory[id], (save.bargainShop or {})[id] == true, unit + end + function f.close() + r.release() + end + return f +end +return F diff --git a/tests/mart_content/worker.lua b/tests/mart_content/worker.lua new file mode 100644 index 000000000..fa7e958ae --- /dev/null +++ b/tests/mart_content/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/mart_content/cases.lua").run(root, backend, version, tonumber(api), format) +print("MART_CONTENT_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native mart content") diff --git a/tools/mart-content-contract-check/README.md b/tools/mart-content-contract-check/README.md new file mode 100644 index 000000000..97aaf5e08 --- /dev/null +++ b/tools/mart-content-contract-check/README.md @@ -0,0 +1,24 @@ +# Shared native mart contracts + +Run the unchanged public consumer through both real SDKs and actual native +mart readers/purchase paths. The source is pinned clean at Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, which introduces the mart registry. +Earlier contract families retain their own reference pins. + +```sh +python3 tools/mart-content-contract-check/check.py --gen1 /path/to/pinned/gen1 --api 3 --format unified --report /tmp/marts.json +``` + +Run API 1/native, API 2/native, API 2/unified and API 3/unified. Reference API 2 +has no unified opt-in; host API-2/unified is compared with its native behavior. +Each mode launches 12 isolated workers for Red, Gold, Silver, Crystal, FireRed +and Emerald. Gold/Silver/Crystal compare shelf records and actual purchases; +the other versions check the honest unavailable boundary. This is not a +claim of whole-game or pixel parity. Host fixtures use imported `ITEM_*` keys +while the reference uses common keys. Native imported Gold UI/VM execution +is a separate driver and does not require ROMs in CI. + +Cases cover unchanged/no-mod behavior, replacement, empty stock, removal, +registration, ordinary price composition, zero/maximum bargain prices, +ordered overlays, rollback and malformed lists. The scalar DELETE sentinel +is rejected by strict SDK schemas; native API 1 retains its warning path. diff --git a/tools/mart-content-contract-check/check.py b/tools/mart-content-contract-check/check.py new file mode 100644 index 000000000..2a1973838 --- /dev/null +++ b/tools/mart-content-contract-check/check.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare unchanged Gen-2 mart definitions and actual native purchases.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2 / "tests/mart_content/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("MART_CONTENT_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("MART_CONTENT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, "observations": rows}) + assert observations[0] == observations[1], f"mart content mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native mart content observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() From f96f8c84c9d88ed4e01021bdda30d9cd3b9ef16c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:29:51 +0200 Subject: [PATCH 60/86] Match absent mart reads and cover capability and generation boundaries --- src/mods/MartContent.lua | 2 +- tests/engine/mart_content.lua | 15 ++++++++++++++- tests/engine/mart_content_authority.lua | 16 ++++++++++++++++ tests/mart_content/cases.lua | 2 ++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/mods/MartContent.lua b/src/mods/MartContent.lua index 2ad13a609..4fde87ff9 100644 --- a/src/mods/MartContent.lua +++ b/src/mods/MartContent.lua @@ -157,7 +157,7 @@ function M.api(loader, mod, native, generation, unified) return nil, reason end if unified then - identifier(id) + assert(type(id) == "string" and id ~= "", "invalid content: registry id is required") end return convert(loader, id, native:get(id), false) end diff --git a/tests/engine/mart_content.lua b/tests/engine/mart_content.lua index f1f537409..439879c42 100644 --- a/tests/engine/mart_content.lua +++ b/tests/engine/mart_content.lua @@ -3,7 +3,20 @@ local T, V = require("tests.modkit"), require("src.core.GameVersion") local old = V.get() for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do for _, version in ipairs(V.ORDER) do - dofile("tests/mart_content/cases.lua").run(".", "gen2", version, mode[1], mode[2]) + if V.generation(version) == 2 then + dofile("tests/mart_content/cases.lua").run(".", "gen2", version, mode[1], mode[2]) + else + local f = dofile("tests/mart_content/fixture.lua").new(".", "gen2", version, mode[1], mode[2], "none") + local m = f.e.api.content.marts + T.eq(f.e.api:supports("content", "marts"), false, "honest unavailable generation") + T.eq(m:get("0"), nil, "unavailable get has no shelf") + T.eq(m:each()(), nil, "unavailable enumeration is empty") + local original = f.data.map_scripts.marts[0] + m:patch("0", { "ULTRA_BALL" }) + T.eq(next(f.result.loader.content.marts.ops), nil, "unavailable write adds no operation") + T.eq(f.data.map_scripts.marts[0], original, "unavailable write leaves native data intact") + f.close() + end end end V.set(old) diff --git a/tests/engine/mart_content_authority.lua b/tests/engine/mart_content_authority.lua index 28c406398..b5509da17 100644 --- a/tests/engine/mart_content_authority.lua +++ b/tests/engine/mart_content_authority.lua @@ -10,6 +10,7 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, local api, format = unpack(mode) local f = F.new(".", "gen2", "gold", api, format, "replace") local m = f.e.api.content.marts + T.eq(f.e.api:supports("content", "marts"), true, "public capability names the native registry") local pool = f.data.map_scripts.marts T.same(pool[0], { f.key("ULTRA_BALL") }, "public item ID reaches native pool key") T.eq(f.data.gen2Marts, nil, "virtual registry creates no foreign namespace") @@ -100,6 +101,21 @@ for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, T.same(forward.e.api.content.marts:get("0"), { "CUSTOM_MART" }, "forward public read uses common name") forward.close() end +-- An unrelated in-place edit after the merge is not owned by retirement. +do + local f = F.new(".", "gen2", "gold", 3, "unified", "replace") + local pool = f.data.map_scripts.marts + pool[0][1] = f.key("ANTIDOTE") + f.close() + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "items" and root ~= "map_scripts" then + f.data[root] = nil + end + end + local r = T.sdk.loadNone({ data = f.data }) + T.same(pool[0], { f.key("ANTIDOTE") }, "reload preserves unrelated in-place shelf edit") + r.release() +end -- Native script dispatch and ordinary UI input, including close/resume. do local f = F.new(".", "gen2", "gold", 3, "unified", "replace") diff --git a/tests/mart_content/cases.lua b/tests/mart_content/cases.lua index 5b8a8da0f..c2cdb707b 100644 --- a/tests/mart_content/cases.lua +++ b/tests/mart_content/cases.lua @@ -33,6 +33,8 @@ function C.run(root, backend, version, api, format) local row = { mode = mode, failed = #f.result.errors > 0 } local m = f.e and f.e.api.content.marts if m then + f.T.eq(m:get("34"), nil, "unknown string ID reads as absent") + f.T.eq(m:has("generation"), false, "metadata is not a shelf") row.stock = m:get("0") row.bargain = m:get("bargain") row.last = m:get("33") From fbb21ba43e64e35562a30d9f1f1aff8ba22d6aa6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:31:41 +0200 Subject: [PATCH 61/86] Probe absent shelves through reads supported by native API 1 and 2 --- tests/mart_content/cases.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mart_content/cases.lua b/tests/mart_content/cases.lua index c2cdb707b..8d356ec89 100644 --- a/tests/mart_content/cases.lua +++ b/tests/mart_content/cases.lua @@ -34,7 +34,7 @@ function C.run(root, backend, version, api, format) local m = f.e and f.e.api.content.marts if m then f.T.eq(m:get("34"), nil, "unknown string ID reads as absent") - f.T.eq(m:has("generation"), false, "metadata is not a shelf") + f.T.eq(m:get("generation"), nil, "metadata is not a shelf") row.stock = m:get("0") row.bargain = m:get("bargain") row.last = m:get("33") From e9cc3f58148a8dfb5c4cfa49f66f4494e8b66381 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:14:00 +0200 Subject: [PATCH 62/86] Add native shiny and gender roll hooks with shared identity contracts --- .github/workflows/pokemon-roll-contract.yml | 40 ++++ docs/api3-portability.md | 3 + docs/portable-pokemon-rolls.md | 60 ++++++ examples/portable_pokemon_rolls/main.lua | 116 +++++++++++ examples/portable_pokemon_rolls/manifest.json | 1 + src/battle/BattleState.lua | 2 +- src/mods/CallbackRecords.lua | 58 +++++- src/mods/Capabilities.lua | 1 + src/mods/Loader.lua | 3 + src/mods/UnifiedCallbacks.lua | 6 +- src/pokemon/DayCare.lua | 7 +- src/pokemon/IdentityRolls.lua | 106 ++++++++++ src/pokemon/Pokemon.lua | 45 ++++- src/ui/SummaryMenu.lua | 1 + tests/drivers/pokemon_rolls.lua | 117 +++++++++++ tests/engine/pokemon_identity_authority.lua | 101 +++++++++ tests/engine/pokemon_identity_rolls.lua | 10 + tests/pokemon_rolls/cases.lua | 191 ++++++++++++++++++ tests/pokemon_rolls/fixture.lua | 99 +++++++++ tests/pokemon_rolls/worker.lua | 6 + tools/pokemon-roll-contract-check/README.md | 28 +++ tools/pokemon-roll-contract-check/check.py | 52 +++++ 22 files changed, 1039 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/pokemon-roll-contract.yml create mode 100644 docs/portable-pokemon-rolls.md create mode 100644 examples/portable_pokemon_rolls/main.lua create mode 100644 examples/portable_pokemon_rolls/manifest.json create mode 100644 src/pokemon/IdentityRolls.lua create mode 100644 tests/drivers/pokemon_rolls.lua create mode 100644 tests/engine/pokemon_identity_authority.lua create mode 100644 tests/engine/pokemon_identity_rolls.lua create mode 100644 tests/pokemon_rolls/cases.lua create mode 100644 tests/pokemon_rolls/fixture.lua create mode 100644 tests/pokemon_rolls/worker.lua create mode 100644 tools/pokemon-roll-contract-check/README.md create mode 100644 tools/pokemon-roll-contract-check/check.py diff --git a/.github/workflows/pokemon-roll-contract.yml b/.github/workflows/pokemon-roll-contract.yml new file mode 100644 index 000000000..7ef8959a1 --- /dev/null +++ b/.github/workflows/pokemon-roll-contract.yml @@ -0,0 +1,40 @@ +name: portable Pokemon identity hooks +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pokemon-roll-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Validate native definitions and authority + run: | + luajit tests/engine/pokemon_identity_rolls.lua + luajit tests/engine/pokemon_identity_authority.lua + luajit tests/engine/mod_supports.lua + - name: Compare both SDKs in all four modes + run: | + mkdir -p /tmp/pokemon-roll + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 1 --format native --report /tmp/pokemon-roll/api1.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format native --report /tmp/pokemon-roll/api2-native.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format unified --report /tmp/pokemon-roll/api2-unified.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 3 --format unified --report /tmp/pokemon-roll/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: pokemon-roll-contract + path: /tmp/pokemon-roll + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 08a2cd5ae..21d5d1285 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -176,3 +176,6 @@ Gen-2 `mod.content.held_items` registry, native write-back and generation bounds See [portable mart definitions](portable-mart-content.md) for Gen-2 ordinary and bargain shelves, common item IDs, native purchases and shared contracts. + +See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for +`shiny.roll`, `gender.roll`, native DV/PID semantics and shared contracts. diff --git a/docs/portable-pokemon-rolls.md b/docs/portable-pokemon-rolls.md new file mode 100644 index 000000000..80c959d9f --- /dev/null +++ b/docs/portable-pokemon-rolls.md @@ -0,0 +1,60 @@ +# Portable Pokemon identity hooks + +`shiny.roll` and `gender.roll` wrap the native Gen-2/3 identity predicates. +API 3 supplies common species IDs and definition records; native API 1/2 +retain native IDs. Host API-2/unified is also supported. GB and DS report +both hooks unavailable through `mod:supports("hooks", name)`. + +```lua +local mod = ... +if mod:supports("hooks", "shiny.roll") then + mod.hooks:wrap("shiny.roll", function(next, ctx) + if ctx.species == "CHARMANDER" then return true end + return next(ctx) + end) +end +``` + +Both contexts carry `species` and `def`. Gen 2 carries the native `dvs` +(`attack`, `defense`, `speed`, `special`, derived `hp`) and construction/ +refresh `level`. Its bare breeding gender query has no level. Gen 3 carries +`personality`; shiny queries also carry `level`, `ivs`, `otId` (or the native +trainer-ID fallback) and optional `otSecretId`. Unified GBA IV keys follow +the reference: `hp`, `atk`, `def`, `spe`, `spa`, `spd`. IVs are a detached +query copy, so editing them does not edit the Pokemon. + +The gender context includes the informational ratio byte. To steer the +native threshold, edit `ctx.def.genderRatio` and delegate, or return a gender +directly. Changing `ctx.ratio` alone does not replace the definition's byte. +Gen-2 definition steering has the reference's live-definition scope; Gen-3 +metadata is call-local. Other unified definition metadata is read-only. + +Shiny results use Lua truthiness (including truthy zero); nil means false. +Gender returns `male`, `female` or `unknown`. Invalid gender returns fall +back to the native predicate. Gen 2 re-reads its current DV/definition state; +Gen 3 retains the native value captured before its hook. Its PID low byte +and the Gen-2 two-DV-byte comparison keep their native boundary rules. + +Gen 2 stores the construction results on the Pokemon. Its native summary +refresh re-evaluates gender and a false shiny result, while an already true +shiny remains true, matching the reference. Gen 3 evaluates gender at birth +and retains the native display cache; breeding/query reads remain separate +live evaluations. Unsubscribing stops callbacks but does not erase a native +cached value. Explicit cartridge-forced shininess bypasses the normal shiny +hook, including the native forced-wild construction path. + +Plain result overrides and Gen-3 queries do not change DV/PID/IV values or +draw RNG. Deliberate Gen-2 DV edits retain native write-back semantics; API-3 +replacement contexts/DV records retain the original native table identity. +Do not treat these mutation cases as pure read-only predicates. Unified +callback writes are committed through the existing call-local transaction; +pre-next failure and invalid threshold writes cannot partially edit the +engine. Retained views and late `next` calls cannot edit native state later. + +See `examples/portable_pokemon_rolls` and +`tools/pokemon-roll-contract-check/`. The shared tests use both real SDKs, +Gen-2 constructors/refresh/breeding and native GBA party creation/predicates. +They compare every outcome in the 65,536-case Gen-2 ratio/DV matrix rather +than relying only on an aggregate checksum. This is a native hook contract, +not a claim that every screen recomputes cached identity at the same time or +that all battle and save-format differences are resolved. diff --git a/examples/portable_pokemon_rolls/main.lua b/examples/portable_pokemon_rolls/main.lua new file mode 100644 index 000000000..e25a78092 --- /dev/null +++ b/examples/portable_pokemon_rolls/main.lua @@ -0,0 +1,116 @@ +local mod = ... +local e = mod.exports +e.api, e.mode, e.calls, e.observations = mod, mod:read("case.txt") or "force", {}, {} +local supported = mod.supports and mod:supports("hooks", "shiny.roll") + or (not mod.supports and (mod.generation == 2 or mod.generation == 3)) +e.available = supported == true +if not supported then + return +end +local function observe(name, ctx) + e.calls[name] = (e.calls[name] or 0) + 1 + e.observations[name] = { + species = ctx.species, + defId = ctx.def and ctx.def.id, + level = ctx.level, + ratio = ctx.ratio, + personality = ctx.personality, + ivs = ctx.ivs and { + hp = true, + atk = ctx.ivs.atk ~= nil, + def = ctx.ivs.def ~= nil, + spe = ctx.ivs.spe ~= nil, + spa = ctx.ivs.spa ~= nil, + spd = ctx.ivs.spd ~= nil, + }, + dvs = ctx.dvs and { + attack = ctx.dvs.attack, + defense = ctx.dvs.defense, + speed = ctx.dvs.speed, + special = ctx.dvs.special, + }, + } +end +local function wrap(name) + return mod.hooks:wrap(name, function(next, ctx) + observe(name, ctx) + local shiny = name == "shiny.roll" + if e.mode == "force" then + if shiny then + return true + else + return "female" + end + end + if e.mode == "suppress" then + if shiny then + return false + else + return "male" + end + end + if e.mode == "nil" then + return nil + end + if e.mode == "invalid" then + if shiny then + return 0 + else + return "invalid" + end + end + if e.mode == "error_before" then + error("intentional roll failure before next") + end + if e.mode == "invalid_threshold" and not shiny then + ctx.def.genderRatio = 254 + return "invalid" + end + if e.mode == "ratio_only" then + ctx.ratio = 254 + end + if e.mode == "new_threshold" and not shiny then + local replaced = {} + for k, v in pairs(ctx) do + replaced[k] = v + end + replaced.def = { genderRatio = 254 } + return next(replaced) + end + if e.mode == "iv_edit" and ctx.ivs then + ctx.ivs.hp = 31 + end + if e.mode == "replacement" then + local replaced = {} + for k, v in pairs(ctx) do + replaced[k] = v + end + if ctx.dvs then + replaced.dvs = { attack = 2, defense = 10, speed = 10, special = 10 } + else + replaced.personality = 0 + if shiny then + replaced.otId, replaced.otSecretId = 0, 0 + end + end + return next(replaced) + end + if e.mode == "steer" then + if ctx.dvs then + ctx.dvs.attack, ctx.dvs.defense, ctx.dvs.speed, ctx.dvs.special = 2, 10, 10, 10 + elseif shiny then + ctx.personality, ctx.otId, ctx.otSecretId = 0, 0, 0 + else + ctx.personality = 0 + end + elseif e.mode == "threshold" and not shiny then + ctx.def.genderRatio = 254 + end + local result = next(ctx) + if e.mode == "error_after" then + error("intentional roll failure after next") + end + return result + end) +end +e.stopShiny, e.stopGender = wrap("shiny.roll"), wrap("gender.roll") diff --git a/examples/portable_pokemon_rolls/manifest.json b/examples/portable_pokemon_rolls/manifest.json new file mode 100644 index 000000000..5ee8b754e --- /dev/null +++ b/examples/portable_pokemon_rolls/manifest.json @@ -0,0 +1 @@ +{"id":"portable_pokemon_rolls","name":"Portable Pokemon identity rolls","version":"1.0.0","api":3,"games":["gen2","gen3"],"entry":"main.lua"} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 447827c16..9a614b159 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1415,7 +1415,7 @@ function BattleState.newWild(game, species, level, opts) and game.data.pokemon[species] and game.data.pokemon[species].forms then form=require("src.pokemon.PolishedUnown").choose(game.save) end - local wild = Pokemon.new(game.data, species, level, nil, form) + local wild = Pokemon.new(game.data, species, level, nil, form, opts) -- SYNCHRONIZE'S NATURE AND CUTE CHARM'S GENDER, decided with the slot (see -- src/world/Gen4WildLead.lua) and applied the cartridge's way: personality -- re-drawn until it carries them. diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index f8a57a73e..c10c98fcf 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -141,6 +141,7 @@ local function array(t, label, max) end function R.new(loader, name, generation, api3) + local identityRoll = name == "shiny.roll" or name == "gender.roll" local ids = Ids.new(loader) local portable = api3 and Api3Records or nil local function readStatus(value) @@ -214,7 +215,10 @@ function R.new(loader, name, generation, api3) return out end local function fields(raw, kind) - if kind == "mon" then + if kind == "rollIvs" then + return { hp = raw.hp, atk = raw.attack, def = raw.defense, + spe = raw.speed, spa = raw.spatk, spd = raw.spdef } + elseif kind == "mon" then return { species = read("pokemon", raw.species), item = read("items", raw.item), @@ -245,9 +249,10 @@ function R.new(loader, name, generation, api3) end out.id = read("items", raw.id) return out - elseif kind == "move" or kind == "species" or kind == "item" then + elseif kind == "move" or kind == "species" or kind == "item" or kind == "identitySpecies" then local out = { native = copy(raw), name = raw.name } - local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local registryKind = (kind == "species" or kind == "identitySpecies") 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 @@ -258,7 +263,7 @@ function R.new(loader, name, generation, api3) end end out.id = read(registryKind, key) - if kind == "species" then + if kind == "species" or kind == "identitySpecies" then out.baseStats = statView(raw.baseStats) out.types = {} for i, t in ipairs(raw.types or {}) do @@ -267,6 +272,7 @@ function R.new(loader, name, generation, api3) out.baseExp = raw.baseExp or raw.expYield out.catchRate = raw.catchRate out.growthRate = raw.growthRate + if kind == "identitySpecies" then out.genderRatio = raw.genderRatio end elseif kind == "move" then for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do out[key] = raw[key] @@ -301,6 +307,9 @@ function R.new(loader, name, generation, api3) local out = shallow(raw) for k, v in pairs(raw) do local role = recordRoles[k] + if identityRoll and k == "def" then role = "identitySpecies" end + if identityRoll and (k == "dvs" or k == "ivs") then role = "context" end + if identityRoll and generation == 3 and k == "ivs" then role = "rollIvs" end if name == "held_item.trigger" and k == "def" then role = "heldItem" end if name == "world.interacted" and k == "target" then role = nil @@ -465,7 +474,7 @@ function R.new(loader, name, generation, api3) end return out end - restore = function(value, kind) + restore = function(value, kind, original) if type(value) ~= "table" then assert( value == nil or (kind == "encounter" and value == false), @@ -480,10 +489,18 @@ function R.new(loader, name, generation, api3) or kind == "encounter" or kind == "trainerMon" or kind == "candidates" - or kind == "trainerParty", + or kind == "trainerParty" + or (identityRoll and (kind == "context" or kind == "rollIvs" or kind == "identitySpecies")), "replacement " .. tostring(kind) .. " must be a bound record" ) - e = { public = value, kind = kind, raw = {}, before = {} } + local target = identityRoll and api3 and type(original) == "table" and original or nil + local previous = target and byRaw[target] and byRaw[target][kind] + e = { public = value, kind = kind, raw = target or {}, + before = previous and previous.before or {}, replacement = true } + if target then + byView[value] = e + entries[#entries + 1] = e + end end if visiting[e] then return e.raw @@ -504,6 +521,24 @@ function R.new(loader, name, generation, api3) end end end + elseif kind == "rollIvs" then + local aliases = { hp = "hp", atk = "attack", def = "defense", + spe = "speed", spa = "spatk", spd = "spdef" } + for k, v in pairs(value) do + assert(aliases[k], "unknown IV field") + if v ~= before[k] then stage(raw, aliases[k], integer(v, 0, 31, "IV")) end + end + for k in pairs(before) do assert(value[k] ~= nil, "IV field is required") end + elseif kind == "identitySpecies" then + local current, original = shallow(value), shallow(before) + current.genderRatio, original.genderRatio = nil, nil + if e.replacement then + for key in pairs(original) do if current[key] == nil then original[key] = nil end end + end + assert(equal(current, original), "species definition is read-only except genderRatio") + if value.genderRatio ~= before.genderRatio then + stage(raw, "genderRatio", integer(value.genderRatio, 0, 255, "gender ratio")) + end elseif kind == "move" or kind == "species" or kind == "item" or kind == "battler" then if kind == "battler" then assert( @@ -682,11 +717,14 @@ function R.new(loader, name, generation, api3) assert(equal(v, before[k]), "learnable is read-only") elseif v ~= before[k] then local role = recordRoles[k] + if identityRoll and k == "def" then role = "identitySpecies" end + if identityRoll and (k == "dvs" or k == "ivs") then role = "context" end + if identityRoll and generation == 3 and k == "ivs" then role = "rollIvs" end 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) + v = restore(v, role, identityRoll and api3 and old or nil) end stage(raw, k, v) end @@ -739,8 +777,8 @@ function R.new(loader, name, generation, api3) open = false end, view = view, - restore = function(v, kind) - return restore(v, kind) + restore = function(v, kind, original) + return restore(v, kind, original) end, commit = commit, refresh = refresh, diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 793a3bad1..b07d4377f 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -36,6 +36,7 @@ add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") add("hooks", "13", "item.use") add("hooks", "23", "held_item.trigger") +add("hooks", "23", "shiny.roll gender.roll") add("hooks", "123", "encounter.table battle.charge_required battle.caught_marker_visible battle.field_residual") add("hooks", "12", "intro.oak_speech.build") diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index daec6027c..9f8be7166 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1548,6 +1548,9 @@ function Loader:load(data) end) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on + -- Scope native query context to this bus; SDK/runtime restoration swaps + -- the whole bus, so no process-global dataset outlives its loader. + self.hooks.data = data Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 4928928a0..ab5e71e6c 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,6 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ + shiny.roll gender.roll held_item.trigger battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation battle.field_residual battle.caught_marker_visible battle.charge_required battle.accuracy battle.bottom_ui_visible 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 core.logic_speed core.update @@ -41,6 +42,8 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["shiny.roll"] = { "context" }, + ["gender.roll"] = { "context" }, ["held_item.trigger"] = { "context" }, ["battle.field_residual"] = { "context" }, ["battle.caught_marker_visible"] = { "context" }, @@ -176,7 +179,8 @@ function C.install(loader, api, owner, generation) if role == "items" or role == "moves" or role == "itemId" then v = boundary.native(role == "itemId" and "items" or role, v, original[i]) elseif role then - v = boundary.restore(v, role) + local identity = api3 and (name == "shiny.roll" or name == "gender.roll") + v = boundary.restore(v, role, identity and original[i] or nil) end out[i] = v end diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua index e5a1c46c6..b94bb233b 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -231,6 +231,11 @@ local DITTO = "SPECIES_132" -- is compared against, scaled by 16: below it the mon is female. function DayCare.gender(data, mon) if not (data and mon) then return nil end + local generation = require("src.core.GameVersion").generation() + if (generation == 2 or generation == 3) and require("src.mods.Runtime").wantsHook("gender.roll") then + local gender = require("src.pokemon.IdentityRolls").gender(data, mon, generation) + return gender == "unknown" and "none" or gender + end local def = data.pokemon and data.pokemon[mon.species] local ratio = def and def.genderRatio if ratio == nil then return nil end -- Gen1 import: no gender data @@ -247,7 +252,7 @@ function DayCare.gender(data, mon) return (ratio > (mon.personality % 256)) and "female" or "male" end local atk = (mon.dvs and mon.dvs.attack) or 0 - return (atk * 16 < ratio) and "female" or "male" + return (atk * 16 + ((mon.dvs or {}).speed or 0) <= ratio) and "female" or "male" end local function eggGroupsOf(data, mon) diff --git a/src/pokemon/IdentityRolls.lua b/src/pokemon/IdentityRolls.lua new file mode 100644 index 000000000..282ebc8f1 --- /dev/null +++ b/src/pokemon/IdentityRolls.lua @@ -0,0 +1,106 @@ +-- Shared hook results, with each generation's actual DV/PID predicate. +-- Native predicates draw no RNG; explicit Gen-2 context edits retain native +-- write-back rather than silently changing the saved identity on an override. +local Runtime = require("src.mods.Runtime") +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local R = {} +local GENDERS = { male = true, female = true, unknown = true } + +function R.data(data) + return data or Runtime.hooks.data +end + +local function definition(data, species, generation) + local def = data and data.pokemon and data.pokemon[species] + if generation ~= 3 or type(def) ~= "table" then + return def + end + -- GBA's species_hook_def is a call-local metadata record. Threshold + -- steering changes this evaluation, not the mounted species table. + local out = {} + for k, v in pairs(def) do + out[k] = v + end + out.id = species + return out +end + +function R.nativeGender(def, dvs, personality, generation) + local ratio = def and def.genderRatio + if ratio == nil or ratio == 255 then + return "unknown" + end + if ratio == 0 then + return "male" + end + if ratio == 254 then + return "female" + end + if generation == 3 then + return ratio > ((tonumber(personality) or 0) % 256) and "female" or "male" + end + local value = (dvs and dvs.attack or 0) * 16 + (dvs and dvs.speed or 0) + return value <= ratio and "female" or "male" +end + +function R.gender(data, mon, generation, level) + data = R.data(data) + local def = definition(data, mon.species, generation) + local fallback = R.nativeGender(def, mon.dvs, mon.personality, generation) + local function vanilla(c) + return R.nativeGender(c.def, c.dvs, c.personality, generation) + end + local ctx = { species = mon.species, def = def, ratio = def and def.genderRatio } + if generation == 2 then + ctx.dvs, ctx.level = mon.dvs, level + else + ctx.personality = mon.personality + end + local result = Runtime.call("gender.roll", vanilla, ctx) + if not GENDERS[result] then + return generation == 3 and fallback or R.nativeGender(def, mon.dvs, mon.personality, generation) + end + return result +end + +function R.gen2Shiny(mon, def) + local result = Runtime.call("shiny.roll", function(c) + return Stats.isShiny(c.dvs) + end, { dvs = mon.dvs, species = mon.species, def = def, level = mon.level }) + return not not result +end + +local function nativeShiny(personality, otId, otSecretId) + local p = (tonumber(personality) or 0) % 4294967296 + local full = (tonumber(otId) or 0) % 4294967296 + local tid = full % 65536 + local sid = (tonumber(otSecretId) or math.floor(full / 65536)) % 65536 + return bit.bxor(tid, sid, math.floor(p / 65536), p % 65536) < 8 +end + +function R.gen3Shiny(mon, data) + data = R.data(data) + local def = definition(data, mon.species, 3) + local ivs + if type(mon.ivs) == "table" then + ivs = {} + for k, v in pairs(mon.ivs) do + ivs[k] = v + end + end + local result = Runtime.call("shiny.roll", function(c) + return nativeShiny(c.personality, c.otId, c.otSecretId) + end, { + species = mon.species, + def = def, + level = mon.level, + personality = mon.personality, + ivs = ivs, + otId = mon.otId or mon.trainerId, + otSecretId = mon.otSecretId, + }) + return not not result +end + +return R diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 79c91e7fe..df2b3a809 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -173,7 +173,7 @@ function Pokemon.applySeed(data, mon, seed) return true end -function Pokemon.new(data, species, level, rng, form) +function Pokemon.new(data, species, level, rng, form, opts) local nativeForm=(data.constants or {}).gen==4 and (form or 0) or nil local speciesDef=(data.pokemon or {})[species] if require("src.core.GameVersion").get()=="polishedcrystal" and speciesDef and speciesDef.forms then @@ -241,10 +241,42 @@ function Pokemon.new(data, species, level, rng, form) -- meaning "in tall grass only". At the default setting shinyOddsOf answers -- nil and this returns immediately, leaving the cartridge's own 1/8192 -- exactly as it was. + local generation = require("src.core.GameVersion").generation() + local Runtime = require("src.mods.Runtime") + if generation == 2 then + if opts and opts.shiny then mon.shiny = true + elseif Runtime.wantsHook("shiny.roll") then + mon.shiny = require("src.pokemon.IdentityRolls").gen2Shiny(mon, def) + end + if Runtime.wantsHook("gender.roll") then + mon.gender = require("src.pokemon.IdentityRolls").gender(data, mon, 2, level) + end + elseif generation == 3 then + if Runtime.wantsHook("gender.roll") then + mon.gender = require("src.pokemon.IdentityRolls").gender(data, mon, 3) + end + if opts and opts.shiny then mon.isShiny, mon.shiny = true, true end + end Pokemon.rollShiny(data, nil, mon, rng) return mon end +-- Gen 2 refreshes identity at its summary/stat-refresh boundary. A true +-- shiny stays authoritative; a cached false and gender are read again. +function Pokemon.syncIdentity(data, mon) + if type(mon) ~= "table" or require("src.core.GameVersion").generation() ~= 2 then return mon end + local Runtime, Rolls = require("src.mods.Runtime"), require("src.pokemon.IdentityRolls") + local def = data and data.pokemon and data.pokemon[mon.species] + if not def then return mon end + if mon.gender ~= nil or Runtime.wantsHook("gender.roll") then + mon.gender = Rolls.gender(data, mon, 2, mon.level) + end + if mon.shiny ~= nil or Runtime.wantsHook("shiny.roll") then + mon.shiny = mon.shiny or Rolls.gen2Shiny(mon, def) + end + return mon +end + -- LoadEnemyMon .InitDVs (engine/battle/core.asm): a BATTLETYPE_SHINY -- encounter skips the random roll and writes the two fixed DV bytes -- ATKDEFDV_SHINY ($EA) and SPDSPCDV_SHINY ($AA) -- Attack 14, Defense 10, @@ -261,6 +293,9 @@ Pokemon.SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10, hp = -- player passes it here and the personality is moved for real at birth. function Pokemon.forceShiny(data, mon, rng, otId) if type(mon) ~= "table" then return mon end + local generation = require("src.core.GameVersion").generation() + if generation == 2 then mon.shiny = true end + if generation == 3 then mon.isShiny = true end -- A GEN 3 POKEMON IS NOT MADE SHINY BY ITS DVs, because it has none. Its -- personality and the trainer's id decide it, and Stats.shinyPersonality -- moves the half of the personality that decides nothing else -- so the @@ -379,6 +414,14 @@ end -- what an editor writes function Pokemon.isShiny(mon) if type(mon) ~= "table" then return false end + local generation = require("src.core.GameVersion").generation() + if generation == 2 and mon.shiny ~= nil then return not not mon.shiny end + if generation == 3 then + if mon.isShiny ~= nil then return not not mon.isShiny end + if require("src.mods.Runtime").wantsHook("shiny.roll") then + return require("src.pokemon.IdentityRolls").gen3Shiny(mon) + end + end -- PERSONALITY FIRST, and this order is the whole correctness of it: -- Pokemon.new rolls DVs for every Pokemon including Hoenn's, so a Gen 3 mon -- has BOTH fields and asking `dvs` first sent it down Johto's branch, where diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 69941f5f6..246a108ce 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -149,6 +149,7 @@ function SummaryMenu.new(game, mon, opts) -- SaveData.validate has run over a loaded save, but this is the site the -- original recomputes at, and it also covers a mon handed in by a mod. Stats.ensure(game.data.pokemon[mon.species], mon) + require("src.pokemon.Pokemon").syncIdentity(game.data, mon) local self = setmetatable({ game = game, mon = mon, diff --git a/tests/drivers/pokemon_rolls.lua b/tests/drivers/pokemon_rolls.lua new file mode 100644 index 000000000..bc0675d46 --- /dev/null +++ b/tests/drivers/pokemon_rolls.lua @@ -0,0 +1,117 @@ +-- Imported-data smoke: real constructors, summary screens and forced wild. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported version") + local gen = V.generation() + assert(gen == 2 or gen == 3, "identity hook smoke requires Gen 2/3") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + title.onNewGame() + U.wait(60) + local world = assert(game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_pokemon_rolls, "install public identity example") + assert(e.available, "identity hooks unavailable") + local P, DayCare = require("src.pokemon.Pokemon"), require("src.pokemon.DayCare") + local Copy = require("src.mods.Merge").deepCopy + local species + for id, def in pairs(game.data.pokemon) do + if def.name == "CHARMANDER" then + species = id + break + end + end + assert(species, "imported Charmander missing") + game.save.options.shinyOdds = false + e.mode = "force" + local sequence = { 15, 0, 0, 0, 1, 255 } + local i = 0 + local mon = P.new(game.data, species, 5, function(lo) + i = i + 1 + return sequence[i] or lo or 0 + end) + if gen == 3 then + mon.otId, mon.otSecretId = 0, 0 + end + local genes = { dvs = Copy(mon.dvs), ivs = Copy(mon.ivs), pid = mon.personality, stats = Copy(mon.stats) } + game.save.party = { mon } + assert(P.isShiny(mon), "native shiny reader ignored hook") + assert(P.genderOf(game.data, mon) == "female", "native birth gender ignored hook") + assert(e.observations["gender.roll"].species == "CHARMANDER", "species did not cross API-3 boundary") + assert(e.observations["gender.roll"].defId == "CHARMANDER", "definition ID is not portable") + local Screens = require("src.ui.Screens") + game.stack.states = { world } + Screens.push(game, "SummaryMenu", mon, {}) + U.wait(15) + assert(game.stack:top() ~= world, "native summary did not open") + assert(P.isShiny(mon), "summary lost shiny result") + assert(P.genderOf(game.data, mon) == "female", "summary lost birth gender") + U.tap(game, "b") + U.wait(10) + e.mode = "suppress" + if gen == 2 then + game.stack.states = { world } + Screens.push(game, "SummaryMenu", mon, {}) + U.wait(15) + assert(mon.gender == "male", "Gen-2 summary did not refresh gender") + assert(P.isShiny(mon), "Gen-2 summary lost authoritative true shiny") + U.tap(game, "b") + U.wait(10) + else + assert(P.genderOf(game.data, mon) == "female", "GBA display lost native birth cache") + assert(DayCare.gender(game.data, mon) == "male", "GBA native query did not evaluate hook") + assert(not P.isShiny(mon), "GBA native shiny query did not suppress") + end + local actual = { dvs = mon.dvs, ivs = mon.ivs, pid = mon.personality, stats = mon.stats } + local function equal(a, b) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + for k, v in pairs(a) do + if not equal(v, b[k]) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true + end + assert(equal(actual, genes), "plain overrides/summary changed genetics or stats") + local explicit = mon.isShiny + if gen == 3 then + mon.isShiny = true + end + local before = e.calls["shiny.roll"] or 0 + local battle = require("src.battle.BattleState").newWild(game, species, 5, { shiny = true }) + assert(P.isShiny(battle.enemy.mon), "native forced wild lost shiny precedence") + assert((e.calls["shiny.roll"] or 0) == before, "forced wild invoked ordinary shiny callback") + mon.isShiny = explicit + e.stopShiny() + e.stopGender() + local calls = Copy(e.calls) + if gen == 2 then + P.syncIdentity(game.data, mon) + end + P.isShiny(mon) + DayCare.gender(game.data, mon) + assert(equal(calls, e.calls), "retired native hooks still dispatched") + print("PORTABILITY_POKEMON_ROLL_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/pokemon_identity_authority.lua b/tests/engine/pokemon_identity_authority.lua new file mode 100644 index 000000000..6c6528b66 --- /dev/null +++ b/tests/engine/pokemon_identity_authority.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local F = dofile("tests/pokemon_rolls/fixture.lua") +local P, R, Copy = + require("src.pokemon.Pokemon"), require("src.mods.Runtime"), require("src.mods.Merge").deepCopy +local old = V.get() +for _, version in ipairs({ "gold", "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", "gen2", version, mode[1], mode[2], "delegate") + local gen = f.gen + T.eq(f.e.api:supports("hooks", "shiny.roll"), true, "native shiny capability") + T.eq(f.e.api:supports("hooks", "gender.roll"), true, "native gender capability") + local mon = f.make(false) + local genes = { dvs = Copy(mon.dvs), ivs = Copy(mon.ivs), pid = mon.personality, stats = Copy(mon.stats) } + f.e.mode = "force" + if gen == 2 then + P.syncIdentity(f.data, mon) + end + T.eq(f.shiny(mon), true, "actual native identity read follows public hook") + T.eq(f.gender(mon, true), "female", "actual native gender predicate follows public hook") + T.same( + { dvs = mon.dvs, ivs = mon.ivs, pid = mon.personality, stats = mon.stats }, + genes, + "pure steering preserves stats and genetic values" + ) + local cached = mon.gender + f.e.mode = "suppress" + if gen == 3 then + T.eq(P.genderOf(f.data, mon), cached, "GBA native display retains constructor gender cache") + T.eq(f.gender(mon, true), "male", "GBA predicate remains a separate live query") + else + P.syncIdentity(f.data, mon) + T.eq(mon.gender, "male", "Gen-2 summary refresh updates gender") + T.eq(P.isShiny(mon), true, "Gen-2 true shiny remains authoritative at refresh") + end + local calls = f.e.calls["shiny.roll"] or 0 + local forced = f.make(false, true) + T.eq(f.shiny(forced), true, "explicit forced shiny wins over suppressing hook") + T.eq(f.e.calls["shiny.roll"] or 0, calls, "forced path bypasses shiny callback") + f.e.stopShiny() + f.e.stopGender() + local counts = Copy(f.e.calls) + if gen == 2 then + P.syncIdentity(f.data, mon) + end + f.shiny(mon) + f.gender(mon, true) + T.same(f.e.calls, counts, "retired callbacks stop real producer dispatch") + f.close() + end +end +for _, format in ipairs({ { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", "gen2", "gold", format[1], format[2], "delegate") + local def = f.data.pokemon.SPECIES_004 + local ratio = def.genderRatio + local captured, nextSaved + local stop = f.e.api.hooks:wrap("gender.roll", function(next, ctx) + captured, nextSaved = ctx, next + ctx.dvs.attack = 0 + ctx.def.genderRatio = 254 + error("intentional pre-next identity transaction failure") + end, 100) + local mon = f.make(false) + T.eq(mon.dvs.attack, 15, "failed unified callback cannot change saved DVs") + T.eq(def.genderRatio, ratio, "failed unified callback cannot change definition threshold") + T.eq(mon.gender, "male", "failed unified callback delegates ordinary gender") + stop() + captured.def.genderRatio = 0 + captured.dvs.attack = 0 + T.eq(def.genderRatio, ratio, "retained detached context cannot edit engine data later") + T.eq(mon.dvs.attack, 15, "retained detached DVs cannot edit created mon later") + T.raises(function() + nextSaved() + end, "next is only valid", "late downstream call rejected") + local stopBad = f.e.api.hooks:wrap("gender.roll", function(next, ctx) + ctx.def.genderRatio = 256 + return next(ctx) + end, 100) + f.gender(mon, true) + T.eq(def.genderRatio, ratio, "invalid definition threshold rolls back before delegation") + stopBad() + f.close() +end +-- A true cartridge-forced wild encounter must skip the birth hook, too. +do + local f = F.new(".", "gen2", "gold", 3, "unified", "suppress") + f.game.save.party = { f.make(false) } + local before = f.e.calls["shiny.roll"] or 0 + local battle = require("src.battle.BattleState").newWild(f.game, "SPECIES_004", 5, { shiny = true }) + T.eq(P.isShiny(battle.enemy.mon), true, "native forced-wild path retains shininess") + T.eq(f.e.calls["shiny.roll"] or 0, before, "native forced-wild birth bypasses shiny hook") + f.close() +end +for _, version in ipairs({ "red", "platinum", "black" }) do + local f = F.new(".", "gen2", version, 3, "unified", "delegate") + T.eq(f.e.api:supports("hooks", "shiny.roll"), false, "unsupported shiny capability is honest") + T.eq(f.e.api:supports("hooks", "gender.roll"), false, "unsupported gender capability is honest") + f.close() +end +V.set(old) +T.finish("identity ownership, cached/native queries, forced wild and rollback") diff --git a/tests/engine/pokemon_identity_rolls.lua b/tests/engine/pokemon_identity_rolls.lua new file mode 100644 index 000000000..8e81c4bb7 --- /dev/null +++ b/tests/engine/pokemon_identity_rolls.lua @@ -0,0 +1,10 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local old = V.get() +for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, version in ipairs(V.ORDER) do + dofile("tests/pokemon_rolls/cases.lua").run(".", "gen2", version, mode[1], mode[2]) + end +end +V.set(old) +T.finish("native shiny.roll and gender.roll contracts") diff --git a/tests/pokemon_rolls/cases.lua b/tests/pokemon_rolls/cases.lua new file mode 100644 index 000000000..f7902b319 --- /dev/null +++ b/tests/pokemon_rolls/cases.lua @@ -0,0 +1,191 @@ +local C = {} +function C.run(root, backend, version, api, format) + local Fixture = dofile(root .. "/tests/pokemon_rolls/fixture.lua") + local V = require("src.core.GameVersion") + local gen = V.generation(version) + if gen ~= 2 and gen ~= 3 then + local f = Fixture.new(root, backend, version, api, format, "delegate") + f.T.eq(f.e.available, false, "identity hooks unavailable outside Gen 2/3") + f.close() + return { { mode = "unavailable", available = false } } + end + local rows = {} + for _, mode in ipairs({ + "delegate", + "force", + "suppress", + "nil", + "invalid", + "steer", + "replacement", + "iv_edit", + "threshold", + "invalid_threshold", + "ratio_only", + "new_threshold", + "error_before", + "error_after", + "rollback", + "no-mod", + }) do + for _, natural in ipairs({ false, true }) do + local f = Fixture.new(root, backend, version, api, format, mode) + f.T.eq(#f.result.errors > 0, mode == "rollback", "identity entry outcome") + local mon = f.make(natural) + local before = { + personality = mon.personality, + ivs = f.copy(mon.ivs), + dvs = f.copy(mon.dvs), + otId = mon.otId, + otSecretId = mon.otSecretId, + } + local queryRandomCalls = 0 + local randomMath, randomLove = math.random, love.math.random + math.random = function(...) + queryRandomCalls = queryRandomCalls + 1 + return randomMath(...) + end + love.math.random = function(...) + queryRandomCalls = queryRandomCalls + 1 + return randomLove(...) + end + local rngBefore = backend == "gen1" and gen == 3 and f.copy(require("src.core.game3.rng").getState()) + or nil + local shiny, gender = f.shiny(mon), f.gender(mon) + math.random, love.math.random = randomMath, randomLove + if rngBefore then + f.T.same( + require("src.core.game3.rng").getState(), + rngBefore, + "native GBA RNG state unchanged by identity queries" + ) + end + f.T.eq(queryRandomCalls, 0, "identity queries do not draw random numbers") + local row = { + mode = mode, + natural = natural, + shiny = shiny, + gender = gender, + birthGender = ({ M = "male", F = "female", U = "unknown" })[mon.gender] or mon.gender, + } + if mode == "force" then + f.T.eq(shiny, true, "hook forces actual shiny read") + f.T.eq(gender, "female", "hook forces actual gender read") + end + if mode == "suppress" then + f.T.eq(shiny, false, "hook suppresses actual shiny read") + f.T.eq(gender, "male", "hook suppresses actual gender read") + end + if mode == "invalid" then + f.T.eq(shiny, true, "Lua zero is truthy for shiny") + f.T.eq(gender, natural and "female" or "male", "invalid gender falls back to native") + end + if mode == "nil" then + f.T.eq(shiny, false, "nil shiny becomes false") + f.T.eq(gender, natural and "female" or "male", "nil gender falls back to native") + end + if mode == "new_threshold" or mode == "threshold" then + f.T.eq(gender, "female", "definition threshold reaches native gender predicate") + end + if mode == "invalid_threshold" then + f.T.eq( + gender, + gen == 2 and "female" or natural and "female" or "male", + "invalid gender fallback keeps native generation timing" + ) + end + if mode == "ratio_only" then + f.T.eq( + gender, + natural and "female" or "male", + "ratio metadata alone does not replace definition threshold" + ) + end + if mode == "steer" or mode == "replacement" then + f.T.eq(shiny, true, "DV/PID context can steer native shiny predicate") + f.T.eq(gender, "female", "DV/PID context can steer native gender predicate") + end + local after = { + personality = mon.personality, + ivs = f.copy(mon.ivs), + dvs = f.copy(mon.dvs), + otId = mon.otId, + otSecretId = mon.otSecretId, + } + f.T.same(after, before, "identity queries do not mutate saved genes") + local e = f.e + if not (e and e.calls) then + row.birthGender = nil + end + if e and e.calls then + row.shinyCalls, row.genderCalls = e.calls["shiny.roll"] or 0, e.calls["gender.roll"] or 0 + if api == 3 then + local obs = e.observations["gender.roll"] + f.T.eq(obs.species, "CHARMANDER", "API-3 species ID is common") + f.T.eq(obs.defId, "CHARMANDER", "API-3 definition ID is common") + row.genderContext = obs + row.shinyContext = e.observations["shiny.roll"] + end + e.stopShiny() + e.stopGender() + local calls = f.copy(e.calls) + if gen == 2 then + if backend == "gen1" then + require("src.battle.gen2.Mon").syncIdentity(mon, f.data) + else + require("src.pokemon.Pokemon").syncIdentity(f.data, mon) + end + end + row.retiredShiny, row.retiredGender = f.shiny(mon), f.gender(mon, true) + f.T.same(e.calls, calls, "unsubscribe stops later query and refresh dispatch") + end + rows[#rows + 1] = row + f.close() + end + end + for _, forced in ipairs({ true, false }) do + local f = Fixture.new(root, backend, version, api, format, forced and "suppress" or "force") + local mon = f.make(false, forced) + local value = f.shiny(mon) + f.T.eq(value, gen == 2 and true or forced, "forced shiny precedence follows native generation") + rows[#rows + 1] = + { mode = "forced", forced = forced, shiny = value, shinyCalls = f.e.calls["shiny.roll"] or 0 } + f.close() + end + if version == "gold" then + local f = Fixture.new(root, backend, version, api, format, "no-mod") + local def = f.data.pokemon[backend == "gen1" and "CHARMANDER" or "SPECIES_004"] + local native = backend == "gen1" and require("src.battle.gen2.Mon") or require("src.pokemon.DayCare") + local bit = require("bit") + local hash, female, male, unknown = 0, 0, 0, 0 + local outcomes = {} + for ratio = 0, 255 do + def.genderRatio = ratio + for attack = 0, 15 do + for speed = 0, 15 do + local dvs = { attack = attack, speed = speed } + local g = backend == "gen1" and native.gender(def, dvs) + or native.gender(f.data, { species = "SPECIES_004", dvs = dvs }) + local code = g == "female" and 1 or g == "male" and 2 or 3 + female = female + (code == 1 and 1 or 0) + male = male + (code == 2 and 1 or 0) + unknown = unknown + (code == 3 and 1 or 0) + hash = bit.bxor(bit.rol(hash, 5), code) + outcomes[#outcomes + 1] = tostring(code) + end + end + end + rows[#rows + 1] = { + mode = "native_gender_matrix", + hash = hash, + female = female, + male = male, + unknown = unknown, + cases = 65536, + outcomes = table.concat(outcomes), + } + f.close() + end + return rows +end +return C diff --git a/tests/pokemon_rolls/fixture.lua b/tests/pokemon_rolls/fixture.lua new file mode 100644 index 000000000..85d40ecb3 --- /dev/null +++ b/tests/pokemon_rolls/fixture.lua @@ -0,0 +1,99 @@ +local F = {} +function F.new(root, backend, version, api, format, mode) + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + mode == "no-mod", + mode == "rollback", + "portable_pokemon_rolls", + function(data, gen) + if backend == "gen2" then + data.constants.generation = gen + local def = data.pokemon.CHARMANDER + def.genderRatio = gen == 3 and 31 or 127 + if gen == 3 then + def.baseStats = { hp = 80, attack = 50, defense = 50, speed = 50, spatk = 50, spdef = 50 } + def.evYield = { hp = 0, attack = 0, defense = 0, speed = 0, spatk = 0, spdef = 0 } + end + def.id = "SPECIES_004" + data.pokemon.SPECIES_004, data.pokemon.CHARMANDER = def, nil + elseif gen == 3 then + data.gen3Pokemon._speciesMeta[4].genderRatio = 31 + end + end, + function(files) + local J = require("src.link.Json") + local base = "mods/portable_pokemon_rolls/" + local manifest = J.decode(files[base .. "manifest.json"]) + manifest.games = { version } + manifest.content_format = format + files[base .. "manifest.json"] = J.encode(manifest) + files[base .. "case.txt"] = mode + end + ) + f.copy = require("src.mods.Merge").deepCopy + function f.make(shiny, forced) + local dvs = shiny and { attack = 2, defense = 10, speed = 10, special = 10 } + or { attack = 15, defense = 0, speed = 0, special = 0 } + if backend == "gen1" and f.gen == 2 then + return require("src.battle.gen2.Mon").new(f.data, "CHARMANDER", 5, { dvs = dvs, shiny = forced }) + elseif backend == "gen1" and f.gen == 3 then + local session = + { party = {}, name = "PROBE", version = version, trainerId = 0, secretId = 0, mapSec = 1 } + local ok, _, mon = require("src.core.game3.party").giveMon( + session, + 4, + 5, + "", + { fixedPersonality = shiny and 0 or 65791 } + ) + assert(ok and mon, "native GBA Party.giveMon failed") + if forced ~= nil then + mon.isShiny = forced + end + return mon + end + local sequence = { dvs.attack, dvs.defense, dvs.speed, dvs.special } + if f.gen == 3 then + sequence[5], sequence[6] = shiny and 0 or 1, shiny and 0 or 255 + end + local index = 0 + local mon = require("src.pokemon.Pokemon").new(f.data, "SPECIES_004", 5, function(lo) + index = index + 1 + return sequence[index] or lo or 0 + end, nil, { shiny = forced }) + if f.gen == 3 then + mon.otId, mon.otSecretId = 0, 0 + if forced ~= nil then + mon.isShiny = forced + end + end + return mon + end + function f.shiny(mon) + if backend == "gen1" then + if f.gen == 2 then + return mon.shiny + end + return require("src.core.game3.pokemon").isShiny(mon) + end + return require("src.pokemon.Pokemon").isShiny(mon) + end + function f.gender(mon, query) + if backend == "gen1" then + if f.gen == 2 then + return query and require("src.core.gen2.Breeding").genderOf(f.data, mon) or mon.gender + end + local g = require("src.core.game3.pokemon").gender(mon.species, mon.personality) + return ({ M = "male", F = "female", U = "unknown" })[g] + end + local g = (query or f.gen == 3) and require("src.pokemon.DayCare").gender(f.data, mon) + or require("src.pokemon.Pokemon").genderOf(f.data, mon) + return g == "none" and "unknown" or g + end + return f +end +return F diff --git a/tests/pokemon_rolls/worker.lua b/tests/pokemon_rolls/worker.lua new file mode 100644 index 000000000..9c976fc28 --- /dev/null +++ b/tests/pokemon_rolls/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/pokemon_rolls/cases.lua").run(root, backend, version, tonumber(api), format) +print("POKEMON_ROLL_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native Pokemon identity rolls") diff --git a/tools/pokemon-roll-contract-check/README.md b/tools/pokemon-roll-contract-check/README.md new file mode 100644 index 000000000..5baf53e24 --- /dev/null +++ b/tools/pokemon-roll-contract-check/README.md @@ -0,0 +1,28 @@ +# Shared native Pokemon identity contracts + +Use a clean Gen1Recomp checkout pinned at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +python3 tools/pokemon-roll-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified --report /tmp/identity.json +``` + +Run API 1/native, API 2/native, API 2/unified and API 3/unified. Each mode +starts 12 isolated workers across Red, Gold, Silver, Crystal, FireRed and +Emerald. Reference API 2 remains native; host API-2/unified is compared with +that native behavior. API 3 is the common unified callback boundary. + +The unchanged public consumer exercises actual Gen-2 Mon.new, identity +refresh and breeding queries and GBA Party.giveMon/Pokemon predicates. +The host uses imported-style species keys. No producer is replaced with a +Runtime.call stub. Cases cover overrides, delegation, nil/truthy/invalid +returns, DV/PID steering and replacement, IV isolation, threshold scope and +fallback timing, failure before/after next, entry rollback, native forced +values, cached birth gender, retirement and query RNG/identity preservation. + +The Gold worker additionally records all 65,536 native ratio/attack/speed +outcomes as an exact ordered string. Both engines' strings must match; +counts/checksum alone do not decide parity. Red verifies unavailable hooks. +The tests are ROM-free. Imported Gold/FireRed/Emerald UI/forced-wild tests +are separate native driver checks; no full playthrough or pixel equality is +implied. diff --git a/tools/pokemon-roll-contract-check/check.py b/tools/pokemon-roll-contract-check/check.py new file mode 100644 index 000000000..adec515f4 --- /dev/null +++ b/tools/pokemon-roll-contract-check/check.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare unchanged Gen-2/3 shiny and gender hooks through native identity producers.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2 / "tests/pokemon_rolls/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("POKEMON_ROLL_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("POKEMON_ROLL_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, "observations": rows}) + assert observations[0] == observations[1], f"Pokemon identity roll mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native Pokemon identity roll observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() From 049217dbe3a1096bc6fd0544dbd4febb101194f1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:23:26 +0200 Subject: [PATCH 63/86] Read split and aliased trainer identities consistently without hooks --- docs/portable-pokemon-rolls.md | 4 +++- src/pokemon/IdentityRolls.lua | 1 + src/pokemon/Pokemon.lua | 6 ++++++ tests/pokemon_rolls/cases.lua | 23 +++++++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/portable-pokemon-rolls.md b/docs/portable-pokemon-rolls.md index 80c959d9f..38db2ee75 100644 --- a/docs/portable-pokemon-rolls.md +++ b/docs/portable-pokemon-rolls.md @@ -19,7 +19,9 @@ Both contexts carry `species` and `def`. Gen 2 carries the native `dvs` (`attack`, `defense`, `speed`, `special`, derived `hp`) and construction/ refresh `level`. Its bare breeding gender query has no level. Gen 3 carries `personality`; shiny queries also carry `level`, `ivs`, `otId` (or the native -trainer-ID fallback) and optional `otSecretId`. Unified GBA IV keys follow +trainer-ID fallback) and optional `otSecretId`. The native unhooked GBA +reader also handles split secret IDs and the `trainerId` alias; installing a +delegating hook is not required for those explicit identity layouts. Unified GBA IV keys follow the reference: `hp`, `atk`, `def`, `spe`, `spa`, `spd`. IVs are a detached query copy, so editing them does not edit the Pokemon. diff --git a/src/pokemon/IdentityRolls.lua b/src/pokemon/IdentityRolls.lua index 282ebc8f1..5c8d13967 100644 --- a/src/pokemon/IdentityRolls.lua +++ b/src/pokemon/IdentityRolls.lua @@ -78,6 +78,7 @@ local function nativeShiny(personality, otId, otSecretId) local sid = (tonumber(otSecretId) or math.floor(full / 65536)) % 65536 return bit.bxor(tid, sid, math.floor(p / 65536), p % 65536) < 8 end +R.nativeGen3Shiny = nativeShiny function R.gen3Shiny(mon, data) data = R.data(data) diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index df2b3a809..0663fab24 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -421,6 +421,12 @@ function Pokemon.isShiny(mon) if require("src.mods.Runtime").wantsHook("shiny.roll") then return require("src.pokemon.IdentityRolls").gen3Shiny(mon) end + -- Split trainer IDs and the reference's trainerId alias are explicit + -- identity data too; a delegating hook must not be their only reader. + if mon.otSecretId ~= nil or (mon.otId == nil and mon.trainerId ~= nil) then + return require("src.pokemon.IdentityRolls").nativeGen3Shiny( + mon.personality, mon.otId or mon.trainerId, mon.otSecretId) or mon.shiny == true + end end -- PERSONALITY FIRST, and this order is the whole correctness of it: -- Pokemon.new rolls DVs for every Pokemon including Hoenn's, so a Gen 3 mon diff --git a/tests/pokemon_rolls/cases.lua b/tests/pokemon_rolls/cases.lua index f7902b319..db576b63b 100644 --- a/tests/pokemon_rolls/cases.lua +++ b/tests/pokemon_rolls/cases.lua @@ -152,6 +152,29 @@ function C.run(root, backend, version, api, format) { mode = "forced", forced = forced, shiny = value, shinyCalls = f.e.calls["shiny.roll"] or 0 } f.close() end + if gen == 3 then + local edges = { + { pid = 0, ot = 0, yes = true }, + { pid = 7, ot = 0, yes = true }, + { pid = 8, ot = 0, yes = false }, + { pid = 65536, ot = 65536, yes = true }, + { pid = 65536, ot = 0, sid = 1, yes = true }, + { pid = 0, ot = 0, sid = 65535, yes = false }, + { pid = 0, trainer = 7, yes = true }, + { pid = 4294967295, ot = 0, yes = true }, + } + for _, mode in ipairs({ "no-mod", "delegate" }) do + for index, edge in ipairs(edges) do + local f = Fixture.new(root, backend, version, api, format, mode) + local mon = f.make(false) + mon.personality, mon.otId, mon.otSecretId, mon.trainerId = edge.pid, edge.ot, edge.sid, edge.trainer + local value = f.shiny(mon) + f.T.eq(value, edge.yes, "native GBA shiny XOR threshold and trainer-ID layouts") + rows[#rows + 1] = { mode = "gba_trainer_identity", policy = mode, case = index, shiny = value } + f.close() + end + end + end if version == "gold" then local f = Fixture.new(root, backend, version, api, format, "no-mod") local def = f.data.pokemon[backend == "gen1" and "CHARMANDER" or "SPECIES_004"] From 0662753b4fa4b5fbfd9086accfe7398152e55141 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:59:13 +0200 Subject: [PATCH 64/86] Unify script-text sources and native Gen-2 text IDs for API 3 --- .github/workflows/text-source-contract.yml | 37 +++ docs/api3-text-sources.md | 75 ++++++ examples/portable_text_sources/main.lua | 39 ++++ examples/portable_text_sources/manifest.json | 8 + src/mods/Loader.lua | 2 + src/mods/TextContent.lua | 226 +++++++++++++++++++ src/mods/UnifiedContent.lua | 3 + tests/drivers/text_sources.lua | 81 +++++++ tests/engine/text_source_authority.lua | 132 +++++++++++ tests/engine/text_sources.lua | 10 + tests/text_sources/cases.lua | 71 ++++++ tests/text_sources/corpus.lua | 74 ++++++ tests/text_sources/fixture.lua | 165 ++++++++++++++ tests/text_sources/worker.lua | 6 + tools/text-source-contract-check/README.md | 14 ++ tools/text-source-contract-check/check.py | 56 +++++ 16 files changed, 999 insertions(+) create mode 100644 .github/workflows/text-source-contract.yml create mode 100644 docs/api3-text-sources.md create mode 100644 examples/portable_text_sources/main.lua create mode 100644 examples/portable_text_sources/manifest.json create mode 100644 src/mods/TextContent.lua create mode 100644 tests/drivers/text_sources.lua create mode 100644 tests/engine/text_source_authority.lua create mode 100644 tests/engine/text_sources.lua create mode 100644 tests/text_sources/cases.lua create mode 100644 tests/text_sources/corpus.lua create mode 100644 tests/text_sources/fixture.lua create mode 100644 tests/text_sources/worker.lua create mode 100644 tools/text-source-contract-check/README.md create mode 100644 tools/text-source-contract-check/check.py diff --git a/.github/workflows/text-source-contract.yml b/.github/workflows/text-source-contract.yml new file mode 100644 index 000000000..9025c2d3c --- /dev/null +++ b/.github/workflows/text-source-contract.yml @@ -0,0 +1,37 @@ +name: portable script-text sources +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: text-source-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native readers and ownership + run: | + luajit tests/engine/text_sources.lua + luajit tests/engine/text_source_authority.lua + - name: Compare unchanged public consumer through both SDKs + run: | + mkdir -p /tmp/text-source-contract + python3 tools/text-source-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/text-source-contract/api3.json + python3 tools/text-source-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/text-source-contract/host-api2-unified.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: text-source-contract + path: /tmp/text-source-contract + if-no-files-found: ignore diff --git a/docs/api3-text-sources.md b/docs/api3-text-sources.md new file mode 100644 index 000000000..39921d911 --- /dev/null +++ b/docs/api3-text-sources.md @@ -0,0 +1,75 @@ +# Portable API-3 script text + +API 3, and API 2 with `content_format: "unified"`, expose `mod.content.text` +source strings for GB, Gen 2 and the registered GBA games. API 1/2 native mode +retains its existing text keys and bytes. DS text handles retain their existing +native behavior; this change does not claim a shared DS printer. + +```lua +local mod = ... +mod.content.text:register("MOD_GREETING", "Hello, {PLAYER}!\\nWelcome.\\pGood luck!") +``` + +Source controls are newline (`\n`), paragraph (`\f`) and scroll (`\v`). The +literal escape pairs `\\n`, `\\p`/`\\f` and `\\l` are accepted too. Reads return +source strings with actual control characters. Percent signs remain literal; +this registry is not a printf formatter. Carriage returns and raw byte 252 +printer controls cannot round-trip through the shared source language and +return `nil, "unsupported: ..."` on writes. Invalid value types throw. + +GBA source uses `{PLAYER}`, `{RIVAL}` and `{STR_VAR_1}` through `{STR_VAR_3}`. +The adapter writes the latter to the host's native `{VAR1}` through `{VAR3}`, +so the existing script buffer commands and textbox token handlers consume them. +Reads reverse those aliases. Other public GBA cartridge placeholder and symbol +names round-trip with the version's grammar. Unknown placeholders and printer +style commands are refused, rather than accidentally installing a missing token. +The source contract does not promise matching glyph artwork or pixel layout. +GB/Gen-2 tokens retain their own native grammar and registered handlers. + +## Native script IDs + +Gen-2 text IDs use the reference's lowercase hexadecimal `bank:address` form, +for example `55:4067`. The host VM stores that same script text as +`TEXT_S55_4067`. Unified reads, writes, removes and enumeration map to that +physical record. They do not create an unused `data.text["55:4067"]` shadow. +Existing host native IDs are still accepted. Two distinct records occupying +both spellings are an ambiguity, even if their current contents match; the +adapter refuses to guess which one a mod meant. + +New authored labels remain usable from authored scripts. **This is not yet +`mod.content.rom_text`**: named GBA cartridge label aliases and engine/battle/menu +prose bindings require the next, separate registry adapter. Existing Gen-2 host +text still shares a native table with prose. This PR does not advertise that +all Gen1 cartridge text labels already resolve on every host game. + +The ordinary registry journal retains load-order, failure rollback, tombstones, +and frozen-after-load writes. Unified operations on a canonical and native +Gen-2 alias fold into the same ordered registry record. On reloading the same +mounted host dataset, successful adapter writes retire only while their exact +installed bytes and table identity remain present. External edits and unrelated +keys survive. The retirement journal is scoped to the dataset and does not +change untouched native API-1/2 text loads. + +## Verification + +```sh +luajit tests/engine/text_sources.lua +luajit tests/engine/text_source_authority.lua +python3 tools/text-source-contract-check/check.py --gen1 /path/to/clean/gen1 +python3 tools/text-source-contract-check/check.py --gen1 /path/to/clean/gen1 --host-api 2 +``` + +The checker pins Gen1Recomp to `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +Both SDKs load the same public example and corpus without engine imports in the +consumer. API-2 host opt-in is compared with the reference's API-3 source +contract; reference API 2 remains native, so these are not mislabeled as the +same API-2 format. Real readers include Gen-2 VM text, host lowered +`writetext`/ScriptRunner/TextBox, and GBA VM text/TextIR and host GBA commands. +The grammar corpus checks storage/source equality independently of presentation; +it does not run every symbol through every game's full UI. + +`tests/drivers/text_sources.lua` is the additional imported-data LÖVE driver. +Use the isolated combined-fork launcher and temporarily install the example; +for Gold, supply `id.txt` containing an existing cartridge `bank:address` ID. +The driver opens, renders and closes real native dialogue, using an in-memory +New Game. It performs no normal SAVE and makes no full-playthrough claim. diff --git a/examples/portable_text_sources/main.lua b/examples/portable_text_sources/main.lua new file mode 100644 index 000000000..173affc8b --- /dev/null +++ b/examples/portable_text_sources/main.lua @@ -0,0 +1,39 @@ +-- One source file for both engines: no engine imports or generation branches. +local mod = ... +local text = mod.content.text +mod.exports.api = mod +local mode = mod:read("case.txt") or "example" +local id = mod:read("id.txt") or "PORTABLE_TEXT" +local source = mod:read("source.txt") or "Hello, {PLAYER}!\\nA portable dialogue.\\pSee you!" +mod.exports.before = text:get(id) +if mode == "corpus" then + mod.exports.corpus = {} + local i = 1 + while mod:read("corpus/" .. i) do + local key = "CORPUS_" .. i + text:override(key, "seed") + local value, reason = text:override(key, mod:read("corpus/" .. i)) + mod.exports.corpus[i] = { accepted = reason == nil, value = value, read = text:get(key) } + i = i + 1 + end +elseif mode == "remove" then + text:remove(id) +elseif mode == "delete" then + text:override(id, mod.DELETE) +elseif mode == "patch-delete" then + text:patch(id, mod.DELETE) +elseif mode == "patch" then + mod.exports.result, mod.exports.reason = text:patch(id, source) +elseif mode == "register" or mode == "example" then + mod.exports.result, mod.exports.reason = text:register(id, source) +else + mod.exports.result, mod.exports.reason = text:override(id, source) +end +mod.exports.after = text:get(id) +mod.exports.ids = {} +for key in text:each() do + mod.exports.ids[#mod.exports.ids + 1] = key +end +if mode == "rollback" then + error("intentional portable text rollback") +end diff --git a/examples/portable_text_sources/manifest.json b/examples/portable_text_sources/manifest.json new file mode 100644 index 000000000..1f0b598d8 --- /dev/null +++ b/examples/portable_text_sources/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_text_sources", + "name": "Portable text sources", + "version": "1.0.0", + "api": 3, + "games": ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"], + "entry": "main.lua" +} diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 9f8be7166..915c316b7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1410,6 +1410,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.mods.TextContent").prepare(self, data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1595,6 +1596,7 @@ function Loader:load(data) -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so -- the namespaces that appear are exactly the ones with content behind them. + require("src.mods.TextContent").capture(self, data) for _, name in ipairs(self:_mergeOrder()) do local registry = self.content[name] local spec = registry.spec diff --git a/src/mods/TextContent.lua b/src/mods/TextContent.lua new file mode 100644 index 000000000..15dd08b37 --- /dev/null +++ b/src/mods/TextContent.lua @@ -0,0 +1,226 @@ +-- API-3 source text over the host's native string registry. Native API 1/2 +-- keeps its keys and bytes; the VM continues reading its original table. +local Merge = require("src.mods.Merge") +local Text = {} +local previous = setmetatable({}, { __mode = "k" }) +local BREAKS = { n = "\n", p = "\f", f = "\f", l = "\v" } +local function set(words) + local out = {} + for word in words:gmatch("%S+") do + out[word] = true + end + return out +end +-- Public cartridge placeholder/symbol names, not cartridge prose. Validation +-- mirrors the source-language boundary, not the permissive native token bus. +local PH = + set("UNKNOWN PLAYER STR_VAR_1 STR_VAR_2 STR_VAR_3 KUN RIVAL VERSION AQUA MAGMA ARCHIE MAXIE KYOGRE GROUDON") +local TAG = set([[ + A_BUTTON B_BUTTON DOWN_ARROW DPAD_ANY DPAD_DOWN DPAD_LEFT DPAD_LEFTRIGHT DPAD_RIGHT DPAD_UP DPAD_UPDOWN + EMOJI_ACUTE EMOJI_ANGRY EMOJI_AT EMOJI_BALL EMOJI_BIGANGER EMOJI_BIGSMILE EMOJI_BIGWHEEL EMOJI_BOLT + EMOJI_CIRCLE EMOJI_EQUALS EMOJI_EVIL EMOJI_FIRE EMOJI_GRAVE EMOJI_GREATER_THAN EMOJI_HAPPY EMOJI_HEART + EMOJI_HIGHBAR EMOJI_IRRITATED EMOJI_LEAF EMOJI_LEFT_EYE EMOJI_LEFT_FIST EMOJI_LEFT_PAREN EMOJI_MINUS + EMOJI_MISCHIEVOUS EMOJI_MOON EMOJI_NEUTRAL EMOJI_NOTE EMOJI_PIPE EMOJI_PLUS EMOJI_RIGHT_EYE + EMOJI_RIGHT_FIST EMOJI_RIGHT_PAREN EMOJI_SEMICOLON EMOJI_SHOCKED EMOJI_SMALLWHEEL EMOJI_SPHERE + EMOJI_SPIRAL EMOJI_SQUARE EMOJI_SURPRISED EMOJI_TILDE EMOJI_TIRED EMOJI_TONGUE EMOJI_TRIANGLE + EMOJI_TRIANGLE_OUTLINE EMOJI_UNDERSCORE EMOJI_UNION EMOJI_WATER ID LEFT_ARROW LEFT_PAREN LV LV_2 + L_BUTTON MN PK PKMN PLUS POKEBLOCK PP RIGHT_ARROW RIGHT_PAREN R_BUTTON SELECT_BUTTON START_BUTTON + UNK_SPACER UP_ARROW +]]) +local BATTLE = set([[ + B_BUFF1 B_BUFF2 B_COPY_VAR_1 B_COPY_VAR_2 B_COPY_VAR_3 B_PLAYER_MON1_NAME + B_OPPONENT_MON1_NAME B_PLAYER_MON2_NAME B_OPPONENT_MON2_NAME B_LINK_PLAYER_MON1_NAME + B_LINK_OPPONENT_MON1_NAME B_LINK_PLAYER_MON2_NAME B_LINK_OPPONENT_MON2_NAME + B_ATK_NAME_WITH_PREFIX_MON1 B_ATK_PARTNER_NAME B_ATK_NAME_WITH_PREFIX + B_DEF_NAME_WITH_PREFIX B_EFF_NAME_WITH_PREFIX B_ACTIVE_NAME_WITH_PREFIX + B_SCR_ACTIVE_NAME_WITH_PREFIX B_CURRENT_MOVE B_LAST_MOVE B_LAST_ITEM + B_LAST_ABILITY B_ATK_ABILITY B_DEF_ABILITY B_SCR_ACTIVE_ABILITY B_EFF_ABILITY + B_TRAINER1_CLASS B_TRAINER1_NAME B_LINK_PLAYER_NAME B_LINK_PARTNER_NAME + B_LINK_OPPONENT1_NAME B_LINK_OPPONENT2_NAME B_LINK_SCR_TRAINER_NAME B_PLAYER_NAME + B_TRAINER1_LOSE_TEXT B_TRAINER1_WIN_TEXT B_26 B_PC_CREATOR_NAME B_ATK_PREFIX1 + B_DEF_PREFIX1 B_ATK_PREFIX2 B_DEF_PREFIX2 B_ATK_PREFIX3 B_DEF_PREFIX3 + B_TRAINER2_LOSE_TEXT B_TRAINER2_WIN_TEXT B_BUFF3 +]]) +local EMERALD = set("B_TRAINER2_CLASS B_TRAINER2_NAME B_PARTNER_CLASS B_PARTNER_NAME") +local function refuse(reason) + error("unsupported: " .. reason, 0) +end +local function identifier(id) + assert(type(id) == "string" and id ~= "", "text: registry id is required") +end + +function Text.source(value, generation, version, native) + assert(type(value) == "string", "invalid unified content: text must be a source string") + if value:find("[\r\252]") then + refuse("unrepresentable text control") + end + if generation ~= 3 then + return (value:gsub("\\([nplf])", BREAKS)) + end + -- The host's imported FD 02..04 uses VARn. Authored unified text uses the + -- reference's STR_VAR_n, while native overrides retain the old spelling. + if native then + value = value:gsub("{VAR([123])}", "{STR_VAR_%1}") + end + local out, i = {}, 1 + while i <= #value do + local c = value:sub(i, i) + if c == "\\" and i < #value then + local nextChar = value:sub(i + 1, i + 1) + out[#out + 1] = BREAKS[nextChar] or (c .. nextChar) + i = i + 2 + elseif c == "{" and value:find("}", i, true) then + local stop = value:find("}", i, true) + local name = value:sub(i + 1, stop - 1) + if not (PH[name] or TAG[name] or BATTLE[name] or version == "emerald" and EMERALD[name]) then + refuse("unknown text placeholder or printer control: " .. name) + end + out[#out + 1] = "{" .. name .. "}" + i = stop + 1 + else + out[#out + 1] = c + i = i + 1 + end + end + return table.concat(out) +end + +function Text.write(value, generation, version) + if value == Merge.DELETE then + return value + end + local source = Text.source(value, generation, version, false) + if generation == 3 then + return (source:gsub("{STR_VAR_([123])}", "{VAR%1}")) + end + return source +end + +local function addresses(id) + local bank, addr = id:match("^(%x%x):(%x%x%x%x)$") + if not bank then + bank, addr = id:match("^TEXT_S(%x%x)_(%x%x%x%x)$") + end + if not bank then + return id, id + end + bank, addr = tonumber(bank, 16), tonumber(addr, 16) + return ("%02x:%04x"):format(bank, addr), ("TEXT_S%02X_%04X"):format(bank, addr) +end +function Text.api(loader, mod, native, generation) + local registry = loader.content.text + local version = loader.version or require("src.core.GameVersion").get() + local function keys(id) + identifier(id) + if generation ~= 2 then + return id, id + end + local canonical, physical = addresses(id) + if canonical ~= physical and registry:get(canonical) ~= nil then + if registry:get(physical) ~= nil then + refuse("ambiguous script text ID: " .. canonical) + end + physical = canonical + end + return canonical, physical + end + local function get(id) + local _, physical = keys(id) + local value = native:get(physical) + if value == nil then + return nil + end + return Text.source(value, generation, version, true) + end + local api = { + available = true, + supports = function() + return true + end, + } + function api:get(id) + return get(id) + end + function api:has(id) + return get(id) ~= nil + end + function api:each() + local ids, seen = {}, {} + for physical in native:each() do + local canonical = generation == 2 and addresses(physical) or physical + if not seen[canonical] then + seen[canonical] = true + ids[#ids + 1] = canonical + end + end + table.sort(ids) + local i = 0 + return function() + while i < #ids do + i = i + 1 + local value = get(ids[i]) + if value ~= nil then + return ids[i], value + end + end + end + end + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + api[verb] = function(_, id, value) + local ok, result = pcall(function() + -- Frozen writes fail before conversion, as in the reference SDK. + assert(not registry.frozen, "text: content is frozen after load") + local _, physical = keys(id) + local payload = verb ~= "remove" and Text.write(value, generation, version) or nil + native[verb](native, physical, payload) + loader._unifiedTextKeys = loader._unifiedTextKeys or {} + loader._unifiedTextKeys[physical] = loader._unifiedTextKeys[physical] or {} + loader._unifiedTextKeys[physical][mod.manifest.id] = true + return get(id) + end) + if ok then + return result + end + if type(result) == "string" and result:sub(1, 13) == "unsupported: " then + return nil, result + end + error(result, 0) + end + end + return api +end +-- Retire only this adapter's successful writes when the same mounted dataset +-- is loaded again. An intervening external edit or replacement table wins. +function Text.prepare(loader, data) + local undo = data and previous[data] + for _, row in ipairs(undo or {}) do + if data.text == row.target and row.target[row.key] == row.after then + row.target[row.key] = row.before + end + end + if data then + previous[data] = nil + end + loader._unifiedTextKeys = nil +end +function Text.capture(loader, data) + if not data or not loader._unifiedTextKeys then + return + end + local registry, undo = loader.content.text, {} + for key, owners in pairs(loader._unifiedTextKeys) do + local active = false + for _, op in ipairs(registry.ops[key] or {}) do + if owners[op.owner] then + active = true + break + end + end + if active then + data.text = data.text or {} + undo[#undo + 1] = { target = data.text, key = key, before = data.text[key], after = registry:get(key) } + end + end + previous[data] = undo +end +return Text diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 69955d9b0..aa01f47d5 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -403,6 +403,9 @@ local function protect(fn) end function U.new(loader, mod, native, gen) local out = {}; for name, handle in pairs(native) do out[name] = handle end + if native.text and gen <= 3 then + out.text = require("src.mods.TextContent").api(loader, mod, native.text, gen) + end if native.held_items then out.held_items = require("src.mods.HeldItemContent").api(loader, mod, native.held_items, gen, true) end diff --git a/tests/drivers/text_sources.lua b/tests/drivers/text_sources.lua new file mode 100644 index 000000000..fdb22c6af --- /dev/null +++ b/tests/drivers/text_sources.lua @@ -0,0 +1,81 @@ +-- Imported-data native text smoke. Use the isolated combined-fork wrapper. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported version") + local gen = V.generation() + assert(gen == 2 or gen == 3, "smoke requires imported Gen 2/GBA") + local e = assert(game.mods.exports.portable_text_sources, "install public text example") + assert(e.after and not e.reason, "public text source was refused") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(4) + end + assert(title, "native title missing") + title:onNewGame() + local world = assert(game.overworld, "native New Game missing") + game.save.player.name, game.save.player.rival = "PROBE", "RIVAL" + game.stringBuffers = { "ONE", "TWO", "THREE" } + game.stack.states = { world } + local key = gen == 2 and "TEXT_S55_4067" or "PORTABLE_TEXT" + assert(game.data.text[key], "native storage key missing") + if gen == 2 then + assert(e.before and e.before ~= "SCRIPT BASE", "Gold must use actual imported cartridge text") + assert(not game.data.text["55:4067"], "unused canonical shadow text created") + assert(e.api.content.text:get("55:4067") == e.after, "canonical read mismatch") + end + local script + if gen == 2 then + local pool = assert(game.data.map_scripts.scripts) + assert(pool.portable_text_source_probe == nil, "driver key already occupied") + pool.portable_text_source_probe = { { "writetext", key }, { "end" } } + script = assert(require("src.script.Gen2ScriptVM").compile(game.data, "portable_text_source_probe")) + else + script = { { "g3_show_text", key } } + end + local runner = require("src.script.ScriptRunner").new(game, world) + runner:run(script) + local box = assert(game.stack:top(), "native text did not open") + assert(box ~= world and type(box.pages) == "table", "native textbox missing") + local all = {} + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do + all[#all + 1] = line + end + end + local shown = table.concat(all, "|") + assert(shown:find("PROBE", 1, true), "player placeholder did not reach native printer") + assert(not shown:find("{PLAYER}", 1, true), "player token remained literal") + if gen == 3 then + assert(shown:find("ONE", 1, true) and shown:find("TWO", 1, true), "common string vars did not expand") + assert( + not shown:find("{STR_VAR", 1, true) and not shown:find("{VAR", 1, true), + "string vars remained literal" + ) + end + assert(#box.pages >= 2, "paragraph source control did not reach native pages") + U.wait(10) -- the ordinary renderer draws the actual box + for _ = 1, 100 do + if game.stack:top() == world then + break + end + U.tap(game, "a") + U.wait(3) + end + assert(game.stack:top() == world, "native A input did not close dialogue") + assert(not runner:isRunning(), "native dialogue did not resume script") + local ok, err = pcall(function() + e.api.content.text:override("PORTABLE_TEXT", "LATE") + end) + assert(not ok and tostring(err):find("frozen", 1, true), "late public write was not frozen") + print("PORTABILITY_TEXT_SOURCE_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/text_source_authority.lua b/tests/engine/text_source_authority.lua new file mode 100644 index 000000000..94a4046bd --- /dev/null +++ b/tests/engine/text_source_authority.lua @@ -0,0 +1,132 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local F = dofile("tests/text_sources/fixture.lua") +local old = V.get() +local function reload(data) + -- Isolate this registry's retirement from the pre-existing builtin + -- duplicate-registration issue on an already-merged fixture dataset. + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "text" and root ~= "constants" then + data[root] = nil + end + end + return T.sdk.loadNone({ data = data }) +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local api, format = unpack(mode) + local f = F.new(".", "gen2", version, api, format, "override", "PORTABLE_TEXT", "X\\nY\\pZ\\lQ") + T.eq(#f.result.errors, 0, "all legacy/opt-in modes load") + local unified = api == 3 or format == "unified" + T.eq( + f.data.text.PORTABLE_TEXT, + unified and "X\nY\fZ\vQ" or "X\\nY\\pZ\\lQ", + "native source bytes remain unchanged outside unified boundary" + ) + T.eq(f.e.after, f.data.text.PORTABLE_TEXT, "public source matches mode") + T.raises(function() + f.e.api.content.text:override("KEEP", "late") + end, "frozen", "all modes freeze content") + f.close() + if unified then + local pool = f.data.text + pool.UNRELATED = "external" + local r = reload(f.data) + T.eq(pool.PORTABLE_TEXT, "BASE", "reload retires successful adapter write") + T.eq(pool.UNRELATED, "external", "reload retains unrelated external writes") + T.eq(f.data.text, pool, "reload retains native table identity") + r.release() + local edited = F.new(".", "gen2", version, api, format, "override", "PORTABLE_TEXT", "MODDED") + edited.data.text.PORTABLE_TEXT = "EXTERNAL" + edited.close() + local keep = reload(edited.data) + T.eq(edited.data.text.PORTABLE_TEXT, "EXTERNAL", "retirement cannot overwrite intervening edit") + keep.release() + local replacement = F.new(".", "gen2", version, api, format, "register", "NEW_TEXT", "MODDED") + replacement.data.text = { REPLACED = "other dataset" } + replacement.close() + local other = reload(replacement.data) + T.eq(replacement.data.text.NEW_TEXT, nil, "retirement does not add data to replacement table") + other.release() + for _, source in ipairs({ "{PLAYER}", "{STR_VAR_1}", "{STR_VAR_2}", "{STR_VAR_3}" }) do + if V.generation(version) == 3 then + local tokens = F.new(".", "gen2", version, api, format, "override", "PORTABLE_TEXT", source .. "!") + local expected = ({ + ["{PLAYER}"] = "PROBE!", + ["{STR_VAR_1}"] = "ONE!", + ["{STR_VAR_2}"] = "TWO!", + ["{STR_VAR_3}"] = "THREE!", + })[source] + T.eq( + tokens.dialogue("PORTABLE_TEXT"), + expected, + "real GBA command resolves common token " .. source + ) + T.eq(tokens.e.after, source .. "!", "read exposes common token without changing native alias") + tokens.close() + end + end + end + end +end +for _, api in ipairs({ 2, 3 }) do + for _, body in ipairs({ + 'mod.content.text:override("PORTABLE_TEXT", "EARLY"); mod.content.text:override("BAD", {})', + 'mod.content.text:override("PORTABLE_TEXT", "EARLY"); mod.content.text:get(nil)', + 'mod.content.text:override("PORTABLE_TEXT", "EARLY"); mod.content.text:register("KEEP", "duplicate")', + }) do + local f = F.new(".", "gen2", "gold", api, "unified", "none", "PORTABLE_TEXT", "", body) + T.check(#f.result.errors > 0, "invalid entry is rejected") + T.eq(f.data.text.PORTABLE_TEXT, "BASE", "invalid entry rolls earlier write back") + T.eq(next(f.result.loader.content.text.ops), nil, "invalid entry adds no content ops") + f.close() + end + local f = F.new( + ".", + "gen2", + "gold", + api, + "unified", + "none", + "55:4067", + "", + [[ + mod.content.text:override("55:4067", "FIRST") + mod.content.text:patch("TEXT_S55_4067", "SECOND") + mod.exports.value = mod.content.text:get("55:4067") + mod.exports.physical = mod.content.text:get("TEXT_S55_4067") + ]] + ) + T.eq(f.e.value, "SECOND", "canonical and native alias fold one ordered registry key") + T.eq(f.e.physical, "SECOND", "native alias reads the same record") + T.eq(f.data.text["55:4067"], nil, "no unused canonical shadow text is written") + T.eq(f.data.text.TEXT_S55_4067, "SECOND", "native VM physical storage is authoritative") + f.close() + local conflict = F.new( + ".", + "gen2", + "gold", + api, + "unified", + "override", + "55:4067", + "unsafe", + nil, + function(data) + data.text["55:4067"] = "AMBIGUOUS" + end + ) + T.check(#conflict.result.errors > 0, "ambiguous imported/custom aliases fail before entry writes") + T.eq(conflict.data.text.TEXT_S55_4067, "SCRIPT BASE", "ambiguous alias does not pick a native record") + T.eq(conflict.data.text["55:4067"], "AMBIGUOUS", "ambiguous alias does not mutate custom record") + conflict.close() +end +-- DS keeps its existing native text handles. This change does not invent a +-- common DS printer or claim GBA source controls work on those screens. +for _, version in ipairs({ "platinum", "black" }) do + local f = F.new(".", "gen2", version, 3, "unified", "override", "PORTABLE_TEXT", "X\\nY") + T.eq(f.data.text.PORTABLE_TEXT, "X\\nY", "DS native bytes remain unchanged") + f.close() +end +V.set(old) +T.finish("source text ownership, legacy bytes, aliases and native token consumers") diff --git a/tests/engine/text_sources.lua b/tests/engine/text_sources.lua new file mode 100644 index 000000000..7cd0b4386 --- /dev/null +++ b/tests/engine/text_sources.lua @@ -0,0 +1,10 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local old = V.get() +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, api in ipairs({ 2, 3 }) do + dofile("tests/text_sources/cases.lua").run(".", "gen2", version, api, "unified") + end +end +V.set(old) +T.finish("portable source text and native script readers") diff --git a/tests/text_sources/cases.lua b/tests/text_sources/cases.lua new file mode 100644 index 000000000..9ed59b930 --- /dev/null +++ b/tests/text_sources/cases.lua @@ -0,0 +1,71 @@ +local C = {} +function C.run(root, backend, version, api, format) + local F = dofile(root .. "/tests/text_sources/fixture.lua") + local rows = {} + local modes = { "override", "patch", "register", "remove", "delete", "patch-delete", "rollback", "no-mod" } + for _, mode in ipairs(modes) do + local id = mode == "register" and "NEW_TEXT" or "PORTABLE_TEXT" + local f = F.new(root, backend, version, api, format, mode, id, "Hi {PLAYER}!\\n50%\\pBye\\lEnd") + local T = f.T + T.eq(#f.result.errors > 0, mode == "rollback" or mode == "delete", mode .. " load outcome") + local value = f.source(id) + if mode == "no-mod" or mode == "rollback" or mode == "delete" then + T.eq(value, "BASE", mode .. " restores base") + elseif mode == "remove" or mode == "patch-delete" then + T.eq(value, nil, mode .. " removes native text") + else + T.eq(value, "Hi {PLAYER}!\n50%\fBye\vEnd", mode .. " native controls") + end + local row = { mode = mode, source = value, failed = #f.result.errors > 0 } + if f.e then + row.before, row.after = f.e.before, f.e.after + row.ids = f.e.ids + T.eq(f.e.api.content.text:has("KEEP"), true, "has preserves unrelated text") + T.raises(function() + f.e.api.content.text:override(id, "LATE") + end, "frozen", "late text writes rejected") + end + if value then + -- Real native readers, never Runtime.call producer stubs. Formatting + -- details differ; exact source/control comparison is the shared contract. + local shown = f.dialogue(id) + T.check( + shown:find((mode == "no-mod" or mode == "rollback" or mode == "delete") and "BASE" or "Hi", 1, true) + ~= nil, + "native reader sees effective source" + ) + end + rows[#rows + 1] = row + f.close() + end + local gen = require("src.core.GameVersion").generation(version) + if gen == 2 then + local f = F.new(root, backend, version, api, format, "override", "55:4067", "SCRIPT PATCH") + f.T.eq(f.source("55:4067"), "SCRIPT PATCH", "portable bank/address reaches native script pool") + f.T.eq(f.dialogue("55:4067"), "SCRIPT PATCH", "native VM displays physical ID override") + rows[#rows + 1] = + { mode = "bank-address", before = f.e.before, after = f.e.after, native = f.source("55:4067") } + f.close() + end + local corpus = F.new(root, backend, version, api, format, "corpus", "PORTABLE_TEXT", "unused") + corpus.T.eq(#corpus.result.errors, 0, "source corpus loads atomically") + for i, observation in ipairs(corpus.e.corpus) do + corpus.T.eq( + corpus.source("CORPUS_" .. i), + observation.read, + "canonical source reaches native storage " .. i + ) + if not observation.accepted then + corpus.T.eq(observation.read, "seed", "refused source leaves previous value") + end + rows[#rows + 1] = { + mode = "source-" .. i, + accepted = observation.accepted, + value = observation.value, + read = observation.read, + } + end + corpus.close() + return rows +end +return C diff --git a/tests/text_sources/corpus.lua b/tests/text_sources/corpus.lua new file mode 100644 index 000000000..cfffeda7f --- /dev/null +++ b/tests/text_sources/corpus.lua @@ -0,0 +1,74 @@ +-- Source-language cases are consumed unchanged by both engines. Symbol and +-- placeholder names are the public cartridge grammar, with invalid near misses. +local values = { + "", + "plain", + "50% and %s %d %%", + "é UTF-8 ♂ ♀", + "line\nnext\fpage\vscroll", + "line\\nnext\\ppage\\lscroll\\fpage", + "\\\\n", + "\\q", + "trailing\\", + "{", + "{PLAYER", + "{PLAYER}", + "{RIVAL}", + "{STR_VAR_1} {STR_VAR_2} {STR_VAR_3}", + "{VAR1}", + "{STR_VAR_4}", + "{NOT_A_TOKEN}", + "{COLOR RED}", + "{COLOR:1}", + "{FONT_MALE}", + "{FONT_NORMAL}", + "{SHADOW}", + "{BG}", + "prefix\rsuffix", + "prefix\252suffix", + "{PLAYER}{RIVAL}", + "{PL\\nAYER}", + "{PLAYER}\\\\n{STR_VAR_1}", + "{PLAYER}}", + "{PLAYER\v}", + "{ PLAYER}", + "{PLAYER:arg}", + "{not_a_token}", + "\\{PLAYER}", + "a\\\\nb", + "{A_BUTTON}", + "{PKMN}", +} +local words = [[ +UNKNOWN KUN VERSION MAGMA AQUA MAXIE ARCHIE GROUDON KYOGRE EVIL_TEAM GOOD_TEAM +EVIL_LEADER GOOD_LEADER EVIL_LEGENDARY GOOD_LEGENDARY +B_BUFF1 B_BUFF2 B_COPY_VAR_1 B_COPY_VAR_2 B_COPY_VAR_3 B_PLAYER_MON1_NAME +B_OPPONENT_MON1_NAME B_PLAYER_MON2_NAME B_OPPONENT_MON2_NAME B_LINK_PLAYER_MON1_NAME +B_LINK_OPPONENT_MON1_NAME B_LINK_PLAYER_MON2_NAME B_LINK_OPPONENT_MON2_NAME +B_ATK_NAME_WITH_PREFIX_MON1 B_ATK_PARTNER_NAME B_ATK_NAME_WITH_PREFIX +B_DEF_NAME_WITH_PREFIX B_EFF_NAME_WITH_PREFIX B_ACTIVE_NAME_WITH_PREFIX +B_SCR_ACTIVE_NAME_WITH_PREFIX B_CURRENT_MOVE B_LAST_MOVE B_LAST_ITEM B_LAST_ABILITY +B_ATK_ABILITY B_DEF_ABILITY B_SCR_ACTIVE_ABILITY B_EFF_ABILITY B_TRAINER1_CLASS +B_TRAINER1_NAME B_LINK_PLAYER_NAME B_LINK_PARTNER_NAME B_LINK_OPPONENT1_NAME +B_LINK_OPPONENT2_NAME B_LINK_SCR_TRAINER_NAME B_PLAYER_NAME B_TRAINER1_LOSE_TEXT +B_TRAINER1_WIN_TEXT B_26 B_PC_CREATOR_NAME B_ATK_PREFIX1 B_DEF_PREFIX1 B_ATK_PREFIX2 +B_DEF_PREFIX2 B_ATK_PREFIX3 B_DEF_PREFIX3 B_TRAINER2_LOSE_TEXT B_TRAINER2_WIN_TEXT +B_BUFF3 B_TRAINER2_CLASS B_TRAINER2_NAME B_PARTNER_CLASS B_PARTNER_NAME +A_BUTTON B_BUTTON DOWN_ARROW DPAD_ANY DPAD_DOWN DPAD_LEFT DPAD_LEFTRIGHT DPAD_RIGHT +DPAD_UP DPAD_UPDOWN EMOJI_ACUTE EMOJI_ANGRY EMOJI_AT EMOJI_BALL EMOJI_BIGANGER +EMOJI_BIGSMILE EMOJI_BIGWHEEL EMOJI_BOLT EMOJI_CIRCLE EMOJI_EQUALS EMOJI_EVIL +EMOJI_FIRE EMOJI_GRAVE EMOJI_GREATER_THAN EMOJI_HAPPY EMOJI_HEART EMOJI_HIGHBAR +EMOJI_IRRITATED EMOJI_LEAF EMOJI_LEFT_EYE EMOJI_LEFT_FIST EMOJI_LEFT_PAREN +EMOJI_MINUS EMOJI_MISCHIEVOUS EMOJI_MOON EMOJI_NEUTRAL EMOJI_NOTE EMOJI_PIPE +EMOJI_PLUS EMOJI_RIGHT_EYE EMOJI_RIGHT_FIST EMOJI_RIGHT_PAREN EMOJI_SEMICOLON +EMOJI_SHOCKED EMOJI_SMALLWHEEL EMOJI_SPHERE EMOJI_SPIRAL EMOJI_SQUARE +EMOJI_SURPRISED EMOJI_TILDE EMOJI_TIRED EMOJI_TONGUE EMOJI_TRIANGLE +EMOJI_TRIANGLE_OUTLINE EMOJI_UNDERSCORE EMOJI_UNION EMOJI_WATER ID LEFT_ARROW +LEFT_PAREN LV LV_2 L_BUTTON MN PK PKMN PLUS POKEBLOCK PP RIGHT_ARROW RIGHT_PAREN +R_BUTTON SELECT_BUTTON START_BUTTON UNK_SPACER UP_ARROW +EMOJI_BIGSPIKE EMOJI_WINK EMOJI_SAD EMOJI_QUESTION +]] +for name in words:gmatch("%S+") do + values[#values + 1] = "{" .. name .. "}" +end +return values diff --git a/tests/text_sources/fixture.lua b/tests/text_sources/fixture.lua new file mode 100644 index 000000000..d3d95e6c4 --- /dev/null +++ b/tests/text_sources/fixture.lua @@ -0,0 +1,165 @@ +local F = {} +function F.new(root, backend, version, api, format, mode, id, source, body, prepare) + local T, V = require("tests.modkit"), require("src.core.GameVersion") + V.set(version) + local gen, J = V.generation(), require("src.link.Json") + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or { constants = { generation = gen } } + if backend == "gen1" and gen == 1 then + data = T.fixtures.fresh() + end + local raw = { PORTABLE_TEXT = "BASE", KEEP = "KEEP" } + if gen == 2 then + raw[backend == "gen1" and "55:4067" or "TEXT_S55_4067"] = "SCRIPT BASE" + end + if backend == "gen1" and gen == 2 then + data.text, data.gen2Text = { ENGINE_ONLY = "ENGINE" }, raw + elseif backend == "gen1" and gen == 3 then + local IR = require("src.core.game3.scripting.text_ir") + local native = {} + for key, value in pairs(raw) do + native[key] = IR.fromAscii(value) + end + data.gen3Text = native + else + data.text = raw + end + if prepare then + prepare(data, gen) + end + local name, files = "portable_text_sources", {} + for _, path in ipairs({ "manifest.json", "main.lua" }) do + local f = assert(io.open(root .. "/examples/" .. name .. "/" .. path, "rb")) + files["mods/" .. name .. "/" .. path] = f:read("*a") + f:close() + end + local manifest = J.decode(files["mods/" .. name .. "/manifest.json"]) + manifest.api, manifest.games, manifest.content_format = api, { version }, format + manifest.gen2compat = api < 3 and true or nil + files["mods/" .. name .. "/manifest.json"] = J.encode(manifest) + files["mods/" .. name .. "/case.txt"] = mode + files["mods/" .. name .. "/id.txt"] = id + files["mods/" .. name .. "/source.txt"] = source + if body then + files["mods/" .. name .. "/main.lua"] = "local mod=...; mod.exports.api=mod; " .. body + end + if mode == "corpus" then + for i, value in ipairs(dofile(root .. "/tests/text_sources/corpus.lua")) do + files["mods/" .. name .. "/corpus/" .. i] = value + end + end + if mode == "no-mod" then + files = {} + end + local r = T.sdk.loadMods( + mode == "no-mod" and {} or { "mods/" .. name }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + local f = { T = T, result = r, data = data, gen = gen, e = r.loader.exports[name] } + function f.raw(key) + if backend == "gen1" and gen == 3 then + return data.gen3Text[key] + end + if backend == "gen1" and gen == 2 then + return data.gen2Text[key] + end + if gen == 2 then + local b, a = key:match("^(%x%x):(%x%x%x%x)$") + if b then + key = ("TEXT_S%02X_%04X"):format(tonumber(b, 16), tonumber(a, 16)) + end + end + return data.text[key] + end + function f.source(key) + local value = f.raw(key) + if backend == "gen1" and gen == 3 and value then + local IR = require("src.core.game3.scripting.text_ir") + local dialect = IR.dialect(version == "emerald" and "rse" or "frlg") + local parts = {} + for _, row in ipairs(value) do + if row.t == "eos" then + break + elseif row.t == "text" then + parts[#parts + 1] = row.s + elseif row.t == "tag" then + parts[#parts + 1] = row.tag + elseif row.t == "player" then + parts[#parts + 1] = "{PLAYER}" + elseif row.t == "rival" then + parts[#parts + 1] = "{RIVAL}" + elseif row.t == "strvar" then + parts[#parts + 1] = "{STR_VAR_" .. row.n .. "}" + elseif row.t == "ph" then + parts[#parts + 1] = "{" .. (row.name or dialect.PH_NAMES[row.code]) .. "}" + elseif row.t == "bph" then + parts[#parts + 1] = "{" .. dialect.B_TXT[row.code] .. "}" + else + parts[#parts + 1] = assert(({ nl = "\n", para = "\f", scroll = "\v" })[row.t], row.t) + end + end + return table.concat(parts) + end + if gen == 3 and value then + return value:gsub("{VAR([123])}", "{STR_VAR_%1}") + end + return value + end + function f.dialogue(key) + if backend == "gen1" and gen == 2 then + local vm = require("src.script.gen2.Vm").new({}, data.gen2Text, nil, { showText = function() end }) + local co = coroutine.create(function() + vm:showText(key) + end) + local ok, row = coroutine.resume(co) + assert(ok, row) + return row.text + elseif backend == "gen1" and gen == 3 then + local vm = require("src.core.game3.scripting.vm").new({ text = data.gen3Text }) + return require("src.core.game3.scripting.text_ir").toAscii( + vm:getText(key), + { playerName = "PROBE", rivalName = "RIVAL", stringVars = { "ONE", "TWO", "THREE" } } + ) + end + local game = { + data = data, + save = { player = { name = "PROBE", rival = "RIVAL" }, options = {} }, + stringBuffers = { "ONE", "TWO", "THREE" }, + input = { + wasPressed = function() + return false + end, + }, + } + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + require("src.render.Font").load(data) + local runner = require("src.script.ScriptRunner").new(game) + if gen == 2 then + local b, a = key:match("^(%x%x):(%x%x%x%x)$") + if b then + key = ("TEXT_S%02X_%04X"):format(tonumber(b, 16), tonumber(a, 16)) + end + end + local script = { { "show_text", key } } + if gen == 2 then + data.map_scripts = { scripts = { probe = { { "writetext", key }, { "end" } } } } + script = assert(require("src.script.Gen2ScriptVM").compile(data, "probe")) + elseif gen == 3 then + script = { { "g3_show_text", key } } + end + runner:run(script) + local box = assert(game.stack:top(), "native script did not open a textbox") + local lines = {} + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do + lines[#lines + 1] = line + end + end + return table.concat(lines, "|") + end + function f.close() + r.release() + end + return f +end +return F diff --git a/tests/text_sources/worker.lua b/tests/text_sources/worker.lua new file mode 100644 index 000000000..27babd35b --- /dev/null +++ b/tests/text_sources/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/text_sources/cases.lua").run(root, backend, version, tonumber(api), format) +print("TEXT_SOURCE_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("portable native script text") diff --git a/tools/text-source-contract-check/README.md b/tools/text-source-contract-check/README.md new file mode 100644 index 000000000..8001f898d --- /dev/null +++ b/tools/text-source-contract-check/README.md @@ -0,0 +1,14 @@ +# Native script-text contract check + +Run `python3 tools/text-source-contract-check/check.py --gen1 /clean/gen1 --lua luajit`. +The reference must be clean at the pinned commit. No code is copied into it. +Eight versions each run in separate processes for both real SDKs. The unchanged +public consumer exercises scalar registry operations, rollback, freeze, Gen-2 +physical ID routing and a shared source-language corpus, checked against native +storage. Native script/text readers run separately from the corpus. + +`--host-api 2` checks the host's opt-in unified mode against reference API 3; +it does not claim the reference supports unified content on API 2. +`--report path.json` records exact observations, references and host dirtiness. +A mismatch or an SDK/test error fails the checker. No ROM, cache or save is needed. +See `docs/api3-text-sources.md` for remaining prose/label bindings. diff --git a/tools/text-source-contract-check/check.py b/tools/text-source-contract-check/check.py new file mode 100644 index 000000000..8298a4882 --- /dev/null +++ b/tools/text-source-contract-check/check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Compare public source text through both real SDKs and native script readers.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--host-api", type=int, choices=[2, 3], default=3) + p.add_argument("--report", type=Path) + a = p.parse_args() + source, host = a.gen1.resolve(), a.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + p.error("Gen1 must be clean at " + REFERENCE) + results = [] + for version in ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine, api in [("gen1", source, 3), ("gen2", host, a.host_api)]: + run = subprocess.run([a.lua, str(host / "tests/text_sources/worker.lua"), str(host), + backend, version, str(api), "unified"], cwd=engine, + text=True, capture_output=True, timeout=45) + rows = [line.removeprefix("TEXT_SOURCE_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("TEXT_SOURCE_CONTRACT ")] + if run.returncode or len(rows) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(rows[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, "api": api, "observations": rows}) + if observations[0] != observations[1]: + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + raise AssertionError(f"{version}/{index}: reference={left!r}, host={right!r}") + raise AssertionError(f"{version}: different observation counts") + print(f"PASS {version}: {len(observations[0])} matching native source-text observations") + assert not git(source, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"reference_ref": REFERENCE, + "host_ref": git(host, "rev-parse", "HEAD"), "host_api": a.host_api, + "host_worktree_dirty": bool(git(host, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() From 2a35a429f816758c702c55dfdec79a960aca60ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:02:06 +0200 Subject: [PATCH 65/86] Exercise mixed text owners and keep example enumeration optional --- docs/api3-portability.md | 4 ++ examples/portable_text_sources/main.lua | 6 ++- tests/engine/text_source_authority.lua | 53 +++++++++++++++++++++++++ tests/text_sources/fixture.lua | 6 ++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 21d5d1285..1e67f31b7 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -179,3 +179,7 @@ and bargain shelves, common item IDs, native purchases and shared contracts. See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for `shiny.roll`, `gender.roll`, native DV/PID semantics and shared contracts. + +For shared script-text source strings, GBA placeholder names and native Gen-2 +bank/address IDs, see [portable text sources](api3-text-sources.md). Cartridge +prose and named GBA label bindings remain separate follow-up work. diff --git a/examples/portable_text_sources/main.lua b/examples/portable_text_sources/main.lua index 173affc8b..be766b65c 100644 --- a/examples/portable_text_sources/main.lua +++ b/examples/portable_text_sources/main.lua @@ -31,8 +31,10 @@ else end mod.exports.after = text:get(id) mod.exports.ids = {} -for key in text:each() do - mod.exports.ids[#mod.exports.ids + 1] = key +if mod:read("enumerate.txt") == "yes" then + for key in text:each() do + mod.exports.ids[#mod.exports.ids + 1] = key + end end if mode == "rollback" then error("intentional portable text rollback") diff --git a/tests/engine/text_source_authority.lua b/tests/engine/text_source_authority.lua index 94a4046bd..71ddcac21 100644 --- a/tests/engine/text_source_authority.lua +++ b/tests/engine/text_source_authority.lua @@ -121,6 +121,59 @@ for _, api in ipairs({ 2, 3 }) do T.eq(conflict.data.text["55:4067"], "AMBIGUOUS", "ambiguous alias does not mutate custom record") conflict.close() end +-- Mixed native/unified owners share one physical record and the existing +-- dependency order. Failed unified owners must not acquire retirement of a +-- surviving native owner's value. +for _, order in ipairs({ "native-first", "unified-first", "failed-unified" }) do + local f = F.new( + ".", + "gen2", + "gold", + 3, + "unified", + order == "failed-unified" and "rollback" or "override", + "55:4067", + "UNIFIED", + nil, + nil, + function(files, name, J) + local path = "mods/native_text_overlay/" + local m = { + id = "native_text_overlay", + name = "Native text overlay", + version = "1.0.0", + api = 2, + games = { "gold" }, + gen2compat = true, + entry = "main.lua", + } + if order == "unified-first" then + m.dependencies = { name } + else + local current = J.decode(files["mods/" .. name .. "/manifest.json"]) + current.dependencies = { m.id } + files["mods/" .. name .. "/manifest.json"] = J.encode(current) + end + files[path .. "manifest.json"] = J.encode(m) + files[path .. "main.lua"] = 'local mod=...; mod.content.text:override("TEXT_S55_4067", "NATIVE")' + end + ) + T.eq(#f.result.errors > 0, order == "failed-unified", "mixed owner load outcome") + T.eq( + f.data.text.TEXT_S55_4067, + order == "native-first" and "UNIFIED" or "NATIVE", + "declared dependency order reaches physical VM key" + ) + T.eq(f.data.text["55:4067"], nil, "mixed owners do not create shadow key") + f.close() + local r = reload(f.data) + T.eq( + f.data.text.TEXT_S55_4067, + order == "failed-unified" and "NATIVE" or "SCRIPT BASE", + "failed adapter cannot claim a surviving native owner's retirement" + ) + r.release() +end -- DS keeps its existing native text handles. This change does not invent a -- common DS printer or claim GBA source controls work on those screens. for _, version in ipairs({ "platinum", "black" }) do diff --git a/tests/text_sources/fixture.lua b/tests/text_sources/fixture.lua index d3d95e6c4..347feb1cb 100644 --- a/tests/text_sources/fixture.lua +++ b/tests/text_sources/fixture.lua @@ -1,5 +1,5 @@ local F = {} -function F.new(root, backend, version, api, format, mode, id, source, body, prepare) +function F.new(root, backend, version, api, format, mode, id, source, body, prepare, configure) local T, V = require("tests.modkit"), require("src.core.GameVersion") V.set(version) local gen, J = V.generation(), require("src.link.Json") @@ -42,6 +42,10 @@ function F.new(root, backend, version, api, format, mode, id, source, body, prep if body then files["mods/" .. name .. "/main.lua"] = "local mod=...; mod.exports.api=mod; " .. body end + files["mods/" .. name .. "/enumerate.txt"] = "yes" + if configure then + configure(files, name, J) + end if mode == "corpus" then for i, value in ipairs(dofile(root .. "/tests/text_sources/corpus.lua")) do files["mods/" .. name .. "/corpus/" .. i] = value From 13598190777c0de6fac79cbe0f2663b8f8c56725 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:07:43 +0200 Subject: [PATCH 66/86] Preserve authored text ID case and escaped placeholder literals --- docs/api3-text-sources.md | 6 ++++-- src/mods/TextContent.lua | 30 +++++++++++++++++++----------- tests/text_sources/cases.lua | 5 +++++ tests/text_sources/corpus.lua | 3 +++ tests/text_sources/fixture.lua | 22 +++++++++++++++++++--- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/api3-text-sources.md b/docs/api3-text-sources.md index 39921d911..cf9be315a 100644 --- a/docs/api3-text-sources.md +++ b/docs/api3-text-sources.md @@ -20,7 +20,8 @@ return `nil, "unsupported: ..."` on writes. Invalid value types throw. GBA source uses `{PLAYER}`, `{RIVAL}` and `{STR_VAR_1}` through `{STR_VAR_3}`. The adapter writes the latter to the host's native `{VAR1}` through `{VAR3}`, so the existing script buffer commands and textbox token handlers consume them. -Reads reverse those aliases. Other public GBA cartridge placeholder and symbol +Reads reverse those aliases. Escaped literal braces retain their source spelling; +placeholder conversion applies only to actual source tokens. Other public GBA cartridge placeholder and symbol names round-trip with the version's grammar. Unknown placeholders and printer style commands are refused, rather than accidentally installing a missing token. The source contract does not promise matching glyph artwork or pixel layout. @@ -32,7 +33,8 @@ Gen-2 text IDs use the reference's lowercase hexadecimal `bank:address` form, for example `55:4067`. The host VM stores that same script text as `TEXT_S55_4067`. Unified reads, writes, removes and enumeration map to that physical record. They do not create an unused `data.text["55:4067"]` shadow. -Existing host native IDs are still accepted. Two distinct records occupying +Existing host native IDs are still accepted. Differently-cased authored labels +remain distinct IDs; they are not silently normalized into cartridge addresses. Two distinct records occupying both spellings are an ambiguity, even if their current contents match; the adapter refuses to guess which one a mod meant. diff --git a/src/mods/TextContent.lua b/src/mods/TextContent.lua index 15dd08b37..28fa3241a 100644 --- a/src/mods/TextContent.lua +++ b/src/mods/TextContent.lua @@ -49,7 +49,7 @@ local function identifier(id) assert(type(id) == "string" and id ~= "", "text: registry id is required") end -function Text.source(value, generation, version, native) +local function convert(value, generation, version, native, writing) assert(type(value) == "string", "invalid unified content: text must be a source string") if value:find("[\r\252]") then refuse("unrepresentable text control") @@ -59,9 +59,6 @@ function Text.source(value, generation, version, native) end -- The host's imported FD 02..04 uses VARn. Authored unified text uses the -- reference's STR_VAR_n, while native overrides retain the old spelling. - if native then - value = value:gsub("{VAR([123])}", "{STR_VAR_%1}") - end local out, i = {}, 1 while i <= #value do local c = value:sub(i, i) @@ -72,9 +69,15 @@ function Text.source(value, generation, version, native) elseif c == "{" and value:find("}", i, true) then local stop = value:find("}", i, true) local name = value:sub(i + 1, stop - 1) + if native then + name = name:gsub("^VAR([123])$", "STR_VAR_%1") + end if not (PH[name] or TAG[name] or BATTLE[name] or version == "emerald" and EMERALD[name]) then refuse("unknown text placeholder or printer control: " .. name) end + if writing then + name = name:gsub("^STR_VAR_([123])$", "VAR%1") + end out[#out + 1] = "{" .. name .. "}" i = stop + 1 else @@ -85,15 +88,14 @@ function Text.source(value, generation, version, native) return table.concat(out) end +function Text.source(value, generation, version, native) + return convert(value, generation, version, native, false) +end function Text.write(value, generation, version) if value == Merge.DELETE then return value end - local source = Text.source(value, generation, version, false) - if generation == 3 then - return (source:gsub("{STR_VAR_([123])}", "{VAR%1}")) - end - return source + return convert(value, generation, version, false, true) end local function addresses(id) @@ -105,11 +107,17 @@ local function addresses(id) return id, id end bank, addr = tonumber(bank, 16), tonumber(addr, 16) - return ("%02x:%04x"):format(bank, addr), ("TEXT_S%02X_%04X"):format(bank, addr) + local canonical, physical = ("%02x:%04x"):format(bank, addr), ("TEXT_S%02X_%04X"):format(bank, addr) + -- Only these two exact spellings are the imported/native address pair. + -- A differently-cased authored label is a distinct ID in the reference. + if id ~= canonical and id ~= physical then + return id, id + end + return canonical, physical end function Text.api(loader, mod, native, generation) local registry = loader.content.text - local version = loader.version or require("src.core.GameVersion").get() + local version = require("src.core.GameVersion").get() local function keys(id) identifier(id) if generation ~= 2 then diff --git a/tests/text_sources/cases.lua b/tests/text_sources/cases.lua index 9ed59b930..2be3195d8 100644 --- a/tests/text_sources/cases.lua +++ b/tests/text_sources/cases.lua @@ -46,6 +46,11 @@ function C.run(root, backend, version, api, format) rows[#rows + 1] = { mode = "bank-address", before = f.e.before, after = f.e.after, native = f.source("55:4067") } f.close() + local authored = F.new(root, backend, version, api, format, "register", "55:ABCD", "AUTHORED") + authored.T.eq(authored.source("55:ABCD"), "AUTHORED", "authored ID case is preserved") + authored.T.eq(authored.dialogue("55:ABCD"), "AUTHORED", "native script reads exact authored label") + rows[#rows + 1] = { mode = "authored-case", after = authored.e.after, ids = authored.e.ids } + authored.close() end local corpus = F.new(root, backend, version, api, format, "corpus", "PORTABLE_TEXT", "unused") corpus.T.eq(#corpus.result.errors, 0, "source corpus loads atomically") diff --git a/tests/text_sources/corpus.lua b/tests/text_sources/corpus.lua index cfffeda7f..267af303a 100644 --- a/tests/text_sources/corpus.lua +++ b/tests/text_sources/corpus.lua @@ -39,6 +39,9 @@ local values = { "{A_BUTTON}", "{PKMN}", } +values[#values + 1] = "\\{VAR1}" +values[#values + 1] = "\\{STR_VAR_1}" +values[#values + 1] = "\\\\{VAR1}" local words = [[ UNKNOWN KUN VERSION MAGMA AQUA MAXIE ARCHIE GROUDON KYOGRE EVIL_TEAM GOOD_TEAM EVIL_LEADER GOOD_LEADER EVIL_LEGENDARY GOOD_LEGENDARY diff --git a/tests/text_sources/fixture.lua b/tests/text_sources/fixture.lua index 347feb1cb..5dcafade7 100644 --- a/tests/text_sources/fixture.lua +++ b/tests/text_sources/fixture.lua @@ -68,7 +68,7 @@ function F.new(root, backend, version, api, format, mode, id, source, body, prep end if gen == 2 then local b, a = key:match("^(%x%x):(%x%x%x%x)$") - if b then + if b and key == key:lower() then key = ("TEXT_S%02X_%04X"):format(tonumber(b, 16), tonumber(a, 16)) end end @@ -104,7 +104,23 @@ function F.new(root, backend, version, api, format, mode, id, source, body, prep return table.concat(parts) end if gen == 3 and value then - return value:gsub("{VAR([123])}", "{STR_VAR_%1}") + local parts, i = {}, 1 + while i <= #value do + if value:sub(i, i) == "\\" and i < #value then + parts[#parts + 1] = value:sub(i, i + 1) + i = i + 2 + else + local slot = value:sub(i):match("^{VAR([123])}") + if slot then + parts[#parts + 1] = "{STR_VAR_" .. slot .. "}" + i = i + 6 + else + parts[#parts + 1] = value:sub(i, i) + i = i + 1 + end + end + end + return table.concat(parts) end return value end @@ -140,7 +156,7 @@ function F.new(root, backend, version, api, format, mode, id, source, body, prep local runner = require("src.script.ScriptRunner").new(game) if gen == 2 then local b, a = key:match("^(%x%x):(%x%x%x%x)$") - if b then + if b and key == key:lower() then key = ("TEXT_S%02X_%04X"):format(tonumber(b, 16), tonumber(a, 16)) end end From cedba8db00e3639bd00a8f996ad949bf45fd320d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:12:16 +0200 Subject: [PATCH 67/86] Document validated portable text-source runtime and boundaries --- docs/api3-portability.md | 5 +++-- docs/api3-text-sources.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 1e67f31b7..81c50b687 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -51,8 +51,9 @@ 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 +existing unified adapters. Script `text` uses the shared source-string adapter +described in [portable text sources](api3-text-sources.md). Other registries +retain their documented record shapes. 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. diff --git a/docs/api3-text-sources.md b/docs/api3-text-sources.md index cf9be315a..51f531fef 100644 --- a/docs/api3-text-sources.md +++ b/docs/api3-text-sources.md @@ -75,3 +75,38 @@ Use the isolated combined-fork launcher and temporarily install the example; for Gold, supply `id.txt` containing an existing cartridge `bank:address` ID. The driver opens, renders and closes real native dialogue, using an in-memory New Game. It performs no normal SAVE and makes no full-playthrough claim. + +## Validated snapshot + +Feature runtime `13598190777c0de6fac79cbe0f2663b8f8c56725`; combined runtime +`a7a4091be9f4b631d54cd6f57ce1d33c5d8bed46`: + +- Source/native contracts **3,700/3,700**; ownership/legacy/retirement contracts + **234/234**. Both host opt-in modes are covered, native API-1/2 bytes retain + their old behavior, and DS keeps its native text handles. +- **32 isolated SDK workers / 3,148 paired observations** per feature/combined + snapshot. Eight versions, reference API 3 versus host API 3 and API 2/unified. + The unchanged consumer covers 196 observations per GB/GBA game and 198 per + Gen-2 game. The corpus includes all public GBA placeholder/symbol names, + dialect-specific battle names, near misses, escaped literals and raw controls. +- Feature **131/141 engine suites**, SDK **3/3**, B30 pass. The same ten baseline + suites produce exactly the same **26 FAIL lines** as #169; independent + baseline repairs are in #128–134. Combined **186/186**, SDK **3/3**, B30 pass. +- Native LÖVE 11.5, existing imported **Gold, FireRed, Emerald**: unchanged + installed consumer, real title/New Game, actual imported Gold text at + `TEXT_S55_4067`, native lowered writetext/GBA script commands, player/string + buffer expansion, real textbox rendering, paragraph pages, A-close and script + resume, frozen late writes. The owned example was removed with hash guards. + This is an in-memory dialogue smoke, with no normal SAVE, full playthrough, + fresh full-ROM reimport or pixel-equivalence claim. +- The unchanged #169 negative control writes a shadow `55:4067` while the native + VM still prints its old physical text; a common GBA string variable remains + literal. Both discrepancies are observed through real readers. +- Imported Red regressions: battle **468/468**, save conversion **95/95**, parity + J **73/73**, K **22/22**. No external binary save fixture supplied. +- New Lua formatting, workflow actionlint, Python compile and diff checks pass. + No ROM, imported cache, save or private mod is included. + +Later documentation-only commits do not change the runtime tested above. +Hosted CI status is reported separately in the PR; local passing runs do not +imply GitHub Actions executed or passed. From 8f29eee2a1364ecf74738db4ac49c42eff8e63e5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:26:16 +0200 Subject: [PATCH 68/86] Align native label-prose formatting with the shared Gen-2 contract --- src/battle/BattleState.lua | 32 +----------- src/core/RomText.lua | 52 +++++++++++++++++++ tests/engine/rom_text_format.lua | 19 +++++++ tests/rom_text/format_cases.lua | 44 ++++++++++++++++ tests/rom_text/format_worker.lua | 6 +++ tools/rom-text-contract-check/check-format.py | 47 +++++++++++++++++ 6 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 src/core/RomText.lua create mode 100644 tests/engine/rom_text_format.lua create mode 100644 tests/rom_text/format_cases.lua create mode 100644 tests/rom_text/format_worker.lua create mode 100644 tools/rom-text-contract-check/check-format.py diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9a614b159..966ff44ee 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -87,37 +87,7 @@ for name, method in pairs(BattleCommands.methods) do BattleState[name] = method -- _EnemysWeakText extracts as "The enemy's weak!\nGet'm! " with nowhere -- to put the name -- so the engine's own wording stands in rather than -- printing a sentence with a hole in it. -local function fromRom(data, label, fallback, ...) - local text = data and data.text and data.text[label] - if not text then return Strings(fallback, ...) end - local args = { ... } - if #args == 0 then return text end - - local slots, named = 0, 0 - for token in text:gmatch("%b{}") do - slots = slots + 1 - if token == "{PLAYER}" or token == "{RIVAL}" then named = named + 1 end - end - local fillNamed - if #args == slots then - fillNamed = true - elseif #args == slots - named then - fillNamed = false - else - return Strings(fallback, ...) - end - - local index = 0 - return (text:gsub("%b{}", function(token) - if not fillNamed and (token == "{PLAYER}" or token == "{RIVAL}") then - return token - end - index = index + 1 - local value = args[index] - if value == nil then return token end - return tostring(value) - end)) -end +local fromRom = require("src.core.RomText") function BattleState:romText(label, fallback, ...) return fromRom(self.data, label, fallback, ...) diff --git a/src/core/RomText.lua b/src/core/RomText.lua new file mode 100644 index 000000000..e208eb54b --- /dev/null +++ b/src/core/RomText.lua @@ -0,0 +1,52 @@ +-- Native label-keyed prose, formatted before the ordinary textbox resolves +-- player/rival tokens. This is distinct from bank/address script text. +local Strings = require("src.core.Strings") +local ENDING = { ["{DONE}"] = true, ["{PROMPT}"] = true } +return function(data, label, fallback, ...) + local text = data and data.text and data.text[label] + if text == nil then + if fallback == nil then + error("ROM text " .. tostring(label) .. " is not in the cache", 2) + end + return Strings(fallback, ...) + end + local values = { ... } + if #values == 0 then + return text + end + if #values == 1 and type(values[1]) == "table" then + local named = values[1] + return ( + text:gsub("%b{}", function(token) + local value = named[token] or named[token:sub(2, -2)] + return value == nil and token or tostring(value) + end) + ) + end + local count, deferred = 0, 0 + for token in text:gmatch("%b{}") do + if not ENDING[token] then + count = count + 1 + if token == "{PLAYER}" or token == "{RIVAL}" then + deferred = deferred + 1 + end + end + end + local fillNames = #values == count + if not fillNames and #values ~= count - deferred then + if fallback == nil then + error("ROM text " .. tostring(label) .. " does not fit the call", 2) + end + return Strings(fallback, ...) + end + local index = 0 + return ( + text:gsub("%b{}", function(token) + if ENDING[token] or not fillNames and (token == "{PLAYER}" or token == "{RIVAL}") then + return token + end + index = index + 1 + return values[index] == nil and token or tostring(values[index]) + end) + ) +end diff --git a/tests/engine/rom_text_format.lua b/tests/engine/rom_text_format.lua new file mode 100644 index 000000000..a45d1dc2b --- /dev/null +++ b/tests/engine/rom_text_format.lua @@ -0,0 +1,19 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local rows = dofile("tests/rom_text/format_cases.lua").run() +local byName = {} +for _, row in ipairs(rows) do + byName[row.name] = row.text +end +T.eq(byName["one-slot"], "GOLD wakes!", "native ordinal slot substitution") +T.eq(byName["defer-player"], "{PLAYER} watches GOLD", "player token remains for textbox") +T.eq(byName.done, "GOLD wakes!{DONE}", "ending marker is not an argument slot") +T.eq(byName.prompt, "GOLD wakes!{PROMPT}", "prompt marker is not an argument slot") +T.eq(byName.named, "{PLAYER} GOLD {RIVAL}", "native named values substitute only supplied tokens") +T.eq(byName["printf-is-not-a-slot"], "GOLD fallback", "printf-shaped prose retains native fallback policy") +local b = setmetatable( + { data = { text = { TEST = "{USER}{PROMPT}" } } }, + { __index = require("src.battle.BattleState") } +) +T.eq(b:romText("TEST", "%s fallback", "GOLD"), "GOLD{PROMPT}", "actual battle reader uses common formatter") +T.finish("native label-prose formatter") diff --git a/tests/rom_text/format_cases.lua b/tests/rom_text/format_cases.lua new file mode 100644 index 000000000..d7ac3203e --- /dev/null +++ b/tests/rom_text/format_cases.lua @@ -0,0 +1,44 @@ +local C = {} +function C.run() + local T, format = require("tests.modkit"), require("src.core.RomText") + local rows = {} + local cases = { + { "plain", "Native", "fallback", {} }, + { "no-fallback", "Native", nil, {} }, + { "missing", nil, "fallback %s", { "VALUE" } }, + { "one-slot", "{USER} wakes!", "%s fallback", { "GOLD" } }, + { "two-slots", "{USER} used {MOVE}!", "%s/%s fallback", { "GOLD", "TACKLE" } }, + { "defer-player", "{PLAYER} watches {USER}", "%s fallback", { "GOLD" } }, + { "fill-player", "{PLAYER} watches {USER}", "%s/%s fallback", { "PROBE", "GOLD" } }, + { "defer-both", "{PLAYER} {USER} {RIVAL}", "%s fallback", { "GOLD" } }, + { "named", "{PLAYER} {USER} {RIVAL}", "fallback", { { USER = "GOLD" } } }, + { "named-braces", "{USER}/{TARGET}", "fallback", { { ["{USER}"] = "GOLD", TARGET = 42 } } }, + { "named-missing", "{USER}/{TARGET}", "fallback", { {} } }, + { "named-false", "{USER}/{TARGET}", "fallback", { { USER = false, TARGET = 0 } } }, + { "done", "{USER} wakes!{DONE}", "%s fallback", { "GOLD" } }, + { "prompt", "{USER} wakes!{PROMPT}", "%s fallback", { "GOLD" } }, + { "endings-defer", "{PLAYER} watches {USER}{DONE}", "%s fallback", { "GOLD" } }, + { "endings-named", "{USER}{DONE}", "fallback", { { USER = "GOLD", DONE = "literal" } } }, + { "mismatch", "Native", "%s fallback", { "GOLD" } }, + { "percent-literal", "100% Native", "fallback", {} }, + { "printf-is-not-a-slot", "%s wakes!", "%s fallback", { "GOLD" } }, + { "broken-tag", "prefix {USER", "%s fallback", { "GOLD" } }, + { "slots-percent", "{USER} has 100%", "%s fallback", { "GOLD" } }, + { "missing-required", nil, nil, {} }, + { "mismatch-required", "Native", nil, { "GOLD" } }, + } + for _, case in ipairs(cases) do + local name, native, fallback, args = unpack(case) + local data = { text = { TEST = native } } + local ok, result = pcall(format, data, "TEST", fallback, unpack(args)) + local failed = name == "missing-required" or name == "mismatch-required" + T.eq(ok, not failed, "native formatter outcome " .. name) + if not ok then + T.check(tostring(result):find("ROM text TEST", 1, true) ~= nil, "missing prose names its native label") + end + T.eq(data.text.TEST, native, "formatting leaves source text intact") + rows[#rows + 1] = { name = name, ok = ok, text = ok and result or nil } + end + return rows +end +return C diff --git a/tests/rom_text/format_worker.lua b/tests/rom_text/format_worker.lua new file mode 100644 index 000000000..8466a9b2b --- /dev/null +++ b/tests/rom_text/format_worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, version = unpack(arg) +require("src.core.GameVersion").set(version) +local rows = dofile(root .. "/tests/rom_text/format_cases.lua").run() +print("ROM_PROSE_FORMAT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native prose-format boundary") diff --git a/tools/rom-text-contract-check/check-format.py b/tools/rom-text-contract-check/check-format.py new file mode 100644 index 000000000..367ad7de0 --- /dev/null +++ b/tools/rom-text-contract-check/check-format.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Compare native GB/Gen-2 prose formatting before wiring the public registry.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91' + + +def git(root, *args): + return subprocess.check_output(['git', '-C', str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--gen1', type=Path, required=True) + p.add_argument('--gen2', type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) + p.add_argument('--report', type=Path) + a = p.parse_args() + source, host = a.gen1.resolve(), a.gen2.resolve() + if git(source, 'rev-parse', 'HEAD') != REFERENCE or git(source, 'status', '--porcelain'): + p.error('Gen1 must be clean at ' + REFERENCE) + results = [] + for version in ['red', 'gold', 'silver', 'crystal']: + paired = [] + for backend, engine in [('gen1', source), ('gen2', host)]: + r = subprocess.run([a.lua, str(host / 'tests/rom_text/format_worker.lua'), str(host), version], + cwd=engine, text=True, capture_output=True, timeout=30) + rows = [x[len('ROM_PROSE_FORMAT '):] for x in r.stdout.splitlines() + if x.startswith('ROM_PROSE_FORMAT ')] + if r.returncode or len(rows) != 1: + raise RuntimeError(f'{backend}/{version}: {r.stdout}{r.stderr}') + observation = json.loads(rows[0]) + paired.append(observation) + results.append({'backend': backend, 'version': version, 'observations': observation}) + assert paired[0] == paired[1], f'native prose-format mismatch for {version}' + print(f'PASS {version}: {len(paired[0])} native prose-format observations') + if a.report: + a.report.write_text(json.dumps({'reference_ref': REFERENCE, 'host_ref': git(host, 'rev-parse', 'HEAD'), + 'host_dirty': bool(git(host, 'status', '--porcelain')), 'results': results}, indent=2) + '\n') + + +if __name__ == '__main__': + main() From 6e6a92f257f982e60ef4d1e7b703c3a20ea24e93 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:32:17 +0200 Subject: [PATCH 69/86] Record exact native prose-label provenance for Gen-2 and GBA profiles --- src/mods/RomTextLabels.lua | 26 + src/mods/rom_text_labels/crystal.lua | 1016 +++ src/mods/rom_text_labels/emerald.lua | 6870 +++++++++++++++++ src/mods/rom_text_labels/firered.lua | 3708 +++++++++ src/mods/rom_text_labels/gold.lua | 894 +++ src/mods/rom_text_labels/silver.lua | 894 +++ tests/engine/rom_text_labels.lua | 22 + tools/rom-text-contract-check/README.md | 25 + .../rom-text-contract-check/export-labels.lua | 11 + .../generate-labels.py | 77 + 10 files changed, 13543 insertions(+) create mode 100644 src/mods/RomTextLabels.lua create mode 100644 src/mods/rom_text_labels/crystal.lua create mode 100644 src/mods/rom_text_labels/emerald.lua create mode 100644 src/mods/rom_text_labels/firered.lua create mode 100644 src/mods/rom_text_labels/gold.lua create mode 100644 src/mods/rom_text_labels/silver.lua create mode 100644 tests/engine/rom_text_labels.lua create mode 100644 tools/rom-text-contract-check/README.md create mode 100644 tools/rom-text-contract-check/export-labels.lua create mode 100644 tools/rom-text-contract-check/generate-labels.py diff --git a/src/mods/RomTextLabels.lua b/src/mods/RomTextLabels.lua new file mode 100644 index 000000000..5671cd943 --- /dev/null +++ b/src/mods/RomTextLabels.lua @@ -0,0 +1,26 @@ +-- Exact import-symbol facts are loaded only when a native prose consumer or +-- registry needs them. Never identify a label by matching its rendered words. +local Labels = {} +local cache = {} +local supported = { gold = true, silver = true, crystal = true, firered = true, emerald = true } +function Labels.forVersion(version) + if not supported[version] then + return nil + end + if not cache[version] then + cache[version] = require("src.mods.rom_text_labels." .. version) + end + return cache[version] +end +function Labels.address(version, name) + local rows = Labels.forVersion(version) + if not rows or not rows.text then + return nil + end + local offset = rows.battle[name] + if offset then + return offset, true + end + return rows.text[name], false +end +return Labels diff --git a/src/mods/rom_text_labels/crystal.lua b/src/mods/rom_text_labels/crystal.lua new file mode 100644 index 000000000..9aa3bda8e --- /dev/null +++ b/src/mods/rom_text_labels/crystal.lua @@ -0,0 +1,1016 @@ +-- Generated label/address facts for crystal; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + ["AlreadyAsleepText"] = true, + ["AlreadyConfusedText"] = true, + ["AlreadyParalyzedText"] = true, + ["AlreadyPoisonedText"] = true, + ["AttackMissed2Text"] = true, + ["AttackMissedText"] = true, + ["BadlyPoisonedText"] = true, + ["BattleText_AnEGGCantBattle"] = true, + ["BattleText_CantEscape"] = true, + ["BattleText_CantEscape2"] = true, + ["BattleText_EnemyFled"] = true, + ["BattleText_EnemyIsAboutToUseWillPlayerChangeMon"] = true, + ["BattleText_EnemyMonFainted"] = true, + ["BattleText_EnemySentOut"] = true, + ["BattleText_EnemyWasDefeated"] = true, + ["BattleText_GotAwaySafely"] = true, + ["BattleText_ItemHealedConfusion"] = true, + ["BattleText_ItemsCantBeUsedHere"] = true, + ["BattleText_LinkErrorBattleCanceled"] = true, + ["BattleText_MonCantBeRecalled"] = true, + ["BattleText_MonFainted"] = true, + ["BattleText_MonHasNoMovesLeft"] = true, + ["BattleText_MonIsAlreadyOut"] = true, + ["BattleText_MonsLightScreenFell"] = true, + ["BattleText_MonsReflectFaded"] = true, + ["BattleText_NoTimeLeftToday"] = true, + ["BattleText_PlayerPickedUpPayDayMoney"] = true, + ["BattleText_RainContinuesToFall"] = true, + ["BattleText_SafeguardFaded"] = true, + ["BattleText_StringBuffer1GrewToLevel"] = true, + ["BattleText_TargetRecoveredWithItem"] = true, + ["BattleText_TargetWasHitByFutureSight"] = true, + ["BattleText_TargetsEncoreEnded"] = true, + ["BattleText_TheMoveIsDisabled"] = true, + ["BattleText_TheRainStopped"] = true, + ["BattleText_TheSandstormRages"] = true, + ["BattleText_TheSandstormSubsided"] = true, + ["BattleText_TheSunlightFaded"] = true, + ["BattleText_TheSunlightIsStrong"] = true, + ["BattleText_TheresNoEscapeFromTrainerBattle"] = true, + ["BattleText_TheresNoPPLeftForThisMove"] = true, + ["BattleText_TheresNoWillToBattle"] = true, + ["BattleText_UseNextMon"] = true, + ["BattleText_UserFledUsingAStringBuffer1"] = true, + ["BattleText_UserHurtBySpikes"] = true, + ["BattleText_UserRecoveredPPUsing"] = true, + ["BattleText_UserWasReleasedFromStringBuffer1"] = true, + ["BattleText_UsersHurtByStringBuffer1"] = true, + ["BattleText_UsersStringBuffer1Activated"] = true, + ["BattleText_WildFled"] = true, + ["BattleText_WildMonIsAngry"] = true, + ["BattleText_WildMonIsEating"] = true, + ["BeatUpAttackText"] = true, + ["BecameConfusedText"] = true, + ["BeganToNapText"] = true, + ["BellChimedText"] = true, + ["BellyDrumText"] = true, + ["BlewSpikesText"] = true, + ["BlownAwayText"] = true, + ["BracedItselfText"] = true, + ["BugContestPrizeNoRoomText"] = true, + ["ButItFailedText"] = true, + ["CantEscapeNowText"] = true, + ["ClampedByText"] = true, + ["CoinVendor_Buy500CoinsText"] = true, + ["CoinVendor_Buy50CoinsText"] = true, + ["CoinVendor_CancelText"] = true, + ["CoinVendor_CoinCaseFullText"] = true, + ["CoinVendor_IntroText"] = true, + ["CoinVendor_NoCoinCaseText"] = true, + ["CoinVendor_NotEnoughMoneyText"] = true, + ["CoinVendor_WelcomeText"] = true, + ["CoinsScatteredText"] = true, + ["ConfusedNoMoreText"] = true, + ["ContestResults_ConsolationPrizeText"] = true, + ["ContestResults_DidNotWinText"] = true, + ["ContestResults_JoinUsNextTimeText"] = true, + ["ContestResults_PartyFullText"] = true, + ["ContestResults_PlayerWonAPrizeText"] = true, + ["ContestResults_ReadyToJudgeText"] = true, + ["ContestResults_ReturnPartyText"] = true, + ["CopiedStatsText"] = true, + ["CoveredByVeilText"] = true, + ["CrashedText"] = true, + ["CriticalHitText"] = true, + ["DefrostedOpponentText"] = true, + ["DestinyBondEffectText"] = true, + ["DidntAffect1Text"] = true, + ["DidntAffect2Text"] = true, + ["DifficultBookshelfText"] = true, + ["DisabledMoveText"] = true, + ["DisabledNoMoreText"] = true, + ["DoesntAffectText"] = true, + ["DownpourText"] = true, + ["DraggedOutText"] = true, + ["DreamEatenText"] = true, + ["EliminatedStatsText"] = true, + ["EnduredText"] = true, + ["EnemyHitTimesText"] = true, + ["EvadedText"] = true, + ["FastAsleepText"] = true, + ["FellAsleepText"] = true, + ["FellInLoveText"] = true, + ["FireSpinTrapText"] = true, + ["FledFromBattleText"] = true, + ["FledInFearText"] = true, + ["FlinchedText"] = true, + ["ForesawAttackText"] = true, + ["FrozenSolidText"] = true, + ["FullyParalyzedText"] = true, + ["GettingPumpedText"] = true, + ["GotAnEncoreText"] = true, + ["GotMoneyForWinningText"] = true, + ["GymStatue_CityGymText"] = true, + ["GymStatue_WinningTrainersText"] = true, + ["HPIsFullText"] = true, + ["HappinessText1"] = true, + ["HappinessText2"] = true, + ["HappinessText3"] = true, + ["HasANightmareText"] = true, + ["HasNoPPLeftText"] = true, + ["HasSubstituteText"] = true, + ["HomepageText"] = true, + ["HookedPokemonAttackedText"] = true, + ["HungOnText"] = true, + ["HurtByBurnText"] = true, + ["HurtByCurseText"] = true, + ["HurtByPoisonText"] = true, + ["HurtItselfText"] = true, + ["IdentifiedText"] = true, + ["IgnoredOrders2Text"] = true, + ["IgnoredOrdersText"] = true, + ["IgnoredSleepingText"] = true, + ["InLoveWithText"] = true, + ["IncenseBurnerText"] = true, + ["InfatuationText"] = true, + ["IsConfusedText"] = true, + ["ItFailedText"] = true, + ["LeechSeedSapsText"] = true, + ["LightScreenEffectText"] = true, + ["LoafingAroundText"] = true, + ["LookTownMapText"] = true, + ["LostAgainstText"] = true, + ["MadeSubstituteText"] = true, + ["MagazineBookshelfText"] = true, + ["MagnitudeText"] = true, + ["MartSignText"] = true, + ["MerchandiseShelfText"] = true, + ["MimicLearnedMoveText"] = true, + ["MirrorMoveFailedText"] = true, + ["MistText"] = true, + ["MustRechargeText"] = true, + ["NoPPLeftText"] = true, + ["NotVeryEffectiveText"] = true, + ["NothingHappenedText"] = true, + ["NurseAskHealText"] = true, + ["NurseDayText"] = true, + ["NurseGoodbyeText"] = true, + ["NurseMornText"] = true, + ["NurseNiteText"] = true, + ["NursePokerusText"] = true, + ["NurseReturnPokemonText"] = true, + ["NurseTakePokemonText"] = true, + ["OneHitKOText"] = true, + ["ParalyzedText"] = true, + ["PerishCountText"] = true, + ["PictureBookshelfText"] = true, + ["PlayerHitTimesText"] = true, + ["PokeComNurseDayText"] = true, + ["PokeComNurseMornText"] = true, + ["PokeComNurseNiteText"] = true, + ["PokeComNursePokerusText"] = true, + ["PokecenterSignText"] = true, + ["PokemonFellFromTreeText"] = true, + ["PresentFailedText"] = true, + ["ProtectedByMistText"] = true, + ["ProtectedByText"] = true, + ["ProtectedItselfText"] = true, + ["ProtectingItselfText"] = true, + ["PutACurseText"] = true, + ["RageBuildingText"] = true, + ["ReceivedItemText"] = true, + ["RecoilText"] = true, + ["RecoveredUsingText"] = true, + ["ReflectEffectText"] = true, + ["RegainedHealthText"] = true, + ["RegisteredNumber1Text"] = true, + ["RegisteredNumber2Text"] = true, + ["ReleasedByText"] = true, + ["RestedText"] = true, + ["SafeguardProtectText"] = true, + ["SandstormBrewedText"] = true, + ["SandstormHitsText"] = true, + ["SentAllToMomText"] = true, + ["SentHalfToMomText"] = true, + ["SentSomeToMomText"] = true, + ["SharedPainText"] = true, + ["ShedLeechSeedText"] = true, + ["SketchedText"] = true, + ["SpikesText"] = true, + ["SpiteEffectText"] = true, + ["StartPerishText"] = true, + ["StartedNightmareText"] = true, + ["StoleText"] = true, + ["StoringEnergyText"] = true, + ["SubFadedText"] = true, + ["SubTookDamageText"] = true, + ["SuckedHealthText"] = true, + ["SunGotBrightText"] = true, + ["SuperEffectiveText"] = true, + ["TVText"] = true, + ["TeamRocketOathText"] = true, + ["Text_BallCaught"] = true, + ["Text_BattleEffectActivate"] = true, + ["Text_BattleFoeEffectActivate"] = true, + ["Text_BattleUser"] = true, + ["Text_BreedHuh"] = true, + ["Text_Gained"] = true, + ["Text_MoveForgetCount"] = true, + ["Text_NPCTraded"] = true, + ["Text_PlayedPokeFlute"] = true, + ["TiedAgainstText"] = true, + ["TooWeakSubText"] = true, + ["TookAimText"] = true, + ["TookDownWithItText"] = true, + ["TransformedText"] = true, + ["TransformedTypeText"] = true, + ["TrashCanText"] = true, + ["TurnedAwayText"] = true, + ["UnaffectedText"] = true, + ["UnleashedEnergyText"] = true, + ["UnusedRivalLossText"] = true, + ["UnusedRivalWinText"] = true, + ["UsedBindText"] = true, + ["WantsToBattleText"] = true, + ["WasBurnedText"] = true, + ["WasDefrostedText"] = true, + ["WasDisabledText"] = true, + ["WasFrozenText"] = true, + ["WasPoisonedText"] = true, + ["WasSeededText"] = true, + ["WentToSleepText"] = true, + ["WhirlpoolTrapText"] = true, + ["WildCelebiAppearedText"] = true, + ["WildPokemonAppearedText"] = true, + ["WindowText"] = true, + ["WokeUpText"] = true, + ["WontDropAnymoreText"] = true, + ["WontObeyText"] = true, + ["WontRiseAnymoreText"] = true, + ["WrappedByText"] = true, + ["_ActorNameText"] = true, + ["_AlreadyASaveFileText"] = true, + ["_AlreadySetUpText"] = true, + ["_AlreadySurfingText"] = true, + ["_AlreadyUsingStrengthText"] = true, + ["_AnEggCantHoldAnItemText"] = true, + ["_AnimationTypeText"] = true, + ["_AnotherSaveFileText"] = true, + ["_AreWeGeniusesText"] = true, + ["_AreYouABoyOrAreYouAGirlText"] = true, + ["_AskCutText"] = true, + ["_AskDeleteMoveText"] = true, + ["_AskFloorElevatorText"] = true, + ["_AskForgetMoveText"] = true, + ["_AskGiveNicknameText"] = true, + ["_AskHeadbuttText"] = true, + ["_AskItemMoveText"] = true, + ["_AskQuantityThrowAwayText"] = true, + ["_AskRockSmashText"] = true, + ["_AskStrengthText"] = true, + ["_AskSurfText"] = true, + ["_AskThrowAwayText"] = true, + ["_AskWaterfallText"] = true, + ["_AskWhirlpoolText"] = true, + ["_BGEventText"] = true, + ["_BTExcuseMeText"] = true, + ["_BackAlreadyText"] = true, + ["_BadgeRequiredText"] = true, + ["_BallAlmostHadItText"] = true, + ["_BallAppearedCaughtText"] = true, + ["_BallBlockedText"] = true, + ["_BallBoxFullText"] = true, + ["_BallBrokeFreeText"] = true, + ["_BallDodgedText"] = true, + ["_BallDontBeAThiefText"] = true, + ["_BallMissedText"] = true, + ["_BallSentToPCText"] = true, + ["_BallSoCloseText"] = true, + ["_BargainShopComeAgainText"] = true, + ["_BargainShopFinalPriceText"] = true, + ["_BargainShopIntroText"] = true, + ["_BargainShopNoFundsText"] = true, + ["_BargainShopPackFullText"] = true, + ["_BargainShopSoldOutText"] = true, + ["_BargainShopThanksText"] = true, + ["_BattleDugText"] = true, + ["_BattleFlewText"] = true, + ["_BattleGlowingText"] = true, + ["_BattleLoweredHeadText"] = true, + ["_BattleMadeWhirlwindText"] = true, + ["_BattleMonNickCommaText"] = true, + ["_BattleMonNicknameText"] = true, + ["_BattleStatFellText"] = true, + ["_BattleStatSharplyFellText"] = true, + ["_BattleStatWentUpText"] = true, + ["_BattleStatWentWayUpText"] = true, + ["_BattleTookSunlightText"] = true, + ["_BattleTowerReturnWhenReadyText"] = true, + ["_BenFernText1"] = true, + ["_BenFernText2A"] = true, + ["_BenFernText2B"] = true, + ["_BenFernText3A"] = true, + ["_BenFernText3B"] = true, + ["_BenIntroText1"] = true, + ["_BenIntroText2"] = true, + ["_BenIntroText3"] = true, + ["_BidsFarewellToMonText"] = true, + ["_BlindingFlashText"] = true, + ["_BlueCardBalanceText"] = true, + ["_BoostedExpPointsText"] = true, + ["_BootedHMText"] = true, + ["_BootedTMText"] = true, + ["_BouldersMayMoveText"] = true, + ["_BouldersMoveText"] = true, + ["_BreedAppearsToCareForText"] = true, + ["_BreedAskNicknameText"] = true, + ["_BreedBrimmingWithEnergyText"] = true, + ["_BreedClearboxText"] = true, + ["_BreedEggHatchText"] = true, + ["_BreedFriendlyText"] = true, + ["_BreedNoInterestText"] = true, + ["_BreedShowsInterestText"] = true, + ["_BreedingIsNotPossibleText"] = true, + ["_BuenaAskWhichPrizeText"] = true, + ["_BuenaComeAgainText"] = true, + ["_BuenaHereYouGoText"] = true, + ["_BuenaIsThatRightText"] = true, + ["_BuenaNoRoomText"] = true, + ["_BuenaNotEnoughPointsText"] = true, + ["_BuenaOffTheAirText"] = true, + ["_BuenaRadioMidnightText1"] = true, + ["_BuenaRadioMidnightText10"] = true, + ["_BuenaRadioMidnightText2"] = true, + ["_BuenaRadioMidnightText3"] = true, + ["_BuenaRadioMidnightText4"] = true, + ["_BuenaRadioMidnightText5"] = true, + ["_BuenaRadioMidnightText6"] = true, + ["_BuenaRadioMidnightText7"] = true, + ["_BuenaRadioMidnightText8"] = true, + ["_BuenaRadioMidnightText9"] = true, + ["_BuenaRadioText1"] = true, + ["_BuenaRadioText2"] = true, + ["_BuenaRadioText3"] = true, + ["_BuenaRadioText4"] = true, + ["_BuenaRadioText5"] = true, + ["_BuenaRadioText6"] = true, + ["_BuenaRadioText7"] = true, + ["_BugCatchingContestIsOverText"] = true, + ["_BugCatchingContestTimeUpText"] = true, + ["_BurnWasHealedText"] = true, + ["_ButNoSpaceText"] = true, + ["_CameToItsSensesText"] = true, + ["_CanCutText"] = true, + ["_CantAcceptEggText"] = true, + ["_CantCarryItemText"] = true, + ["_CantGetOffBikeText"] = true, + ["_CantRegisterText"] = true, + ["_CantSurfText"] = true, + ["_CantUseDigText"] = true, + ["_CantUseItemText"] = true, + ["_CantUseTeleportText"] = true, + ["_CardFlipChooseACardText"] = true, + ["_CardFlipDarnText"] = true, + ["_CardFlipNotEnoughCoinsText"] = true, + ["_CardFlipPlaceYourBetText"] = true, + ["_CardFlipPlayAgainText"] = true, + ["_CardFlipPlayWithThreeCoinsText"] = true, + ["_CardFlipShuffledText"] = true, + ["_CardFlipYeahText"] = true, + ["_CardFolderOpenText"] = true, + ["_CardWasListedText"] = true, + ["_CaughtAskNicknameText"] = true, + ["_ChangeBoxSaveText"] = true, + ["_ChangeWhichNumberText"] = true, + ["_CheckCDMAAdapterText"] = true, + ["_CheckCellPhoneAdapterText"] = true, + ["_CheckDDIPHSAdapterText"] = true, + ["_CheckDOCOMOPHSAdapterText"] = true, + ["_CheckMobileAdapterText"] = true, + ["_ClearAllSaveDataText"] = true, + ["_ClearTimeLimitText"] = true, + ["_ClockHasResetText"] = true, + ["_ClockIsThisOKText"] = true, + ["_ClockSetWithControlPadText"] = true, + ["_ClockTimeMayBeWrongText"] = true, + ["_ClosingLinkText"] = true, + ["_CoinCaseCountText"] = true, + ["_ComeAgainText"] = true, + ["_ComeBackLaterText"] = true, + ["_ComeBackText"] = true, + ["_CompatibilityShouldTheyBreedText"] = true, + ["_ConfirmPasscodeText"] = true, + ["_CongratulationsYourPokemonText"] = true, + ["_ContainedMoveText"] = true, + ["_ContestAlreadyCaughtText"] = true, + ["_ContestAskSwitchText"] = true, + ["_ContestCaughtMonText"] = true, + ["_ContestJudging_FirstPlaceScoreText"] = true, + ["_ContestJudging_FirstPlaceText"] = true, + ["_ContestJudging_SecondPlaceScoreText"] = true, + ["_ContestJudging_SecondPlaceText"] = true, + ["_ContestJudging_ThirdPlaceScoreText"] = true, + ["_ContestJudging_ThirdPlaceText"] = true, + ["_CoordinatesEventText"] = true, + ["_CorruptedEventText"] = true, + ["_CuredOfPoisonText"] = true, + ["_CutNothingText"] = true, + ["_DSTIsThatOKText"] = true, + ["_DayCareLadyIntroEggText"] = true, + ["_DayCareLadyIntroText"] = true, + ["_DayCareManIntroEggText"] = true, + ["_DayCareManIntroText"] = true, + ["_DaycareDummyText"] = true, + ["_DeleteSavedLoginPasswordText"] = true, + ["_DeletedTheLoginPasswordText"] = true, + ["_DeleterAskWhichMonText"] = true, + ["_DeleterAskWhichMoveText"] = true, + ["_DeleterEggText"] = true, + ["_DeleterForgotMoveText"] = true, + ["_DeleterIntroText"] = true, + ["_DeleterNoComeAgainText"] = true, + ["_DidNotLearnMoveText"] = true, + ["_DoItMonText"] = true, + ["_DoesntConcernABoyText"] = true, + ["_EggDoesNotQualifyText"] = true, + ["_EggPhotoText"] = true, + ["_EmptyMailboxText"] = true, + ["_EndText"] = true, + ["_EndUsedMove1Text"] = true, + ["_EndUsedMove2Text"] = true, + ["_EndUsedMove3Text"] = true, + ["_EndUsedMove4Text"] = true, + ["_EndUsedMove5Text"] = true, + ["_EnemyUsedOnText"] = true, + ["_EnemyWithdrewText"] = true, + ["_EnterNewPasscodeText"] = true, + ["_EnterPasscodeText"] = true, + ["_EnterTheAmountText"] = true, + ["_EnterTheIDNoText"] = true, + ["_EvolvedIntoText"] = true, + ["_EvolvingText"] = true, + ["_ExcuseMeYoureNotReadyText"] = true, + ["_ExpPointsText"] = true, + ["_FernIntroText1"] = true, + ["_FernIntroText2"] = true, + ["_FluteWakeUpText"] = true, + ["_ForABoyText"] = true, + ["_ForAGirlText"] = true, + ["_ForYourMonSendsText"] = true, + ["_ForYourMonWillTradeText"] = true, + ["_FoundAnEggText"] = true, + ["_FoundItemText"] = true, + ["_FourZerosInvalidText"] = true, + ["_FruitBearingTreeText"] = true, + ["_FruitPackIsFullText"] = true, + ["_GearEllipseText"] = true, + ["_GearOutOfServiceText"] = true, + ["_GearTodayText"] = true, + ["_GoForItMonText"] = true, + ["_GoMonText"] = true, + ["_GoodComeBackText"] = true, + ["_GotBackMonText"] = true, + ["_GotOffBikeText"] = true, + ["_GotOnBikeText"] = true, + ["_GrewToLevelText"] = true, + ["_HaveNoRoomText"] = true, + ["_HeadbuttNothingText"] = true, + ["_HealthReturnedText"] = true, + ["_HerbShopLadyIntroText"] = true, + ["_HerbalLadyComeAgainText"] = true, + ["_HerbalLadyFinalPriceText"] = true, + ["_HerbalLadyHowManyText"] = true, + ["_HerbalLadyNoMoneyText"] = true, + ["_HerbalLadyPackFullText"] = true, + ["_HerbalLadyThanksText"] = true, + ["_HeyItsFruitText"] = true, + ["_HoldStillText"] = true, + ["_HowManyShouldIMakeText"] = true, + ["_HugeWaterfallText"] = true, + ["_IllKeepItThanksText"] = true, + ["_IllRaiseYourMonText"] = true, + ["_IncorrectPasscodeText"] = true, + ["_IsThisOKText"] = true, + ["_ItemBelongsToSomeoneElseText"] = true, + ["_ItemCantGetOnText"] = true, + ["_ItemCantHeldText"] = true, + ["_ItemCantUseOnEggText"] = true, + ["_ItemCantUseOnMonText"] = true, + ["_ItemGotOffText"] = true, + ["_ItemGotOnText"] = true, + ["_ItemLooksBitterText"] = true, + ["_ItemOakWarningText"] = true, + ["_ItemStatRoseText"] = true, + ["_ItemStorageFullText"] = true, + ["_ItemUsedText"] = true, + ["_ItemWontHaveEffectText"] = true, + ["_ItemfinderItemNearbyText"] = true, + ["_ItemfinderNopeText"] = true, + ["_ItemsDiscardedText"] = true, + ["_ItemsOakWarningText"] = true, + ["_ItemsThrowAwayText"] = true, + ["_ItemsTooImportantText"] = true, + ["_ItemsTossOutHowManyText"] = true, + ["_ItsGoingToHatchText"] = true, + ["_JustSawSomeRareMonText"] = true, + ["_KarpGuruRecordText"] = true, + ["_KnowsMoveText"] = true, + ["_LC_DragText1"] = true, + ["_LC_DragText2"] = true, + ["_LC_Text1"] = true, + ["_LC_Text10"] = true, + ["_LC_Text11"] = true, + ["_LC_Text2"] = true, + ["_LC_Text3"] = true, + ["_LC_Text4"] = true, + ["_LC_Text5"] = true, + ["_LC_Text6"] = true, + ["_LC_Text7"] = true, + ["_LC_Text8"] = true, + ["_LC_Text9"] = true, + ["_LastHealthyMonText"] = true, + ["_LearnedMoveText"] = true, + ["_LeftWithDayCareLadyText"] = true, + ["_LeftWithDayCareManText"] = true, + ["_LinkAbnormalMonText"] = true, + ["_LinkAskTradeForText"] = true, + ["_LinkTerminatedText"] = true, + ["_LinkTimeoutText"] = true, + ["_LinkTradeCantBattleText"] = true, + ["_LookAdorableDecoText"] = true, + ["_LookClefairyPosterText"] = true, + ["_LookGiantDecoText"] = true, + ["_LookJigglypuffPosterText"] = true, + ["_LookPikachuPosterText"] = true, + ["_LookTownMapText"] = true, + ["_LuckyNumberMatchPCText"] = true, + ["_LuckyNumberMatchPartyText"] = true, + ["_MagikarpGuruMeasureText"] = true, + ["_MailAlreadyHoldingItemText"] = true, + ["_MailAskSendToPCText"] = true, + ["_MailClearedPutAwayText"] = true, + ["_MailDetachedText"] = true, + ["_MailEggText"] = true, + ["_MailLoseMessageText"] = true, + ["_MailMessageLostText"] = true, + ["_MailMovedFromBoxText"] = true, + ["_MailNoSpaceText"] = true, + ["_MailPackFullText"] = true, + ["_MailSentToPCText"] = true, + ["_MailboxFullText"] = true, + ["_MainMenuTimeUnknownText"] = true, + ["_MartAskMoreText"] = true, + ["_MartBoughtText"] = true, + ["_MartCantBuyText"] = true, + ["_MartComeAgainText"] = true, + ["_MartFinalPriceText"] = true, + ["_MartHowManyText"] = true, + ["_MartNoMoneyText"] = true, + ["_MartPackFullText"] = true, + ["_MartSellHowManyText"] = true, + ["_MartSellPriceText"] = true, + ["_MartThanksText"] = true, + ["_MartWelcomeText"] = true, + ["_MayPassWhirlpoolText"] = true, + ["_MayRegisterItemText"] = true, + ["_MaySmashText"] = true, + ["_MemoryGameDarnText"] = true, + ["_MemoryGameYeahText"] = true, + ["_MobileBattleLessThanOneMinuteLeftText"] = true, + ["_MobileBattleMoreInfoText"] = true, + ["_MobileBattleMustPickThreeMonText"] = true, + ["_MobileBattleNoTimeLeftForLinkingText"] = true, + ["_MobileBattleRemainingTimeText"] = true, + ["_MobileBattleRulesText"] = true, + ["_MobileCardFolderAskDeleteOldText"] = true, + ["_MobileCardFolderAskDeleteText"] = true, + ["_MobileCardFolderAskOpenOldText"] = true, + ["_MobileCardFolderDeleteAreYouSureText"] = true, + ["_MobileCardFolderDeletedText"] = true, + ["_MobileCardFolderFinishRegisteringCardsText"] = true, + ["_MobileCardFolderIntro1Text"] = true, + ["_MobileCardFolderIntro2Text"] = true, + ["_MobileCardFolderIntro3Text"] = true, + ["_MobileCardFolderIntro4Text"] = true, + ["_MobileForPartnersMonText"] = true, + ["_MobileOnlyThreeMonMayEnterText"] = true, + ["_MobilePickThreeMonForBattleText"] = true, + ["_MobilePlayerWillTradeMonText"] = true, + ["_MobilePlayersMonTrade2Text"] = true, + ["_MobilePlayersMonTradeText"] = true, + ["_MobileStadiumEntryText"] = true, + ["_MobileStadiumSuccessText"] = true, + ["_MobileTakeGoodCareOfMonText"] = true, + ["_MobileTakeGoodCareOfText"] = true, + ["_MobileTradeCameBackText"] = true, + ["_MobileUseTheseThreeMonText"] = true, + ["_MomBankWhatDoYouWantToDoText"] = true, + ["_MomBoughtWithYourMoneyText"] = true, + ["_MomFoundADollText"] = true, + ["_MomFoundAnItemText"] = true, + ["_MomHaventSavedThatMuchText"] = true, + ["_MomHiHowAreYouText"] = true, + ["_MomInsufficientFundsInWalletText"] = true, + ["_MomIsThisAboutYourMoneyText"] = true, + ["_MomItsInPCText"] = true, + ["_MomItsInYourRoomText"] = true, + ["_MomJustDoWhatYouCanText"] = true, + ["_MomLeavingText1"] = true, + ["_MomLeavingText2"] = true, + ["_MomLeavingText3"] = true, + ["_MomLostGearBookletText"] = true, + ["_MomNotEnoughRoomInBankText"] = true, + ["_MomNotEnoughRoomInWalletText"] = true, + ["_MomSaveMoneyText"] = true, + ["_MomStartSavingMoneyText"] = true, + ["_MomStoreMoneyText"] = true, + ["_MomStoredMoneyText"] = true, + ["_MomTakeMoneyText"] = true, + ["_MomTakenMoneyText"] = true, + ["_MonNameBidsFarewellText"] = true, + ["_MonNameSentToText"] = true, + ["_MonNumberText"] = true, + ["_MonWasSentToText"] = true, + ["_MoveAskForgetText"] = true, + ["_MoveBoulderText"] = true, + ["_MoveCantForgetHMText"] = true, + ["_MoveForgotText"] = true, + ["_MoveKnowsOneText"] = true, + ["_MoveMonWOMailSaveText"] = true, + ["_MoveNameText"] = true, + ["_MysteryGiftCanceledText"] = true, + ["_MysteryGiftCommErrorText"] = true, + ["_MysteryGiftFiveADayText"] = true, + ["_MysteryGiftOneADayText"] = true, + ["_MysteryGiftSentHomeText"] = true, + ["_MysteryGiftSentText"] = true, + ["_NPCTradeAfterText1"] = true, + ["_NPCTradeAfterText2"] = true, + ["_NPCTradeAfterText3"] = true, + ["_NPCTradeAfterText4"] = true, + ["_NPCTradeCableText"] = true, + ["_NPCTradeCancelText1"] = true, + ["_NPCTradeCancelText2"] = true, + ["_NPCTradeCancelText3"] = true, + ["_NPCTradeCompleteText1"] = true, + ["_NPCTradeCompleteText2"] = true, + ["_NPCTradeCompleteText3"] = true, + ["_NPCTradeCompleteText4"] = true, + ["_NPCTradeFanfareText"] = true, + ["_NPCTradeIntroText1"] = true, + ["_NPCTradeIntroText2"] = true, + ["_NPCTradeIntroText3"] = true, + ["_NPCTradeWrongText1"] = true, + ["_NPCTradeWrongText2"] = true, + ["_NPCTradeWrongText3"] = true, + ["_NameCardLinkCancelledText"] = true, + ["_NameCardLinkCommErrorText"] = true, + ["_NameCardListedCardText"] = true, + ["_NameCardNotRegisteredCardText"] = true, + ["_NameCardReceivedCardText"] = true, + ["_NameRaterBetterNameText"] = true, + ["_NameRaterComeAgainText"] = true, + ["_NameRaterEggText"] = true, + ["_NameRaterFinishedText"] = true, + ["_NameRaterHelloText"] = true, + ["_NameRaterNamedText"] = true, + ["_NameRaterPerfectNameText"] = true, + ["_NameRaterSameNameText"] = true, + ["_NameRaterWhatNameText"] = true, + ["_NameRaterWhichMonText"] = true, + ["_NeedAtLeastThreeMonText"] = true, + ["_NewCardArrivedText"] = true, + ["_NewDexDataText"] = true, + ["_NoCoinCaseText"] = true, + ["_NoCoinsText"] = true, + ["_NoCyclingText"] = true, + ["_NoPhotoText"] = true, + ["_NoPromptText"] = true, + ["_NoRoomForEggText"] = true, + ["_NoRoomTMHMText"] = true, + ["_NotEnoughMoneyText"] = true, + ["_NotYetText"] = true, + ["_NothingHereText"] = true, + ["_NothingToChooseText"] = true, + ["_NothingToPutAwayText"] = true, + ["_NothingToSellText"] = true, + ["_OKComeBackText"] = true, + ["_OPT_AddictivelyText"] = true, + ["_OPT_AlmostPoisonouslyText"] = true, + ["_OPT_AptlyNamedText"] = true, + ["_OPT_BoldSortOfText"] = true, + ["_OPT_CuteText"] = true, + ["_OPT_EvolutionMustBeText"] = true, + ["_OPT_ExcitingText"] = true, + ["_OPT_FlippedOutText"] = true, + ["_OPT_FriendlyText"] = true, + ["_OPT_FrighteningText"] = true, + ["_OPT_GroovyText"] = true, + ["_OPT_GuardedText"] = true, + ["_OPT_HeartMeltinglyText"] = true, + ["_OPT_HotHotHotText"] = true, + ["_OPT_InspiringText"] = true, + ["_OPT_IntroText1"] = true, + ["_OPT_IntroText2"] = true, + ["_OPT_IntroText3"] = true, + ["_OPT_LooksInWaterText"] = true, + ["_OPT_LovelyText"] = true, + ["_OPT_MaryText1"] = true, + ["_OPT_MischievouslyText"] = true, + ["_OPT_OakText1"] = true, + ["_OPT_OakText2"] = true, + ["_OPT_OakText3"] = true, + ["_OPT_PleasantText"] = true, + ["_OPT_PokemonChannelText"] = true, + ["_OPT_PowerfulText"] = true, + ["_OPT_ProvocativelyText"] = true, + ["_OPT_SensuallyText"] = true, + ["_OPT_SpeedyText"] = true, + ["_OPT_StimulatingText"] = true, + ["_OPT_SuaveDebonairText"] = true, + ["_OPT_SweetAdorablyText"] = true, + ["_OPT_TopicallyText"] = true, + ["_OPT_UnbearablyText"] = true, + ["_OPT_UndeniablyKindOfText"] = true, + ["_OPT_WeirdText"] = true, + ["_OPT_WigglySlicklyText"] = true, + ["_OPT_WowImpressivelyText"] = true, + ["_OTSendsText"] = true, + ["_OakPCText1"] = true, + ["_OakPCText2"] = true, + ["_OakPCText3"] = true, + ["_OakPCText4"] = true, + ["_OakRating01"] = true, + ["_OakRating02"] = true, + ["_OakRating03"] = true, + ["_OakRating04"] = true, + ["_OakRating05"] = true, + ["_OakRating06"] = true, + ["_OakRating07"] = true, + ["_OakRating08"] = true, + ["_OakRating09"] = true, + ["_OakRating10"] = true, + ["_OakRating11"] = true, + ["_OakRating12"] = true, + ["_OakRating13"] = true, + ["_OakRating14"] = true, + ["_OakRating15"] = true, + ["_OakRating16"] = true, + ["_OakRating17"] = true, + ["_OakRating18"] = true, + ["_OakRating19"] = true, + ["_OakText1"] = true, + ["_OakText2"] = true, + ["_OakText3"] = true, + ["_OakText4"] = true, + ["_OakText5"] = true, + ["_OakText6"] = true, + ["_OakText7"] = true, + ["_OakThisIsntTheTimeText"] = true, + ["_OakTimeHoursQuestionMarkText"] = true, + ["_OakTimeHowManyMinutesText"] = true, + ["_OakTimeIsItText"] = true, + ["_OakTimeMinutesQuestionMarkText"] = true, + ["_OakTimeOversleptText"] = true, + ["_OakTimeSoDarkText"] = true, + ["_OakTimeWhatDayIsItText"] = true, + ["_OakTimeWhatHoursText"] = true, + ["_OakTimeWhatTimeIsItText"] = true, + ["_OakTimeWhoaMinutesText"] = true, + ["_OakTimeWokeUpText"] = true, + ["_OakTimeYikesText"] = true, + ["_ObjectEventText"] = true, + ["_ObtainedFruitText"] = true, + ["_ObtainedTheVoltorbBadgeText"] = true, + ["_OhFineThenText"] = true, + ["_OnlyOneMonText"] = true, + ["_OnlyThreeMonMayBeEnteredText"] = true, + ["_PCCantDepositLastMonText"] = true, + ["_PCCantTakeText"] = true, + ["_PCGottaHavePokemonText"] = true, + ["_PCMonHoldingMailText"] = true, + ["_PCNoSingleMonText"] = true, + ["_PCWhatText"] = true, + ["_PPIsMaxedOutText"] = true, + ["_PPRestoredText"] = true, + ["_PPsIncreasedText"] = true, + ["_PackEmptyText"] = true, + ["_PackNoItemText"] = true, + ["_PasscodeSetText"] = true, + ["_PasscodesNotSameText"] = true, + ["_PasswordAskEnterText"] = true, + ["_PasswordAskResetClockText"] = true, + ["_PasswordAskResetText"] = true, + ["_PasswordWrongText"] = true, + ["_PerfectHeresYourMonText"] = true, + ["_PharmacyComeAgainText"] = true, + ["_PharmacyFinalPriceText"] = true, + ["_PharmacyHowManyText"] = true, + ["_PharmacyIntroText"] = true, + ["_PharmacyNoMoneyText"] = true, + ["_PharmacyPackFullText"] = true, + ["_PharmacyThanksText"] = true, + ["_PhoneClickText"] = true, + ["_PhoneEllipseText"] = true, + ["_PhoneJustTalkToThemText"] = true, + ["_PhoneOutOfAreaText"] = true, + ["_PhoneThankYouText"] = true, + ["_PhoneWrongNumberText"] = true, + ["_PickErrorPacketText"] = true, + ["_PickThreeMonForMobileBattleText"] = true, + ["_PlayedFluteText"] = true, + ["_PlayerFoundItemText"] = true, + ["_PlayersPCAskWhatDoText"] = true, + ["_PlayersPCDepositItemsText"] = true, + ["_PlayersPCHowManyDepositText"] = true, + ["_PlayersPCHowManyWithdrawText"] = true, + ["_PlayersPCNoItemsText"] = true, + ["_PlayersPCNoRoomDepositText"] = true, + ["_PlayersPCNoRoomWithdrawText"] = true, + ["_PlayersPCTurnOnText"] = true, + ["_PlayersPCWithdrewItemsText"] = true, + ["_PleaseTryAgainTomorrowText"] = true, + ["_PnP_BoldText"] = true, + ["_PnP_CoolText"] = true, + ["_PnP_CuteText"] = true, + ["_PnP_GreatText"] = true, + ["_PnP_HappyText"] = true, + ["_PnP_InspiringText"] = true, + ["_PnP_LazyText"] = true, + ["_PnP_MyTypeText"] = true, + ["_PnP_NoisyText"] = true, + ["_PnP_OddText"] = true, + ["_PnP_PickyText"] = true, + ["_PnP_PrecociousText"] = true, + ["_PnP_RightForMeText"] = true, + ["_PnP_SoSoText"] = true, + ["_PnP_SortOfOKText"] = true, + ["_PnP_Text1"] = true, + ["_PnP_Text2"] = true, + ["_PnP_Text3"] = true, + ["_PnP_Text4"] = true, + ["_PnP_Text5"] = true, + ["_PnP_WeirdText"] = true, + ["_PocketIsFullText"] = true, + ["_PoisonFaintText"] = true, + ["_PoisonWhiteoutText"] = true, + ["_PokecenterBillsPCText"] = true, + ["_PokecenterOaksPCText"] = true, + ["_PokecenterPCCantUseText"] = true, + ["_PokecenterPCOaksClosedText"] = true, + ["_PokecenterPCTurnOnText"] = true, + ["_PokecenterPCWhoseText"] = true, + ["_PokecenterPlayersPCText"] = true, + ["_PokedexShowText"] = true, + ["_PokegearAskDeleteText"] = true, + ["_PokegearAskWhoCallText"] = true, + ["_PokegearPressButtonText"] = true, + ["_PokemonAskSwapItemText"] = true, + ["_PokemonHoldItemText"] = true, + ["_PokemonNotEnoughHPText"] = true, + ["_PokemonNotHoldingText"] = true, + ["_PokemonRemoveMailText"] = true, + ["_PokemonSwapItemText"] = true, + ["_PokemonTookItemText"] = true, + ["_PrestoAllDoneText"] = true, + ["_PutAwayAndSetUpText"] = true, + ["_PutAwayTheDecoText"] = true, + ["_PutCardInCardFolderText"] = true, + ["_PutItemInPocketText"] = true, + ["_RaiseThePPOfWhichMoveText"] = true, + ["_ReceiveItemText"] = true, + ["_ReceivedEggText"] = true, + ["_ReceivedItemText"] = true, + ["_ReceivedTMHMText"] = true, + ["_RecoveredSomeHPText"] = true, + ["_RegisteredItemText"] = true, + ["_RemainingTimeText"] = true, + ["_RemoveMailText"] = true, + ["_RepelUsedEarlierIsStillInEffectText"] = true, + ["_RepelWoreOffText"] = true, + ["_RestoreThePPOfWhichMoveText"] = true, + ["_RetrieveMysteryGiftText"] = true, + ["_RevitalizedText"] = true, + ["_RidOfParalysisText"] = true, + ["_RocketRadioText1"] = true, + ["_RocketRadioText10"] = true, + ["_RocketRadioText2"] = true, + ["_RocketRadioText3"] = true, + ["_RocketRadioText4"] = true, + ["_RocketRadioText5"] = true, + ["_RocketRadioText6"] = true, + ["_RocketRadioText7"] = true, + ["_RocketRadioText8"] = true, + ["_RocketRadioText9"] = true, + ["_RodBiteText"] = true, + ["_RodNothingText"] = true, + ["_SaveFileCorruptedText"] = true, + ["_SavedTheGameText"] = true, + ["_SavingDontTurnOffThePowerText"] = true, + ["_SavingRecordText"] = true, + ["_SeerCantTellAThingText"] = true, + ["_SeerDoNothingText"] = true, + ["_SeerEggText"] = true, + ["_SeerImpressedText"] = true, + ["_SeerMightyText"] = true, + ["_SeerMoreCareText"] = true, + ["_SeerMoreConfidentText"] = true, + ["_SeerMuchStrengthText"] = true, + ["_SeerNameLocationText"] = true, + ["_SeerNoLocationText"] = true, + ["_SeerSeeAllText"] = true, + ["_SeerTimeLevelText"] = true, + ["_SeerTradeText"] = true, + ["_SentTrophyHomeText"] = true, + ["_SetUpTheDecoText"] = true, + ["_SlotsBetHowManyCoinsText"] = true, + ["_SlotsDarnText"] = true, + ["_SlotsLinedUpText"] = true, + ["_SlotsNotEnoughCoinsText"] = true, + ["_SlotsPlayAgainText"] = true, + ["_SlotsRanOutOfCoinsText"] = true, + ["_SlotsStartText"] = true, + ["_SpaceSpaceColonText"] = true, + ["_SquirtbottleNothingText"] = true, + ["_StartMenuContestEndText"] = true, + ["_StartText"] = true, + ["_StartingLinkText"] = true, + ["_StopLearningMoveText"] = true, + ["_StoppedEvolvingText"] = true, + ["_SweetScentNothingText"] = true, + ["_TMHMNotCompatibleText"] = true, + ["_TakeGoodCareOfEggText"] = true, + ["_TakeGoodCareOfMonText"] = true, + ["_TeleportReturnText"] = true, + ["_TestEventText"] = true, + ["_ThatCantBeUsedRightNowText"] = true, + ["_ThatItemCantBePutInThePackText"] = true, + ["_ThatsEnoughComeBackText"] = true, + ["_TheBoxIsFullText"] = true, + ["_TheItemWasPutInThePackText"] = true, + ["_TheMonMustAllBeDifferentKindsText"] = true, + ["_TheMonMustNotHoldTheSameItemsText"] = true, + ["_ThePasswordIsText"] = true, + ["_ThereIsNoEggText"] = true, + ["_ThereIsNothingConnectedText"] = true, + ["_ThrewAwayText"] = true, + ["_TimeAskOkayText"] = true, + ["_TimeLimitWasClearedText"] = true, + ["_TimesetAskAdjustDSTText"] = true, + ["_TimesetAskDSTText"] = true, + ["_TimesetAskNotDSTText"] = true, + ["_TimesetDSTText"] = true, + ["_TimesetNotDSTText"] = true, + ["_TradingMonForOTMonText"] = true, + ["_TrainerRankingExplanationText"] = true, + ["_TrainerRankingNoDataText"] = true, + ["_TryAgainUsingSameSettingsText"] = true, + ["_UnusedNothingHereText"] = true, + ["_UseCutText"] = true, + ["_UseDigText"] = true, + ["_UseEscapeRopeText"] = true, + ["_UseHeadbuttText"] = true, + ["_UseRockSmashText"] = true, + ["_UseSacredAshText"] = true, + ["_UseStrengthText"] = true, + ["_UseSweetScentText"] = true, + ["_UseWaterfallText"] = true, + ["_UseWhirlpoolText"] = true, + ["_UsedInsteadText"] = true, + ["_UsedMove1Text"] = true, + ["_UsedMove2Text"] = true, + ["_UsedSurfText"] = true, + ["_WaitButtonText"] = true, + ["_WantAQuickMobileBattleText"] = true, + ["_WantToRushThroughAMobileBattleText"] = true, + ["_WarpingText"] = true, + ["_WasDefrostedText"] = true, + ["_WasSentToBillsPCText"] = true, + ["_WhatShouldIRaiseText"] = true, + ["_WhichApricornText"] = true, + ["_WhichMonPhotoText"] = true, + ["_WhichSidePutAwayText"] = true, + ["_WhichSidePutOnText"] = true, + ["_WhitedOutText"] = true, + ["_WillTradeText"] = true, + ["_WillYouPlayWithMonText"] = true, + ["_WindowAreaExceededErrorText"] = true, + ["_WindowPoppingErrorText"] = true, + ["_WokeUpText"] = true, + ["_WouldYouLikeToMobileBattleText"] = true, + ["_WouldYouLikeToSaveTheGameText"] = true, + ["_YesPromptText"] = true, + ["_YouCantTakeAnEggText"] = true, + ["_YouCantUseItInABattleText"] = true, + ["_YouDontHaveAMonText"] = true, + ["_YouNeedTwoMonForBreedingText"] = true, + ["_YourFoesWeakGetmMonText"] = true, + ["_YourFriendIsNotReadyText"] = true, + ["_YourMonHasGrownText"] = true, + ["_YourMonsHPWasHealedText"] = true, +} diff --git a/src/mods/rom_text_labels/emerald.lua b/src/mods/rom_text_labels/emerald.lua new file mode 100644 index 000000000..01953e685 --- /dev/null +++ b/src/mods/rom_text_labels/emerald.lua @@ -0,0 +1,6870 @@ +-- Generated label/address facts for emerald; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + text = { + ["BattleColosseum_2P_Text_TakePlaceStartBattle"] = 0x2784B4, + ["BattleDome_Text_EmphasizesAtk"] = 0x2C39C1, + ["BattleDome_Text_EmphasizesAtkAndDef"] = 0x2C3862, + ["BattleDome_Text_EmphasizesAtkAndSpAtk"] = 0x2C389E, + ["BattleDome_Text_EmphasizesAtkAndSpDef"] = 0x2C38C0, + ["BattleDome_Text_EmphasizesAtkAndSpeed"] = 0x2C3881, + ["BattleDome_Text_EmphasizesDef"] = 0x2C39D4, + ["BattleDome_Text_EmphasizesDefAndSpAtk"] = 0x2C3901, + ["BattleDome_Text_EmphasizesDefAndSpDef"] = 0x2C3924, + ["BattleDome_Text_EmphasizesDefAndSpeed"] = 0x2C38E3, + ["BattleDome_Text_EmphasizesHP"] = 0x2C39B2, + ["BattleDome_Text_EmphasizesHPAndAtk"] = 0x2C37D7, + ["BattleDome_Text_EmphasizesHPAndDef"] = 0x2C37F1, + ["BattleDome_Text_EmphasizesHPAndSpAtk"] = 0x2C3825, + ["BattleDome_Text_EmphasizesHPAndSpDef"] = 0x2C3843, + ["BattleDome_Text_EmphasizesHPAndSpeed"] = 0x2C380C, + ["BattleDome_Text_EmphasizesSpAtk"] = 0x2C39FA, + ["BattleDome_Text_EmphasizesSpAtkAndSpDef"] = 0x2C398B, + ["BattleDome_Text_EmphasizesSpDef"] = 0x2C3A11, + ["BattleDome_Text_EmphasizesSpeed"] = 0x2C39E8, + ["BattleDome_Text_EmphasizesSpeedAndSpAtk"] = 0x2C3948, + ["BattleDome_Text_EmphasizesSpeedAndSpDef"] = 0x2C3969, + ["BattleDome_Text_FinalMatch"] = 0x2C3E0F, + ["BattleDome_Text_LetTheBattleBegin"] = 0x2C3C78, + ["BattleDome_Text_NeglectsAtk"] = 0x2C3BF3, + ["BattleDome_Text_NeglectsAtkAndDef"] = 0x2C3AAA, + ["BattleDome_Text_NeglectsAtkAndSpAtk"] = 0x2C3AE2, + ["BattleDome_Text_NeglectsAtkAndSpDef"] = 0x2C3B02, + ["BattleDome_Text_NeglectsAtkAndSpeed"] = 0x2C3AC7, + ["BattleDome_Text_NeglectsDef"] = 0x2C3C04, + ["BattleDome_Text_NeglectsDefAndSpAtk"] = 0x2C3B3F, + ["BattleDome_Text_NeglectsDefAndSpDef"] = 0x2C3B60, + ["BattleDome_Text_NeglectsDefAndSpeed"] = 0x2C3B23, + ["BattleDome_Text_NeglectsHP"] = 0x2C3BE6, + ["BattleDome_Text_NeglectsHPAndAtk"] = 0x2C3A29, + ["BattleDome_Text_NeglectsHPAndDef"] = 0x2C3A41, + ["BattleDome_Text_NeglectsHPAndSpAtk"] = 0x2C3A71, + ["BattleDome_Text_NeglectsHPAndSpDef"] = 0x2C3A8D, + ["BattleDome_Text_NeglectsHPAndSpeed"] = 0x2C3A5A, + ["BattleDome_Text_NeglectsSpAtk"] = 0x2C3C26, + ["BattleDome_Text_NeglectsSpAtkAndSpDef"] = 0x2C3BC1, + ["BattleDome_Text_NeglectsSpDef"] = 0x2C3C3B, + ["BattleDome_Text_NeglectsSpeed"] = 0x2C3C16, + ["BattleDome_Text_NeglectsSpeedAndSpAtk"] = 0x2C3B82, + ["BattleDome_Text_NeglectsSpeedAndSpDef"] = 0x2C3BA1, + ["BattleDome_Text_Potential1"] = 0x2C3188, + ["BattleDome_Text_Potential10"] = 0x2C329B, + ["BattleDome_Text_Potential11"] = 0x2C32B7, + ["BattleDome_Text_Potential12"] = 0x2C32D9, + ["BattleDome_Text_Potential13"] = 0x2C32FC, + ["BattleDome_Text_Potential14"] = 0x2C3312, + ["BattleDome_Text_Potential15"] = 0x2C3332, + ["BattleDome_Text_Potential16"] = 0x2C3359, + ["BattleDome_Text_Potential2"] = 0x2C31AA, + ["BattleDome_Text_Potential3"] = 0x2C31C0, + ["BattleDome_Text_Potential4"] = 0x2C31DD, + ["BattleDome_Text_Potential5"] = 0x2C31FA, + ["BattleDome_Text_Potential6"] = 0x2C321B, + ["BattleDome_Text_Potential7"] = 0x2C3240, + ["BattleDome_Text_Potential8"] = 0x2C325C, + ["BattleDome_Text_Potential9"] = 0x2C327C, + ["BattleDome_Text_PotentialDomeAceTucker"] = 0x2C3378, + ["BattleDome_Text_RaisesMonsWellBalanced"] = 0x2C3C51, + ["BattleDome_Text_Round1Match1"] = 0x2C3D1F, + ["BattleDome_Text_Round1Match2"] = 0x2C3D30, + ["BattleDome_Text_Round1Match3"] = 0x2C3D41, + ["BattleDome_Text_Round1Match4"] = 0x2C3D52, + ["BattleDome_Text_Round1Match5"] = 0x2C3D63, + ["BattleDome_Text_Round1Match6"] = 0x2C3D74, + ["BattleDome_Text_Round1Match7"] = 0x2C3D85, + ["BattleDome_Text_Round1Match8"] = 0x2C3D96, + ["BattleDome_Text_Round2Match1"] = 0x2C3DA7, + ["BattleDome_Text_Round2Match2"] = 0x2C3DB8, + ["BattleDome_Text_Round2Match3"] = 0x2C3DC9, + ["BattleDome_Text_Round2Match4"] = 0x2C3DDA, + ["BattleDome_Text_SemifinalMatch1"] = 0x2C3DEB, + ["BattleDome_Text_SemifinalMatch2"] = 0x2C3DFD, + ["BattleDome_Text_StyleAggressivelySpectacularMoves"] = 0x2C369D, + ["BattleDome_Text_StyleAggressivelyStrongMoves"] = 0x2C35D4, + ["BattleDome_Text_StyleAttacksQuicklyStrongMoves"] = 0x2C3704, + ["BattleDome_Text_StyleBattlesWhileEnduring"] = 0x2C3562, + ["BattleDome_Text_StyleCleverlyDodgesAttacks"] = 0x2C35F4, + ["BattleDome_Text_StyleConstantlyWatchesHP"] = 0x2C3471, + ["BattleDome_Text_StyleEmphasizesDefenseOverOffense"] = 0x2C36E3, + ["BattleDome_Text_StyleEmphasizesOffenseOverDefense"] = 0x2C36C2, + ["BattleDome_Text_StyleEndureLongBattles"] = 0x2C33C4, + ["BattleDome_Text_StyleEnfeebleThenAttack"] = 0x2C353B, + ["BattleDome_Text_StyleEnfeeblesFoes"] = 0x2C34B6, + ["BattleDome_Text_StyleHasPowerfulComboMoves"] = 0x2C3659, + ["BattleDome_Text_StylePowerfulLowPPMoves"] = 0x2C3516, + ["BattleDome_Text_StylePrefersLuckTactics"] = 0x2C34D2, + ["BattleDome_Text_StyleRegalAtmosphere"] = 0x2C34F5, + ["BattleDome_Text_StyleRiskDisaster"] = 0x2C339B, + ["BattleDome_Text_StyleSampleMessage1"] = 0x2C376F, + ["BattleDome_Text_StyleSampleMessage2"] = 0x2C3789, + ["BattleDome_Text_StyleSampleMessage3"] = 0x2C37A3, + ["BattleDome_Text_StyleSampleMessage4"] = 0x2C37BD, + ["BattleDome_Text_StyleStoresAndLoosesPower"] = 0x2C3492, + ["BattleDome_Text_StyleStrongAndStraightforward"] = 0x2C35AD, + ["BattleDome_Text_StyleToughWinningPattern"] = 0x2C340B, + ["BattleDome_Text_StyleUpsetsFoesEmotionally"] = 0x2C3586, + ["BattleDome_Text_StyleUsesAddedEffectMoves"] = 0x2C3727, + ["BattleDome_Text_StyleUsesBalancedMixOfMoves"] = 0x2C374C, + ["BattleDome_Text_StyleUsesHighProbabilityMoves"] = 0x2C367E, + ["BattleDome_Text_StyleUsesPopularMoves"] = 0x2C3640, + ["BattleDome_Text_StyleUsesStartlingMoves"] = 0x2C344C, + ["BattleDome_Text_StyleUsesUpsettingMoves"] = 0x2C361C, + ["BattleDome_Text_StyleUsesVeryRareMove"] = 0x2C3428, + ["BattleDome_Text_StyleVariesTactics"] = 0x2C33E6, + ["BattleDome_Text_TrainerBecameChamp"] = 0x2C3C9F, + ["BattleDome_Text_TrainerWonByDefault"] = 0x2C3CB4, + ["BattleDome_Text_TrainerWonNoMoves"] = 0x2C3CE3, + ["BattleDome_Text_TrainerWonOutrightByDefault"] = 0x2C3CC7, + ["BattleDome_Text_TrainerWonOutrightNoMoves"] = 0x2C3D00, + ["BattleDome_Text_TrainerWonUsingMove"] = 0x2C3C8E, + ["BattleFrontier_BattleTowerLobby_Text_DescribeYourBattle"] = 0x27F97A, + ["BattleFrontier_BattleTowerLobby_Text_DifficultToMakeBattleTurnOutAsPlanned"] = 0x27F921, + ["BattleFrontier_BattleTowerLobby_Text_HowDidBattleTowerTurnOut"] = 0x27F7BA, + ["BattleFrontier_BattleTowerLobby_Text_InterviewRequest"] = 0x27F704, + ["BattleFrontier_BattleTowerLobby_Text_LookingForwardToNextBattle"] = 0x27FAF3, + ["BattleFrontier_BattleTowerLobby_Text_ObviousYouHadGreatBattle"] = 0x27F8AE, + ["BattleFrontier_BattleTowerLobby_Text_SilentType"] = 0x27FA6F, + ["BattleFrontier_BattleTowerLobby_Text_SorryWeDisturbedYou"] = 0x27F84C, + ["BattleFrontier_BattleTowerLobby_Text_ThatsGreatLine"] = 0x27F9FD, + ["BattleTentLobby_Text_ExplainLevelRules"] = 0x2C67CD, + ["BerryBlender_Text_AllPlayersArrived"] = 0x2939A5, + ["BerryBlender_Text_BlendMasterExplainBerryBlending"] = 0x2C444C, + ["BerryBlender_Text_BlendMasterKnowHowToMakePokeblocks"] = 0x2C43FA, + ["BerryBlender_Text_BlendMasterLetsBerryBlender"] = 0x2C451B, + ["BerryBlender_Text_BlendMasterNoBerries"] = 0x2C439D, + ["BerryBlender_Text_BlendMasterNoPokeblockCase"] = 0x2C4573, + ["BerryBlender_Text_BlendMasterPokeblockCaseFull"] = 0x2C45E8, + ["BerryBlender_Text_BlendWithTheBlendMaster"] = 0x2C427C, + ["BerryBlender_Text_CanHaveOneOfMyBerries"] = 0x292FD1, + ["BerryBlender_Text_DontHaveAnyBerries"] = 0x292F77, + ["BerryBlender_Text_DontHaveAnyBerries2"] = 0x293394, + ["BerryBlender_Text_DontHaveAnyBerries3"] = 0x29367D, + ["BerryBlender_Text_DontHaveAnyBerriesHaveOne"] = 0x293AF0, + ["BerryBlender_Text_DontHaveAnyBerriesNoneToSpare"] = 0x293BB4, + ["BerryBlender_Text_DontHaveAnyBerriesToSpare"] = 0x29307D, + ["BerryBlender_Text_DontHavePokeblockCase"] = 0x293157, + ["BerryBlender_Text_DontHavePokeblockCase2"] = 0x2934A2, + ["BerryBlender_Text_DontHavePokeblockCase3"] = 0x293792, + ["BerryBlender_Text_DontHavePokeblockCaseLink"] = 0x293A2D, + ["BerryBlender_Text_Excellent"] = 0x292E28, + ["BerryBlender_Text_ExplainBerryBlending"] = 0x292EA6, + ["BerryBlender_Text_ExplainBerryBlending2"] = 0x2932F1, + ["BerryBlender_Text_ExplainBerryBlending3"] = 0x2935E5, + ["BerryBlender_Text_KnowHowToMakePokeblocks"] = 0x292E57, + ["BerryBlender_Text_KnowHowToMakePokeblocks2"] = 0x29328C, + ["BerryBlender_Text_KnowHowToMakePokeblocks3"] = 0x29357E, + ["BerryBlender_Text_LeftUsInShock"] = 0x293558, + ["BerryBlender_Text_LetsBerryBlender"] = 0x292E78, + ["BerryBlender_Text_LetsBerryBlender2"] = 0x2932C3, + ["BerryBlender_Text_LetsBerryBlender3"] = 0x2935B1, + ["BerryBlender_Text_LetsGetBlendingAlready"] = 0x2931AA, + ["BerryBlender_Text_LookGoodAtBlendingJoinUs"] = 0x293842, + ["BerryBlender_Text_LoveMakingPokeblocks"] = 0x293A6B, + ["BerryBlender_Text_MadeAmazingPokeblocksWithMaster"] = 0x2C4679, + ["BerryBlender_Text_MadeOldTimerSad"] = 0x292E33, + ["BerryBlender_Text_MakeDeliciousPokeblocks"] = 0x29388F, + ["BerryBlender_Text_MakePokeblocksUsingBerryBlender"] = 0x293AA5, + ["BerryBlender_Text_MakePokeblocksWithOurGroup"] = 0x29350B, + ["BerryBlender_Text_MasterWorksOnSkillsInMountains"] = 0x2C4763, + ["BerryBlender_Text_NoBerriesLink"] = 0x2939BD, + ["BerryBlender_Text_OhDear"] = 0x29354E, + ["BerryBlender_Text_Okay"] = 0x293237, + ["BerryBlender_Text_Player1And2Arrived"] = 0x293992, + ["BerryBlender_Text_Player1Arrived"] = 0x293986, + ["BerryBlender_Text_PokeblockCaseIsFull"] = 0x293101, + ["BerryBlender_Text_PokeblockCaseIsFull2"] = 0x29343E, + ["BerryBlender_Text_PokeblockCaseIsFull3"] = 0x293738, + ["BerryBlender_Text_PokeblockCaseIsFullLink"] = 0x2939F3, + ["BerryBlender_Text_QualitiesOfBlendMaster"] = 0x2C46B1, + ["BerryBlender_Text_SaveGameBeforeBerryBlenderLink"] = 0x2938CD, + ["BerryBlender_Text_SearchingForFriends"] = 0x293955, + ["BerryBlender_Text_SeeMyMasteryInAction"] = 0x2C42F4, + ["BerryBlender_Text_SetNewBlenderRecord"] = 0x2937F9, + ["BerryBlender_Text_ThatsTooBad"] = 0x29323D, + ["BerryBlender_Text_TooBusyNowIsee"] = 0x2C4332, + ["BerryBlender_Text_UseItToMakePokeblocksTogether"] = 0x293B76, + ["BerryBlender_Text_WantToBlendPokeblocksWithUs"] = 0x293201, + ["BerryBlender_Text_WantToMakePokeblocks"] = 0x292DEE, + ["BerryBlender_Text_WhatAnExpert"] = 0x2C4669, + ["BerryBlender_Text_WhatKindOfPokeblockWillIGet"] = 0x2931C6, + ["BerryBlender_Text_WhoaAwesome"] = 0x2C464B, + ["BerryBlender_Text_WickedlyFast"] = 0x2C465A, + ["BerryTree_Text_BerryGrowthStage1"] = 0x274560, + ["BerryTree_Text_BerryGrowthStage2"] = 0x274579, + ["BerryTree_Text_BerryGrowthStage3"] = 0x27458A, + ["BerryTree_Text_BerryGrowthStage4"] = 0x2745AB, + ["BerryTree_Text_BerryLeftUnpicked"] = 0x2746CB, + ["BerryTree_Text_BerryPocketFull"] = 0x27468F, + ["BerryTree_Text_CareAdverbGood"] = 0x2745E5, + ["BerryTree_Text_CareAdverbGreat"] = 0x2745CD, + ["BerryTree_Text_CareAdverbPoor"] = 0x2745DE, + ["BerryTree_Text_ExclamationPoint"] = 0x274744, + ["BerryTree_Text_ItsSoftLoamySoil"] = 0x2744F0, + ["BerryTree_Text_PickedTheBerry"] = 0x27461B, + ["BerryTree_Text_PlantIsDelighted"] = 0x274723, + ["BerryTree_Text_PlantedOneBerry"] = 0x274535, + ["BerryTree_Text_PutAwayBerry"] = 0x274630, + ["BerryTree_Text_WantToPick"] = 0x2745EE, + ["BerryTree_Text_WantToPlant"] = 0x274507, + ["BerryTree_Text_WantToWater"] = 0x2746E4, + ["BerryTree_Text_WateredTheBerry"] = 0x274710, + ["BirthIsland_Harbor_Text_SailorReturn"] = 0x2C6B90, + ["BravoTrainerBattleTower_Text_Intro"] = 0x27FB20, + ["BravoTrainerBattleTower_Text_Lost"] = 0x27FC04, + ["BravoTrainerBattleTower_Text_LostFinal"] = 0x27FD91, + ["BravoTrainerBattleTower_Text_NewRecord"] = 0x27FBB3, + ["BravoTrainerBattleTower_Text_None1"] = 0x28009C, + ["BravoTrainerBattleTower_Text_None2"] = 0x2800A1, + ["BravoTrainerBattleTower_Text_None3"] = 0x2800A6, + ["BravoTrainerBattleTower_Text_None4"] = 0x2800AB, + ["BravoTrainerBattleTower_Text_Outro"] = 0x2801E6, + ["BravoTrainerBattleTower_Text_Response"] = 0x2800B0, + ["BravoTrainerBattleTower_Text_ResponseSatisfied"] = 0x2800B6, + ["BravoTrainerBattleTower_Text_ResponseUnsatisfied"] = 0x28013D, + ["BravoTrainerBattleTower_Text_Satisfied"] = 0x27FE93, + ["BravoTrainerBattleTower_Text_Unsatisfied"] = 0x27FF89, + ["BravoTrainerBattleTower_Text_Won"] = 0x27FCD1, + ["CableClub_Text_AccessRecordCorner"] = 0x2790E8, + ["CableClub_Text_AdapterNotConnected"] = 0x27871F, + ["CableClub_Text_AskAboutLinking"] = 0x279937, + ["CableClub_Text_BattleUsingLinkCable"] = 0x277F5A, + ["CableClub_Text_CanMakeBerryPowder"] = 0x2787FC, + ["CableClub_Text_CanMixRecords"] = 0x278831, + ["CableClub_Text_CancelSelectedItem"] = 0x27889C, + ["CableClub_Text_CantDoubleBattleWithXPlayers"] = 0x278307, + ["CableClub_Text_CantMixWithJapaneseGame"] = 0x278651, + ["CableClub_Text_CantSingleBattleWithXPlayers"] = 0x2782D1, + ["CableClub_Text_CantTradeEnigmaBerry"] = 0x278061, + ["CableClub_Text_ChooseGroupLeader"] = 0x2793A3, + ["CableClub_Text_ChooseGroupLeaderOfFour"] = 0x279334, + ["CableClub_Text_ChooseGroupLeaderOfTwo"] = 0x2792CD, + ["CableClub_Text_CloseThisMenu"] = 0x277FDD, + ["CableClub_Text_DirectYouToYourRoom"] = 0x2781A5, + ["CableClub_Text_EnjoyUnionRoom"] = 0x27961C, + ["CableClub_Text_ExplainBattleModes"] = 0x279142, + ["CableClub_Text_ExplainWirelessClub"] = 0x2799AA, + ["CableClub_Text_ExplainWirelessClubFirstTime"] = 0x279718, + ["CableClub_Text_FirstTimeRightThisWay"] = 0x27964A, + ["CableClub_Text_GotToLookAtColoredTrainerCard"] = 0x27847B, + ["CableClub_Text_GotToLookAtTrainerCard"] = 0x278452, + ["CableClub_Text_GuideToVariousServices"] = 0x27886B, + ["CableClub_Text_Hello"] = 0x27878B, + ["CableClub_Text_HopeToSeeYouAgain"] = 0x27854C, + ["CableClub_Text_HopeYouEnjoyWirelessSystem"] = 0x279C91, + ["CableClub_Text_IncorrectNumberOfParticipants"] = 0x2782A8, + ["CableClub_Text_NeedBerryForBerryCrush"] = 0x2788FC, + ["CableClub_Text_NeedFourPlayers"] = 0x27833D, + ["CableClub_Text_NeedTwoMonsForDoubleBattle"] = 0x277FEE, + ["CableClub_Text_NeedTwoMonsForUnionRoom"] = 0x27893E, + ["CableClub_Text_NeedTwoMonsToTrade"] = 0x278027, + ["CableClub_Text_NoEnigmaBerryInUnionRoom"] = 0x27897B, + ["CableClub_Text_NotSetUpForFarAwayRegion"] = 0x278565, + ["CableClub_Text_OhExcuseMe"] = 0x278A39, + ["CableClub_Text_OkayToSaveProgress"] = 0x278157, + ["CableClub_Text_OtherTrainerNotReady"] = 0x2785C9, + ["CableClub_Text_ParticipantsStepUpToCounter"] = 0x27874F, + ["CableClub_Text_PlayWhichBattleMode"] = 0x2790BE, + ["CableClub_Text_PlayerIsWaiting"] = 0x278A48, + ["CableClub_Text_PleaseConfirmNumberAndRestart"] = 0x278372, + ["CableClub_Text_PleaseEnter"] = 0x278197, + ["CableClub_Text_PleaseVisitAgain"] = 0x278291, + ["CableClub_Text_PleaseWait"] = 0x278792, + ["CableClub_Text_RecordCornerUsingLinkCable"] = 0x277F96, + ["CableClub_Text_ReturnsToPreviousStep"] = 0x2788DE, + ["CableClub_Text_TooBusyToNotice"] = 0x27842E, + ["CableClub_Text_TradePokemon"] = 0x27909D, + ["CableClub_Text_TradeUsingLinkCable"] = 0x277F1B, + ["CableClub_Text_TrainerCardDataOverwritten"] = 0x278521, + ["CableClub_Text_UnionRoomAdapterNotConnected"] = 0x2789B5, + ["CableClub_Text_UnionRoomInfo"] = 0x2794B8, + ["CableClub_Text_UseBerryCrush"] = 0x279114, + ["CableClub_Text_WelcomeUnionRoomEnter"] = 0x27940D, + ["CableClub_Text_WelcomeWhichCableClubService"] = 0x277EA4, + ["CableClub_Text_WelcomeWhichDirectCornerRoom"] = 0x279013, + ["CableClub_Text_WhichBattleMode"] = 0x2788BC, + ["CableClub_Text_WhichService"] = 0x277EF1, + ["CableClub_Text_YouHaveAMonThatCantBeTaken"] = 0x2785E9, + ["CableClub_Text_YouMayBattleHere"] = 0x2787D5, + ["CableClub_Text_YouMayTradeHere"] = 0x27879F, + ["ContestHall_Text_AcceptYourPrize"] = 0x27BBA8, + ["ContestHall_Text_AudienceWillVote"] = 0x27CA04, + ["ContestHall_Text_ConferRibbonAsPrize"] = 0x27BBD4, + ["ContestHall_Text_CongratsPleaseCompeteAgain"] = 0x27BB7A, + ["ContestHall_Text_CongratsTrainerXandMon"] = 0x27BB4C, + ["ContestHall_Text_EntryXTrainersMon"] = 0x27B815, + ["ContestHall_Text_GettingStartedParticipantsAsFollows"] = 0x27B711, + ["ContestHall_Text_GettingStartedParticipantsAsFollowsLink"] = 0x27B78F, + ["ContestHall_Text_GettingStartedWireless"] = 0x27C929, + ["ContestHall_Text_JudgeLooksReady"] = 0x27BAAC, + ["ContestHall_Text_LetVotingBegin"] = 0x27CA42, + ["ContestHall_Text_LetsAppeal"] = 0x27CB55, + ["ContestHall_Text_OnlyRegister4Players"] = 0x27BC82, + ["ContestHall_Text_ParticipantsAsFollows"] = 0x27C972, + ["ContestHall_Text_PickUpPrizeAtCounterLater"] = 0x27BC2F, + ["ContestHall_Text_PutRibbonOnMon"] = 0x27BC16, + ["ContestHall_Text_ReceivedRibbon"] = 0x27BC00, + ["ContestHall_Text_SecondStageOfJudging"] = 0x27CAD0, + ["ContestHall_Text_SeenContestantsAudienceWillVote"] = 0x27B830, + ["ContestHall_Text_ThankYouForAppeals"] = 0x27BA30, + ["ContestHall_Text_ThatsItForJudging"] = 0x27BA15, + ["ContestHall_Text_VotingComplete"] = 0x27CA71, + ["ContestHall_Text_VotingCompleteLetsAppeal"] = 0x27B904, + ["ContestHall_Text_VotingUnderWay"] = 0x27B8F2, + ["ContestHall_Text_WeWillNowDeclareWinner"] = 0x27BB25, + ["ContestHall_Text_WeveSeenContestants"] = 0x27C9AF, + ["ContestLadyShow_Text_Intro"] = 0x28C137, + ["ContestLadyShow_Text_Lost"] = 0x28C45B, + ["ContestLadyShow_Text_LostBadly"] = 0x28C662, + ["ContestLadyShow_Text_Won"] = 0x28C28C, + ["ContestLiveUpdates_Text_BetterRound1"] = 0x28A0C6, + ["ContestLiveUpdates_Text_BetterRound2"] = 0x289FB0, + ["ContestLiveUpdates_Text_EqualRounds"] = 0x28A047, + ["ContestLiveUpdates_Text_ExcitingAppeal"] = 0x28A24E, + ["ContestLiveUpdates_Text_FailedToAppeal"] = 0x28A5AC, + ["ContestLiveUpdates_Text_GotNervous"] = 0x28A132, + ["ContestLiveUpdates_Text_GotStartled"] = 0x28A455, + ["ContestLiveUpdates_Text_Intro"] = 0x289DD6, + ["ContestLiveUpdates_Text_LastInBothRounds"] = 0x28A638, + ["ContestLiveUpdates_Text_LostAfterWinningRound1"] = 0x28A797, + ["ContestLiveUpdates_Text_LostBySmallMargin"] = 0x28A920, + ["ContestLiveUpdates_Text_MoveWonderful"] = 0x28A4CF, + ["ContestLiveUpdates_Text_NeverExciting"] = 0x28A86D, + ["ContestLiveUpdates_Text_NotExcitingEnough"] = 0x28A6CF, + ["ContestLiveUpdates_Text_Outro"] = 0x28AB01, + ["ContestLiveUpdates_Text_RepeatedAppeals"] = 0x28A9CC, + ["ContestLiveUpdates_Text_StartledFoes"] = 0x28A1BE, + ["ContestLiveUpdates_Text_TalkAboutAnotherMon"] = 0x28A51C, + ["ContestLiveUpdates_Text_TookBreak"] = 0x28A3DC, + ["ContestLiveUpdates_Text_UsedCombo"] = 0x28A202, + ["ContestLiveUpdates_Text_ValiantEffortButLost"] = 0x28AA74, + ["ContestLiveUpdates_Text_VeryBeautiful"] = 0x28A350, + ["ContestLiveUpdates_Text_VeryCool"] = 0x28A32E, + ["ContestLiveUpdates_Text_VeryCute"] = 0x28A36F, + ["ContestLiveUpdates_Text_VeryExcitingAppeal"] = 0x28A2EE, + ["ContestLiveUpdates_Text_VerySmart"] = 0x28A38D, + ["ContestLiveUpdates_Text_VeryTough"] = 0x28A3B6, + ["ContestLiveUpdates_Text_WasBeautiful"] = 0x28A29B, + ["ContestLiveUpdates_Text_WasCool"] = 0x28A288, + ["ContestLiveUpdates_Text_WasCute"] = 0x28A2B3, + ["ContestLiveUpdates_Text_WasSmart"] = 0x28A2C6, + ["ContestLiveUpdates_Text_WasTough"] = 0x28A2DA, + ["ContestLiveUpdates_Text_WonBothRounds"] = 0x289F53, + ["EventTicket_Text_AsYouLike"] = 0x2A6A82, + ["EventTicket_Text_BrineyHoldOnASecond"] = 0x2C6951, + ["EventTicket_Text_BrineyLetsSail"] = 0x2C69AA, + ["EventTicket_Text_OddTicketGetOnBoard"] = 0x2C6A71, + ["EventTicket_Text_OddTicketsWhereTo"] = 0x2C6BD4, + ["EventTicket_Text_OldSeaMapTooFar"] = 0x2C68A5, + ["EventTicket_Text_SailHome"] = 0x2A6A5D, + ["EventTicket_Text_ShowEonTicket"] = 0x2A68FC, + ["EventTicket_Text_ShowOldSeaMap"] = 0x2A6848, + ["EventTicket_Text_SouthernIslandSailBack"] = 0x2A69F1, + ["EventTicket_Text_ThatPass"] = 0x2A68D2, + ["FallarborTown_BattleTentLobby_Text_AwaitAnotherChallenge"] = 0x2C487F, + ["FallarborTown_BattleTentLobby_Text_AwaitAnotherChallenge2"] = 0x2C501F, + ["FallarborTown_BattleTentLobby_Text_BagFullReturnForPrize"] = 0x2C4F83, + ["FallarborTown_BattleTentLobby_Text_BeatThreeTrainers"] = 0x2C4EC3, + ["FallarborTown_BattleTentLobby_Text_DidntSaveBeforeQuitting"] = 0x2C4DED, + ["FallarborTown_BattleTentLobby_Text_ExplainFallarborTent"] = 0x2C48AC, + ["FallarborTown_BattleTentLobby_Text_GuideYouToBattleTent"] = 0x2C4DC3, + ["FallarborTown_BattleTentLobby_Text_LookingForwardToArrival"] = 0x2C504C, + ["FallarborTown_BattleTentLobby_Text_NotEnoughValidMonsLv50"] = 0x2C4BFA, + ["FallarborTown_BattleTentLobby_Text_NotEnoughValidMonsLvOpen"] = 0x2C4CC0, + ["FallarborTown_BattleTentLobby_Text_PresentYouWithPrize"] = 0x2C4F22, + ["FallarborTown_BattleTentLobby_Text_ReceivedPrize"] = 0x2C4F69, + ["FallarborTown_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C4B35, + ["FallarborTown_BattleTentLobby_Text_SelectThreeMons"] = 0x2C4BC8, + ["FallarborTown_BattleTentLobby_Text_TakeChallenge"] = 0x2C4843, + ["FallarborTown_BattleTentLobby_Text_ThankYouWaitWhileSaving"] = 0x2C4FD7, + ["FallarborTown_BattleTentLobby_Text_WaitWhileSaveGame"] = 0x2C4EFF, + ["FallarborTown_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C47EB, + ["FallarborTown_BattleTentLobby_Text_WhichLevelMode"] = 0x2C4B77, + ["FarawayIsland_Entrance_Text_SailorReturn"] = 0x2C6B42, + ["FarawayIsland_Entrance_Text_Sign"] = 0x2C6D5A, + ["FarawayIsland_Interior_Text_Mew"] = 0x2C6DFF, + ["GabbyAndTy_Text_CleverItemSkillsInterviewAgain"] = 0x28B2FA, + ["GabbyAndTy_Text_DescribeYourFeelings"] = 0x28B433, + ["GabbyAndTy_Text_DontGiveUpKeepingEyeOut"] = 0x28B577, + ["GabbyAndTy_Text_GabbyDefeat"] = 0x28B719, + ["GabbyAndTy_Text_GabbyDefeatFirstTime"] = 0x28B000, + ["GabbyAndTy_Text_GabbyIntro"] = 0x28AF7D, + ["GabbyAndTy_Text_GabbyNotEnoughMons"] = 0x28B5EC, + ["GabbyAndTy_Text_GabbyPreFirstBattle"] = 0x28AF05, + ["GabbyAndTy_Text_GiveUsAnInterviewThisTime"] = 0x28B62D, + ["GabbyAndTy_Text_InterviewAgain"] = 0x28B3F3, + ["GabbyAndTy_Text_KeepingAnEyeOutForYou"] = 0x28B5C0, + ["GabbyAndTy_Text_PerfectWellBeSeeingYou"] = 0x28B4AB, + ["GabbyAndTy_Text_QuoteFromLastInterview"] = 0x28B137, + ["GabbyAndTy_Text_TyDefeat"] = 0x28B8F6, + ["GabbyAndTy_Text_TyDefeatFirstTime"] = 0x28B8B1, + ["GabbyAndTy_Text_TyIntro"] = 0x28B7B1, + ["GabbyAndTy_Text_TyNotEnoughMons"] = 0x28B841, + ["GabbyAndTy_Text_TyPostBattle"] = 0x28B805, + ["GabbyAndTy_Text_TyPreFirstBattle"] = 0x28B75C, + ["GabbyAndTy_Text_WeLookedRespectableInterviewAgain"] = 0x28B379, + ["GabbyAndTy_Text_WhoAreYouInterview"] = 0x28B042, + ["GabbyAndTy_Text_YouStompedUsInterviewAgain"] = 0x28B1B3, + ["GabbyAndTy_Text_YouThrewABallAtUsInterviewAgain"] = 0x28B23D, + ["GiddyText_DontYouAgree"] = 0x294301, + ["GiddyText_IBoughtCrayonsWith120Colors"] = 0x294359, + ["GiddyText_ISoWantToGoOnAVacation"] = 0x294313, + ["GiddyText_Is"] = 0x2942FD, + ["GiddyText_LyingBackInTheGreenGrass"] = 0x2944A0, + ["GiddyText_SecretBasesAreSoWonderful"] = 0x2944D5, + ["GiddyText_SoAmusing"] = 0x2942E3, + ["GiddyText_SoDarling"] = 0x2942A1, + ["GiddyText_SoDesirable"] = 0x2942C6, + ["GiddyText_SoExciting"] = 0x2942D5, + ["GiddyText_SoMagical"] = 0x2942F0, + ["GiddyText_SoPretty"] = 0x294295, + ["GiddyText_SoRelaxed"] = 0x2942AE, + ["GiddyText_SoSunny"] = 0x2942BB, + ["GiddyText_WhatsTheBottomOfTheSeaLike"] = 0x29441C, + ["GiddyText_WhenYouSeeTheSettingSunDoesIt"] = 0x294460, + ["GiddyText_WhenYouWriteOnASandyBeach"] = 0x2943DA, + ["GiddyText_WouldntItBeNiceIfWeCouldFloat"] = 0x294398, + ["InsideOfTruck_Text_BoxPrintedWithMonLogo"] = 0x23BF75, + ["LandmarkName_AbandonedShip"] = 0x5B8CD1, + ["LandmarkName_AlteringCave"] = 0x5B8EE1, + ["LandmarkName_AncientTomb"] = 0x5B8E85, + ["LandmarkName_BerryMastersHouse"] = 0x5B8E49, + ["LandmarkName_CableCar"] = 0x5B8D56, + ["LandmarkName_CyclingRoad"] = 0x5B8CFF, + ["LandmarkName_Desert"] = 0x5B8D3A, + ["LandmarkName_DesertRuins"] = 0x5B8E6A, + ["LandmarkName_DesertUnderpass"] = 0x5B8EEF, + ["LandmarkName_FieryPath"] = 0x5B8E27, + ["LandmarkName_FlowerShop"] = 0x5B8CA0, + ["LandmarkName_FossilManiacsHouse"] = 0x5B8EA1, + ["LandmarkName_GlassWorkshop"] = 0x5B8D60, + ["LandmarkName_GraniteCave"] = 0x5B8DFC, + ["LandmarkName_HuntersHouse"] = 0x5B8EB7, + ["LandmarkName_IslandCave"] = 0x5B8E5E, + ["LandmarkName_JaggedPass"] = 0x5B8E32, + ["LandmarkName_LanettesHouse"] = 0x5B8E17, + ["LandmarkName_MagmaHideout"] = 0x5B8EC6, + ["LandmarkName_MeteorFalls"] = 0x5B8D81, + ["LandmarkName_MirageTower"] = 0x5B8ED4, + ["LandmarkName_MrBrineysCottage"] = 0x5B8CBC, + ["LandmarkName_MtPyre"] = 0x5B8DD8, + ["LandmarkName_NewMauville"] = 0x5B8D0C, + ["LandmarkName_OceanCurrent"] = 0x5B8E09, + ["LandmarkName_OldLadysRestShop"] = 0x5B8D25, + ["LandmarkName_PetalburgWoods"] = 0x5B8CAC, + ["LandmarkName_PokemonDayCare"] = 0x5B8DB2, + ["LandmarkName_RusturfTunnel"] = 0x5B8DA3, + ["LandmarkName_SafariZoneEntrance"] = 0x5B8DC3, + ["LandmarkName_ScorchedSlab"] = 0x5B8E77, + ["LandmarkName_SeafloorCavern"] = 0x5B8DEC, + ["LandmarkName_SealedChamber"] = 0x5B8E92, + ["LandmarkName_SeashoreHouse"] = 0x5B8CE0, + ["LandmarkName_ShoalCave"] = 0x5B8DE1, + ["LandmarkName_SkyPillar"] = 0x5B8E3E, + ["LandmarkName_SlateportBeach"] = 0x5B8CEF, + ["LandmarkName_TrainerHill"] = 0x5B8F00, + ["LandmarkName_TrickHouse"] = 0x5B8D19, + ["LandmarkName_TunnelersRestHouse"] = 0x5B8D8E, + ["LandmarkName_WeatherInstitute"] = 0x5B8D6F, + ["LandmarkName_WinstrateFamily"] = 0x5B8D41, + ["LilycoveCity_ContestLobby_Text_AlreadyWonEnterAnyway"] = 0x27B547, + ["LilycoveCity_ContestLobby_Text_ComeBackForPrizeLater"] = 0x27B6E7, + ["LilycoveCity_ContestLobby_Text_ComeThroughHere"] = 0x27B653, + ["LilycoveCity_ContestLobby_Text_ConfirmContestMon"] = 0x27B5C4, + ["LilycoveCity_ContestLobby_Text_ContestBeginShortly"] = 0x27C043, + ["LilycoveCity_ContestLobby_Text_ContestReception"] = 0x27AEA8, + ["LilycoveCity_ContestLobby_Text_CounterOnlyFor4PlayerContests"] = 0x27AEE3, + ["LilycoveCity_ContestLobby_Text_DescribeContest"] = 0x27EFE7, + ["LilycoveCity_ContestLobby_Text_EggCannotTakePart"] = 0x27B4C4, + ["LilycoveCity_ContestLobby_Text_EggCannotTakePart2"] = 0x27C186, + ["LilycoveCity_ContestLobby_Text_EnterContest1"] = 0x27AF28, + ["LilycoveCity_ContestLobby_Text_EnterContest2"] = 0x27BD7A, + ["LilycoveCity_ContestLobby_Text_EnterContest3"] = 0x27C0F6, + ["LilycoveCity_ContestLobby_Text_EnterWhichContest1"] = 0x27B423, + ["LilycoveCity_ContestLobby_Text_EnterWhichContest2"] = 0x27BE9E, + ["LilycoveCity_ContestLobby_Text_EnterWhichContest3"] = 0x27C119, + ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon1"] = 0x27B44A, + ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon2"] = 0x27BEC5, + ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon3"] = 0x27C1C3, + ["LilycoveCity_ContestLobby_Text_EnterWhichRank"] = 0x27B3FF, + ["LilycoveCity_ContestLobby_Text_Explain4PlayerContest"] = 0x27BD9D, + ["LilycoveCity_ContestLobby_Text_ExplainContestRanks"] = 0x27B221, + ["LilycoveCity_ContestLobby_Text_ExplainContestTypes"] = 0x27B17D, + ["LilycoveCity_ContestLobby_Text_ExplainContests"] = 0x27AF7A, + ["LilycoveCity_ContestLobby_Text_ExplainEMode"] = 0x27C5B1, + ["LilycoveCity_ContestLobby_Text_ExplainGMode"] = 0x27C742, + ["LilycoveCity_ContestLobby_Text_ExplainLinkContest"] = 0x27C340, + ["LilycoveCity_ContestLobby_Text_InterviewRequest"] = 0x27EF15, + ["LilycoveCity_ContestLobby_Text_LinkContestReception"] = 0x27C063, + ["LilycoveCity_ContestLobby_Text_LookingForwardToNextContest"] = 0x27F23F, + ["LilycoveCity_ContestLobby_Text_MonInNoCondition2"] = 0x27C140, + ["LilycoveCity_ContestLobby_Text_MonInNoConditionForContest"] = 0x27B501, + ["LilycoveCity_ContestLobby_Text_MonNotQualifiedForRank"] = 0x27B471, + ["LilycoveCity_ContestLobby_Text_NoWirelessAdapterInGMode"] = 0x27C879, + ["LilycoveCity_ContestLobby_Text_NowThatWeveClearedThatUp"] = 0x27AE47, + ["LilycoveCity_ContestLobby_Text_ParticipateAnotherTime"] = 0x27BD4F, + ["LilycoveCity_ContestLobby_Text_ParticipateAnotherTime2"] = 0x27BFA7, + ["LilycoveCity_ContestLobby_Text_PlayerAt4PCounterUseGMode"] = 0x27C254, + ["LilycoveCity_ContestLobby_Text_PlayersChoseDifferentContest"] = 0x27BF0E, + ["LilycoveCity_ContestLobby_Text_PlayersMadeDifferentChoice"] = 0x27BF4B, + ["LilycoveCity_ContestLobby_Text_PleaseDecideLinkLeader"] = 0x27C1EA, + ["LilycoveCity_ContestLobby_Text_PleaseDoShareStoryWithMe"] = 0x27F1EF, + ["LilycoveCity_ContestLobby_Text_PleaseWaitBButtonCancel"] = 0x27BF85, + ["LilycoveCity_ContestLobby_Text_PokemonWonWeHavePrize"] = 0x27B67B, + ["LilycoveCity_ContestLobby_Text_ProgressWillBeSaved"] = 0x27BD17, + ["LilycoveCity_ContestLobby_Text_ReceptionDontHavePokeblockCase"] = 0x27ADA7, + ["LilycoveCity_ContestLobby_Text_ThatsAllForInterview"] = 0x27F0EC, + ["LilycoveCity_ContestLobby_Text_TransmissionError"] = 0x27BEFA, + ["LilycoveCity_ContestLobby_Text_TransmissionErrorTryAgain"] = 0x27BFD2, + ["LilycoveCity_ContestLobby_Text_Transmitting"] = 0x27BEEC, + ["LilycoveCity_ContestLobby_Text_WhatImageWhenYouHearX"] = 0x27F03E, + ["LilycoveCity_ContestLobby_Text_WhichContestMode"] = 0x27C8FD, + ["LilycoveCity_ContestLobby_Text_WhichTopic1"] = 0x27AF5E, + ["LilycoveCity_ContestLobby_Text_WhichTopic2"] = 0x27C0DA, + ["LilycoveCity_ContestLobby_Text_YourMonIsEntryNum4"] = 0x27B5E2, + ["LilycoveCity_ContestLobby_Text_YourMonIsEntryNumX"] = 0x27BFF8, + ["LilycoveCity_DepartmentStore_1F_Text_AllFiveDigitsMatched"] = 0x2A6731, + ["LilycoveCity_DepartmentStore_1F_Text_ComeBackTomorrow"] = 0x2A6496, + ["LilycoveCity_DepartmentStore_1F_Text_FourDigitsMatched"] = 0x2A66ED, + ["LilycoveCity_DepartmentStore_1F_Text_LotteryCornerDrawTicket"] = 0x2A6390, + ["LilycoveCity_DepartmentStore_1F_Text_NoNumbersMatched"] = 0x2A663C, + ["LilycoveCity_DepartmentStore_1F_Text_NoRoomForThis"] = 0x2A678C, + ["LilycoveCity_DepartmentStore_1F_Text_PleasePickTicket"] = 0x2A64C8, + ["LilycoveCity_DepartmentStore_1F_Text_PleaseVisitAgain"] = 0x2A64B1, + ["LilycoveCity_DepartmentStore_1F_Text_PleaseVisitAgain2"] = 0x2A6831, + ["LilycoveCity_DepartmentStore_1F_Text_PrizeWeveBeenHolding"] = 0x2A67E1, + ["LilycoveCity_DepartmentStore_1F_Text_ThreeDigitsMatched"] = 0x2A66A7, + ["LilycoveCity_DepartmentStore_1F_Text_TicketMatchesPCMon"] = 0x2A65E6, + ["LilycoveCity_DepartmentStore_1F_Text_TicketMatchesPartyMon"] = 0x2A6592, + ["LilycoveCity_DepartmentStore_1F_Text_TicketNumberIsXPleaseWait"] = 0x2A650B, + ["LilycoveCity_DepartmentStore_1F_Text_TwoDigitsMatched"] = 0x2A6664, + ["LilycoveCity_PokemonCenter_1F_Text_AskingForOnePokeblock"] = 0x2A94E8, + ["LilycoveCity_PokemonCenter_1F_Text_DontHaveAPokeblockCase"] = 0x2A9451, + ["LilycoveCity_PokemonCenter_1F_Text_HowBoringBye"] = 0x2A8F65, + ["LilycoveCity_PokemonCenter_1F_Text_ICantHaveOnePokeblock"] = 0x2A9537, + ["LilycoveCity_PokemonCenter_1F_Text_IGetToKeepPrize"] = 0x2A90CD, + ["LilycoveCity_PokemonCenter_1F_Text_IWillCherishThis"] = 0x2A8C6F, + ["LilycoveCity_PokemonCenter_1F_Text_IWillTreasureThis"] = 0x2A8CC8, + ["LilycoveCity_PokemonCenter_1F_Text_IllGiveYouThisInReturn"] = 0x2A8D5D, + ["LilycoveCity_PokemonCenter_1F_Text_IllLookForAChallenger"] = 0x2A9336, + ["LilycoveCity_PokemonCenter_1F_Text_IllTryToCherishIt"] = 0x2A8C0F, + ["LilycoveCity_PokemonCenter_1F_Text_IllUseYourPokeblock"] = 0x2A9571, + ["LilycoveCity_PokemonCenter_1F_Text_ImTheContestLady"] = 0x2A93A7, + ["LilycoveCity_PokemonCenter_1F_Text_ImTheFavorLady"] = 0x2A8A69, + ["LilycoveCity_PokemonCenter_1F_Text_ImTheQuizLady"] = 0x2A8E2B, + ["LilycoveCity_PokemonCenter_1F_Text_IsThatSoGoodbye"] = 0x2A8BCD, + ["LilycoveCity_PokemonCenter_1F_Text_Kecleon"] = 0x2A96E6, + ["LilycoveCity_PokemonCenter_1F_Text_MakeYourOwnQuiz"] = 0x2A90FB, + ["LilycoveCity_PokemonCenter_1F_Text_MaybeNextTime"] = 0x2A9131, + ["LilycoveCity_PokemonCenter_1F_Text_MyFriendDisplaysQuality"] = 0x2A93F4, + ["LilycoveCity_PokemonCenter_1F_Text_NoChangeThanks"] = 0x2A95AD, + ["LilycoveCity_PokemonCenter_1F_Text_NotWillingToShare"] = 0x2A8BEE, + ["LilycoveCity_PokemonCenter_1F_Text_ObsessedWithThing"] = 0x2A8A7D, + ["LilycoveCity_PokemonCenter_1F_Text_PickYourPrize"] = 0x2A9153, + ["LilycoveCity_PokemonCenter_1F_Text_Pikachu"] = 0x2A9703, + ["LilycoveCity_PokemonCenter_1F_Text_PlayerGaveMeBadThing"] = 0x2A8ACE, + ["LilycoveCity_PokemonCenter_1F_Text_PlayerGaveMeGreatThing"] = 0x2A8B36, + ["LilycoveCity_PokemonCenter_1F_Text_Poochyena"] = 0x2A96F6, + ["LilycoveCity_PokemonCenter_1F_Text_QuitChoosingPrize"] = 0x2A9212, + ["LilycoveCity_PokemonCenter_1F_Text_QuitWritingQuizAnswer"] = 0x2A9305, + ["LilycoveCity_PokemonCenter_1F_Text_QuitWritingQuizQuestion"] = 0x2A92D3, + ["LilycoveCity_PokemonCenter_1F_Text_ReadyToEnterContests"] = 0x2A9669, + ["LilycoveCity_PokemonCenter_1F_Text_ReallyImprovedThanks"] = 0x2A9605, + ["LilycoveCity_PokemonCenter_1F_Text_Skitty"] = 0x2A970E, + ["LilycoveCity_PokemonCenter_1F_Text_TakeQuizChallenge"] = 0x2A8EEC, + ["LilycoveCity_PokemonCenter_1F_Text_TakeTheQuizAnotherTime"] = 0x2A8F9A, + ["LilycoveCity_PokemonCenter_1F_Text_ThankForPokeblock"] = 0x2A93D6, + ["LilycoveCity_PokemonCenter_1F_Text_ThankYouForLastTime"] = 0x2A8AB1, + ["LilycoveCity_PokemonCenter_1F_Text_WaitForAnswer"] = 0x2A8F4D, + ["LilycoveCity_PokemonCenter_1F_Text_WaitingForChallenger"] = 0x2A8EAC, + ["LilycoveCity_PokemonCenter_1F_Text_WaitingToTakeYourQuiz"] = 0x2A8E4E, + ["LilycoveCity_PokemonCenter_1F_Text_WhatACheapskate"] = 0x2A9556, + ["LilycoveCity_PokemonCenter_1F_Text_WhatWillYouGiveMe"] = 0x2A8BAD, + ["LilycoveCity_PokemonCenter_1F_Text_WillYouShareThing"] = 0x2A8B69, + ["LilycoveCity_PokemonCenter_1F_Text_WriteYourQuiz"] = 0x2A9270, + ["LilycoveCity_PokemonCenter_1F_Text_WrongTheCorrectAnswerIs"] = 0x2A90A5, + ["LilycoveCity_PokemonCenter_1F_Text_XReceivedOneY"] = 0x2A9056, + ["LilycoveCity_PokemonCenter_1F_Text_YouDontHaveSpaceForIt"] = 0x2A8DBD, + ["LilycoveCity_PokemonCenter_1F_Text_YouGotItRight"] = 0x2A8FC7, + ["LilycoveCity_PokemonCenter_1F_Text_YouGotItRightYouveWonPersonsPrize"] = 0x2A9007, + ["LilycoveCity_PokemonCenter_1F_Text_YourBagIsFilledUp"] = 0x2A906A, + ["LilycoveCity_PokemonCenter_1F_Text_YoureGoingToQuit"] = 0x2A8F7E, + ["LilycoveCity_PokemonCenter_1F_Text_Zigzagoon"] = 0x2A96DA, + ["LilycoveCity_PokemonTrainerFanClub_HopeYouCatchTVSpecial"] = 0x281DB4, + ["LilycoveCity_PokemonTrainerFanClub_Text_HaveYouForgottenTrainer"] = 0x281C3D, + ["LilycoveCity_PokemonTrainerFanClub_Text_HaveYouForgottenTrainer2"] = 0x281CCD, + ["LilycoveCity_PokemonTrainerFanClub_Text_HowStrongRateTrainer"] = 0x281C95, + ["LilycoveCity_PokemonTrainerFanClub_Text_ThankYouIllShareThisInfo"] = 0x281D40, + ["LilycoveCity_PokemonTrainerFanClub_Text_ThatsWhatYouThink"] = 0x281C06, + ["LilycoveCity_PokemonTrainerFanClub_Text_WhatsYourOpinionOfTrainer"] = 0x281BCB, + ["LilycoveCity_PokemonTrainerFanClub_Text_WhatsYourOpinionOfTrainer2"] = 0x281C65, + ["LilycoveCity_PokemonTrainerFanClub_Text_YouShouldMeetTrainer"] = 0x281CF5, + ["LilycoveCity_Text_BecauseYoureTrainer"] = 0x2A72E3, + ["LilycoveCity_Text_BerrySuitsYou"] = 0x2A7244, + ["LilycoveCity_Text_PokeblocksSuitPokemon"] = 0x2A7321, + ["LittlerootTown_ProfessorBirchsLab_Text_LikeYouToHavePokemon"] = 0x1FA74D, + ["MatchCall_BattleDomeText1"] = 0x2B0BC5, + ["MatchCall_BattleDomeText10"] = 0x2B10A7, + ["MatchCall_BattleDomeText11"] = 0x2B1121, + ["MatchCall_BattleDomeText12"] = 0x2B11D3, + ["MatchCall_BattleDomeText13"] = 0x2B124D, + ["MatchCall_BattleDomeText14"] = 0x2B12D0, + ["MatchCall_BattleDomeText2"] = 0x2B0C4A, + ["MatchCall_BattleDomeText3"] = 0x2B0CC7, + ["MatchCall_BattleDomeText4"] = 0x2B0D4A, + ["MatchCall_BattleDomeText5"] = 0x2B0DC8, + ["MatchCall_BattleDomeText6"] = 0x2B0E35, + ["MatchCall_BattleDomeText7"] = 0x2B0ED1, + ["MatchCall_BattleDomeText8"] = 0x2B0F72, + ["MatchCall_BattleDomeText9"] = 0x2B102A, + ["MatchCall_BattleFrontierRecordStreakText1"] = 0x2B03E6, + ["MatchCall_BattleFrontierRecordStreakText10"] = 0x2B0908, + ["MatchCall_BattleFrontierRecordStreakText11"] = 0x2B0984, + ["MatchCall_BattleFrontierRecordStreakText12"] = 0x2B0A11, + ["MatchCall_BattleFrontierRecordStreakText13"] = 0x2B0AB8, + ["MatchCall_BattleFrontierRecordStreakText14"] = 0x2B0B45, + ["MatchCall_BattleFrontierRecordStreakText2"] = 0x2B0457, + ["MatchCall_BattleFrontierRecordStreakText3"] = 0x2B04DE, + ["MatchCall_BattleFrontierRecordStreakText4"] = 0x2B0586, + ["MatchCall_BattleFrontierRecordStreakText5"] = 0x2B061C, + ["MatchCall_BattleFrontierRecordStreakText6"] = 0x2B06A3, + ["MatchCall_BattleFrontierRecordStreakText7"] = 0x2B073B, + ["MatchCall_BattleFrontierRecordStreakText8"] = 0x2B07C7, + ["MatchCall_BattleFrontierRecordStreakText9"] = 0x2B0894, + ["MatchCall_BattleFrontierStreakText1"] = 0x2AFC07, + ["MatchCall_BattleFrontierStreakText10"] = 0x2B0129, + ["MatchCall_BattleFrontierStreakText11"] = 0x2B01A5, + ["MatchCall_BattleFrontierStreakText12"] = 0x2B0232, + ["MatchCall_BattleFrontierStreakText13"] = 0x2B02D9, + ["MatchCall_BattleFrontierStreakText14"] = 0x2B0366, + ["MatchCall_BattleFrontierStreakText2"] = 0x2AFC78, + ["MatchCall_BattleFrontierStreakText3"] = 0x2AFCFF, + ["MatchCall_BattleFrontierStreakText4"] = 0x2AFDA7, + ["MatchCall_BattleFrontierStreakText5"] = 0x2AFE3D, + ["MatchCall_BattleFrontierStreakText6"] = 0x2AFECA, + ["MatchCall_BattleFrontierStreakText7"] = 0x2AFF64, + ["MatchCall_BattleFrontierStreakText8"] = 0x2AFFF0, + ["MatchCall_BattleFrontierStreakText9"] = 0x2B00B5, + ["MatchCall_BattlePikeText1"] = 0x2B1347, + ["MatchCall_BattlePikeText10"] = 0x2B17F8, + ["MatchCall_BattlePikeText11"] = 0x2B1877, + ["MatchCall_BattlePikeText12"] = 0x2B1946, + ["MatchCall_BattlePikeText13"] = 0x2B19C7, + ["MatchCall_BattlePikeText14"] = 0x2B1A4C, + ["MatchCall_BattlePikeText2"] = 0x2B13B1, + ["MatchCall_BattlePikeText3"] = 0x2B142B, + ["MatchCall_BattlePikeText4"] = 0x2B14B4, + ["MatchCall_BattlePikeText5"] = 0x2B1525, + ["MatchCall_BattlePikeText6"] = 0x2B158E, + ["MatchCall_BattlePikeText7"] = 0x2B1622, + ["MatchCall_BattlePikeText8"] = 0x2B169D, + ["MatchCall_BattlePikeText9"] = 0x2B1775, + ["MatchCall_BattlePyramidText1"] = 0x2B1ACA, + ["MatchCall_BattlePyramidText10"] = 0x2B1FA8, + ["MatchCall_BattlePyramidText11"] = 0x2B2022, + ["MatchCall_BattlePyramidText12"] = 0x2B20F3, + ["MatchCall_BattlePyramidText13"] = 0x2B2175, + ["MatchCall_BattlePyramidText14"] = 0x2B21FC, + ["MatchCall_BattlePyramidText2"] = 0x2B1B50, + ["MatchCall_BattlePyramidText3"] = 0x2B1BE4, + ["MatchCall_BattlePyramidText4"] = 0x2B1C6A, + ["MatchCall_BattlePyramidText5"] = 0x2B1CCF, + ["MatchCall_BattlePyramidText6"] = 0x2B1D38, + ["MatchCall_BattlePyramidText7"] = 0x2B1DCD, + ["MatchCall_BattlePyramidText8"] = 0x2B1E4B, + ["MatchCall_BattlePyramidText9"] = 0x2B1F24, + ["MatchCall_DifferentRouteBattleRequestText1"] = 0x2AB670, + ["MatchCall_DifferentRouteBattleRequestText10"] = 0x2ABC26, + ["MatchCall_DifferentRouteBattleRequestText11"] = 0x2ABCE9, + ["MatchCall_DifferentRouteBattleRequestText12"] = 0x2ABDA2, + ["MatchCall_DifferentRouteBattleRequestText13"] = 0x2ABE5E, + ["MatchCall_DifferentRouteBattleRequestText14"] = 0x2ABF36, + ["MatchCall_DifferentRouteBattleRequestText2"] = 0x2AB6CD, + ["MatchCall_DifferentRouteBattleRequestText3"] = 0x2AB73C, + ["MatchCall_DifferentRouteBattleRequestText4"] = 0x2AB808, + ["MatchCall_DifferentRouteBattleRequestText5"] = 0x2AB8B7, + ["MatchCall_DifferentRouteBattleRequestText6"] = 0x2AB95D, + ["MatchCall_DifferentRouteBattleRequestText7"] = 0x2ABA03, + ["MatchCall_DifferentRouteBattleRequestText8"] = 0x2ABA9F, + ["MatchCall_DifferentRouteBattleRequestText9"] = 0x2ABB62, + ["MatchCall_NegativeBattleText1"] = 0x2A9FAB, + ["MatchCall_NegativeBattleText10"] = 0x2AA442, + ["MatchCall_NegativeBattleText11"] = 0x2AA4C5, + ["MatchCall_NegativeBattleText12"] = 0x2AA520, + ["MatchCall_NegativeBattleText13"] = 0x2AA5AD, + ["MatchCall_NegativeBattleText14"] = 0x2AA64D, + ["MatchCall_NegativeBattleText2"] = 0x2AA028, + ["MatchCall_NegativeBattleText3"] = 0x2AA099, + ["MatchCall_NegativeBattleText4"] = 0x2AA100, + ["MatchCall_NegativeBattleText5"] = 0x2AA188, + ["MatchCall_NegativeBattleText6"] = 0x2AA214, + ["MatchCall_NegativeBattleText7"] = 0x2AA2A1, + ["MatchCall_NegativeBattleText8"] = 0x2AA31B, + ["MatchCall_NegativeBattleText9"] = 0x2AA3A8, + ["MatchCall_PersonalizedText1"] = 0x2AC009, + ["MatchCall_PersonalizedText10"] = 0x2AC755, + ["MatchCall_PersonalizedText11"] = 0x2AC82C, + ["MatchCall_PersonalizedText12"] = 0x2AC914, + ["MatchCall_PersonalizedText13"] = 0x2ACA59, + ["MatchCall_PersonalizedText14"] = 0x2ACB02, + ["MatchCall_PersonalizedText15"] = 0x2ACC3F, + ["MatchCall_PersonalizedText16"] = 0x2ACD2F, + ["MatchCall_PersonalizedText17"] = 0x2ACE1E, + ["MatchCall_PersonalizedText18"] = 0x2ACF32, + ["MatchCall_PersonalizedText19"] = 0x2ACFBE, + ["MatchCall_PersonalizedText2"] = 0x2AC0BD, + ["MatchCall_PersonalizedText20"] = 0x2AD0AC, + ["MatchCall_PersonalizedText21"] = 0x2AD194, + ["MatchCall_PersonalizedText22"] = 0x2AD1DF, + ["MatchCall_PersonalizedText23"] = 0x2AD2A8, + ["MatchCall_PersonalizedText24"] = 0x2AD34F, + ["MatchCall_PersonalizedText25"] = 0x2AD44E, + ["MatchCall_PersonalizedText26"] = 0x2AD53A, + ["MatchCall_PersonalizedText27"] = 0x2AD642, + ["MatchCall_PersonalizedText28"] = 0x2AD801, + ["MatchCall_PersonalizedText29"] = 0x2AD92E, + ["MatchCall_PersonalizedText3"] = 0x2AC18C, + ["MatchCall_PersonalizedText30"] = 0x2ADA8F, + ["MatchCall_PersonalizedText31"] = 0x2ADB9B, + ["MatchCall_PersonalizedText32"] = 0x2ADC92, + ["MatchCall_PersonalizedText33"] = 0x2ADE08, + ["MatchCall_PersonalizedText34"] = 0x2ADF07, + ["MatchCall_PersonalizedText35"] = 0x2AE0D9, + ["MatchCall_PersonalizedText36"] = 0x2AE1FD, + ["MatchCall_PersonalizedText37"] = 0x2AE327, + ["MatchCall_PersonalizedText38"] = 0x2AE3DA, + ["MatchCall_PersonalizedText39"] = 0x2AE489, + ["MatchCall_PersonalizedText4"] = 0x2AC228, + ["MatchCall_PersonalizedText40"] = 0x2AE5CD, + ["MatchCall_PersonalizedText41"] = 0x2AE698, + ["MatchCall_PersonalizedText42"] = 0x2AE704, + ["MatchCall_PersonalizedText43"] = 0x2AE78F, + ["MatchCall_PersonalizedText44"] = 0x2AE859, + ["MatchCall_PersonalizedText45"] = 0x2AE8E6, + ["MatchCall_PersonalizedText46"] = 0x2AE998, + ["MatchCall_PersonalizedText47"] = 0x2AEA8F, + ["MatchCall_PersonalizedText48"] = 0x2AEB77, + ["MatchCall_PersonalizedText49"] = 0x2AEBFA, + ["MatchCall_PersonalizedText5"] = 0x2AC30C, + ["MatchCall_PersonalizedText50"] = 0x2AECC1, + ["MatchCall_PersonalizedText51"] = 0x2AED52, + ["MatchCall_PersonalizedText52"] = 0x2AEE35, + ["MatchCall_PersonalizedText53"] = 0x2AEEF4, + ["MatchCall_PersonalizedText54"] = 0x2AEFDA, + ["MatchCall_PersonalizedText55"] = 0x2AF0E7, + ["MatchCall_PersonalizedText56"] = 0x2AF1B8, + ["MatchCall_PersonalizedText57"] = 0x2AF2C4, + ["MatchCall_PersonalizedText58"] = 0x2AF371, + ["MatchCall_PersonalizedText59"] = 0x2AF480, + ["MatchCall_PersonalizedText6"] = 0x2AC3B6, + ["MatchCall_PersonalizedText60"] = 0x2AF671, + ["MatchCall_PersonalizedText61"] = 0x2AF7D8, + ["MatchCall_PersonalizedText62"] = 0x2AF8F7, + ["MatchCall_PersonalizedText63"] = 0x2AFA39, + ["MatchCall_PersonalizedText64"] = 0x2AFB26, + ["MatchCall_PersonalizedText7"] = 0x2AC446, + ["MatchCall_PersonalizedText8"] = 0x2AC5C7, + ["MatchCall_PersonalizedText9"] = 0x2AC682, + ["MatchCall_PositiveBattleText1"] = 0x2AA6AF, + ["MatchCall_PositiveBattleText10"] = 0x2AAB8C, + ["MatchCall_PositiveBattleText11"] = 0x2AAC25, + ["MatchCall_PositiveBattleText12"] = 0x2AAC9D, + ["MatchCall_PositiveBattleText13"] = 0x2AAD41, + ["MatchCall_PositiveBattleText14"] = 0x2AAE00, + ["MatchCall_PositiveBattleText2"] = 0x2AA730, + ["MatchCall_PositiveBattleText3"] = 0x2AA77A, + ["MatchCall_PositiveBattleText4"] = 0x2AA81C, + ["MatchCall_PositiveBattleText5"] = 0x2AA88C, + ["MatchCall_PositiveBattleText6"] = 0x2AA934, + ["MatchCall_PositiveBattleText7"] = 0x2AA9D3, + ["MatchCall_PositiveBattleText8"] = 0x2AAA40, + ["MatchCall_PositiveBattleText9"] = 0x2AAAE4, + ["MatchCall_SameRouteBattleRequestText1"] = 0x2AAE7F, + ["MatchCall_SameRouteBattleRequestText10"] = 0x2AB382, + ["MatchCall_SameRouteBattleRequestText11"] = 0x2AB410, + ["MatchCall_SameRouteBattleRequestText12"] = 0x2AB4B0, + ["MatchCall_SameRouteBattleRequestText13"] = 0x2AB538, + ["MatchCall_SameRouteBattleRequestText14"] = 0x2AB5E4, + ["MatchCall_SameRouteBattleRequestText2"] = 0x2AAEF1, + ["MatchCall_SameRouteBattleRequestText3"] = 0x2AAF69, + ["MatchCall_SameRouteBattleRequestText4"] = 0x2AB010, + ["MatchCall_SameRouteBattleRequestText5"] = 0x2AB076, + ["MatchCall_SameRouteBattleRequestText6"] = 0x2AB11A, + ["MatchCall_SameRouteBattleRequestText7"] = 0x2AB1B4, + ["MatchCall_SameRouteBattleRequestText8"] = 0x2AB23D, + ["MatchCall_SameRouteBattleRequestText9"] = 0x2AB2E9, + ["MatchCall_Text_BirchRegisterCall"] = 0x2B5F52, + ["MatchCall_Text_Brawly_PostRematch"] = 0x2B28D1, + ["MatchCall_Text_Brawly_Preparing"] = 0x2B2659, + ["MatchCall_Text_Brawly_PreparingPostGame"] = 0x2B275D, + ["MatchCall_Text_Brawly_RematchReady"] = 0x2B286F, + ["MatchCall_Text_Brendan1"] = 0x2B43EF, + ["MatchCall_Text_Brendan10"] = 0x2B49C4, + ["MatchCall_Text_Brendan11"] = 0x2B4A44, + ["MatchCall_Text_Brendan12"] = 0x2B4B28, + ["MatchCall_Text_Brendan13"] = 0x2B4C15, + ["MatchCall_Text_Brendan14"] = 0x2B4CD8, + ["MatchCall_Text_Brendan15"] = 0x2B4D46, + ["MatchCall_Text_Brendan2"] = 0x2B4486, + ["MatchCall_Text_Brendan3"] = 0x2B4560, + ["MatchCall_Text_Brendan4"] = 0x2B463F, + ["MatchCall_Text_Brendan5"] = 0x2B46B7, + ["MatchCall_Text_Brendan6"] = 0x2B4761, + ["MatchCall_Text_Brendan7"] = 0x2B47F4, + ["MatchCall_Text_Brendan8"] = 0x2B4882, + ["MatchCall_Text_Brendan9"] = 0x2B4909, + ["MatchCall_Text_BrendanRayquazaCall"] = 0x2B39C6, + ["MatchCall_Text_Drake"] = 0x2B368B, + ["MatchCall_Text_Flannery_PostRematch"] = 0x2B2D54, + ["MatchCall_Text_Flannery_Preparing"] = 0x2B2B4D, + ["MatchCall_Text_Flannery_PreparingPostGame"] = 0x2B2C0E, + ["MatchCall_Text_Flannery_RematchReady"] = 0x2B2CF1, + ["MatchCall_Text_Glacia"] = 0x2B35E4, + ["MatchCall_Text_Juan_PostRematch"] = 0x2B341E, + ["MatchCall_Text_Juan_Preparing"] = 0x2B3249, + ["MatchCall_Text_Juan_PreparingPostGame"] = 0x2B32EC, + ["MatchCall_Text_Juan_RematchReady"] = 0x2B33AA, + ["MatchCall_Text_May1"] = 0x2B3AB3, + ["MatchCall_Text_May10"] = 0x2B3FFB, + ["MatchCall_Text_May11"] = 0x2B4077, + ["MatchCall_Text_May12"] = 0x2B414B, + ["MatchCall_Text_May13"] = 0x2B4228, + ["MatchCall_Text_May14"] = 0x2B42E0, + ["MatchCall_Text_May15"] = 0x2B4350, + ["MatchCall_Text_May2"] = 0x2B3B3F, + ["MatchCall_Text_May3"] = 0x2B3C13, + ["MatchCall_Text_May4"] = 0x2B3CF3, + ["MatchCall_Text_May5"] = 0x2B3D4B, + ["MatchCall_Text_May6"] = 0x2B3DD1, + ["MatchCall_Text_May7"] = 0x2B3E69, + ["MatchCall_Text_May8"] = 0x2B3ECD, + ["MatchCall_Text_May9"] = 0x2B3F55, + ["MatchCall_Text_MayRayquazaCall"] = 0x2B38C1, + ["MatchCall_Text_Mom1"] = 0x2B227B, + ["MatchCall_Text_Mom2"] = 0x2B2310, + ["MatchCall_Text_Mom3"] = 0x2B23F3, + ["MatchCall_Text_MrStone1"] = 0x2B60C0, + ["MatchCall_Text_MrStone10"] = 0x2B6703, + ["MatchCall_Text_MrStone11"] = 0x2B67ED, + ["MatchCall_Text_MrStone2"] = 0x2B61E6, + ["MatchCall_Text_MrStone3"] = 0x2B6302, + ["MatchCall_Text_MrStone4"] = 0x2B63A0, + ["MatchCall_Text_MrStone5"] = 0x2B64A2, + ["MatchCall_Text_MrStone6"] = 0x2B6526, + ["MatchCall_Text_MrStone7"] = 0x2B65BB, + ["MatchCall_Text_MrStone8"] = 0x2B6664, + ["MatchCall_Text_MrStone9"] = 0x2B66B1, + ["MatchCall_Text_Norman1"] = 0x2B5719, + ["MatchCall_Text_Norman2"] = 0x2B5795, + ["MatchCall_Text_Norman3"] = 0x2B584D, + ["MatchCall_Text_Norman4"] = 0x2B58E3, + ["MatchCall_Text_Norman5"] = 0x2B5979, + ["MatchCall_Text_Norman_PostRematch"] = 0x2B5B5E, + ["MatchCall_Text_Norman_Preparing"] = 0x2B5A07, + ["MatchCall_Text_Norman_PreparingPostGame"] = 0x2B5A69, + ["MatchCall_Text_Norman_RematchReady"] = 0x2B5ACF, + ["MatchCall_Text_Phoebe"] = 0x2B3561, + ["MatchCall_Text_RegisteredBirch"] = 0x2B603A, + ["MatchCall_Text_Roxanne_PostRematch"] = 0x2B2607, + ["MatchCall_Text_Roxanne_Preparing"] = 0x2B2456, + ["MatchCall_Text_Roxanne_PreparingPostGame"] = 0x2B250E, + ["MatchCall_Text_Roxanne_RematchReady"] = 0x2B25C1, + ["MatchCall_Text_Scott1"] = 0x2B5184, + ["MatchCall_Text_Scott2"] = 0x2B5275, + ["MatchCall_Text_Scott3"] = 0x2B5323, + ["MatchCall_Text_Scott4"] = 0x2B53DB, + ["MatchCall_Text_Scott5"] = 0x2B54A5, + ["MatchCall_Text_Scott6"] = 0x2B5541, + ["MatchCall_Text_Scott7"] = 0x2B56CA, + ["MatchCall_Text_Sidney"] = 0x2B34CC, + ["MatchCall_Text_Steven1"] = 0x2B5B95, + ["MatchCall_Text_Steven2"] = 0x2B5C53, + ["MatchCall_Text_Steven3"] = 0x2B5CC9, + ["MatchCall_Text_Steven4"] = 0x2B5DB4, + ["MatchCall_Text_Steven5"] = 0x2B5E26, + ["MatchCall_Text_Steven6"] = 0x2B5EA2, + ["MatchCall_Text_Steven7"] = 0x2B5ED9, + ["MatchCall_Text_TateLiza_PostRematch"] = 0x2B31CD, + ["MatchCall_Text_TateLiza_Preparing"] = 0x2B2F97, + ["MatchCall_Text_TateLiza_PreparingPostGame"] = 0x2B306E, + ["MatchCall_Text_TateLiza_RematchReady"] = 0x2B3158, + ["MatchCall_Text_UnusedProfBirch"] = 0x2B6061, + ["MatchCall_Text_Wallace"] = 0x2B3790, + ["MatchCall_Text_Wally1"] = 0x2B4DE2, + ["MatchCall_Text_Wally2"] = 0x2B4E57, + ["MatchCall_Text_Wally3"] = 0x2B4EA5, + ["MatchCall_Text_Wally4"] = 0x2B4F41, + ["MatchCall_Text_Wally5"] = 0x2B4FF3, + ["MatchCall_Text_Wally6"] = 0x2B50B1, + ["MatchCall_Text_Wally7"] = 0x2B5100, + ["MatchCall_Text_Wattson_PostRematch"] = 0x2B2B01, + ["MatchCall_Text_Wattson_Preparing"] = 0x2B2912, + ["MatchCall_Text_Wattson_PreparingPostGame"] = 0x2B29CA, + ["MatchCall_Text_Wattson_RematchReady"] = 0x2B2AB6, + ["MatchCall_Text_Winona_PostRematch"] = 0x2B2F16, + ["MatchCall_Text_Winona_Preparing"] = 0x2B2DA4, + ["MatchCall_Text_Winona_PreparingPostGame"] = 0x2B2E2D, + ["MatchCall_Text_Winona_RematchReady"] = 0x2B2EC2, + ["MatchCall_WildBattleText1"] = 0x2A971C, + ["MatchCall_WildBattleText10"] = 0x2A9C36, + ["MatchCall_WildBattleText11"] = 0x2A9CC8, + ["MatchCall_WildBattleText12"] = 0x2A9D44, + ["MatchCall_WildBattleText13"] = 0x2A9DD7, + ["MatchCall_WildBattleText14"] = 0x2A9E70, + ["MatchCall_WildBattleText15"] = 0x2A9EFD, + ["MatchCall_WildBattleText2"] = 0x2A9798, + ["MatchCall_WildBattleText3"] = 0x2A9813, + ["MatchCall_WildBattleText4"] = 0x2A98A8, + ["MatchCall_WildBattleText5"] = 0x2A9977, + ["MatchCall_WildBattleText6"] = 0x2A99FD, + ["MatchCall_WildBattleText7"] = 0x2A9A78, + ["MatchCall_WildBattleText8"] = 0x2A9AE8, + ["MatchCall_WildBattleText9"] = 0x2A9BA7, + ["MauvilleCity_PokemonCenter_1F_Text_AlsoIWasThinking"] = 0x2902A7, + ["MauvilleCity_PokemonCenter_1F_Text_BardFeelingTheBlues1"] = 0x2903C0, + ["MauvilleCity_PokemonCenter_1F_Text_BardFeelingTheBlues2"] = 0x29049B, + ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesAction"] = 0x28F694, + ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesStory"] = 0x28F6B4, + ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesTitle"] = 0x28F678, + ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedAction"] = 0x28EA98, + ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedStory"] = 0x28EAA8, + ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedTitle"] = 0x28EA7D, + ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesAction"] = 0x28EB31, + ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesStory"] = 0x28EB3E, + ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesTitle"] = 0x28EB19, + ["MauvilleCity_PokemonCenter_1F_Text_CantTradeThatOne"] = 0x28E45A, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockAction"] = 0x28FDBD, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockStory"] = 0x28FDCE, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockTitle"] = 0x28FDA2, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexAction"] = 0x28FB35, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexStory"] = 0x28FB47, + ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexTitle"] = 0x28FB1D, + ["MauvilleCity_PokemonCenter_1F_Text_CouldThereBeOtherLegends"] = 0x28E78A, + ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedAction"] = 0x28EE5D, + ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedStory"] = 0x28EE6A, + ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedTitle"] = 0x28EE45, + ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsAction"] = 0x28F811, + ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsStory"] = 0x28F822, + ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsTitle"] = 0x28F7F6, + ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtAction"] = 0x28EDB5, + ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtStory"] = 0x28EDCF, + ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtTitle"] = 0x28EDA1, + ["MauvilleCity_PokemonCenter_1F_Text_GiddyFeelingTheBlues"] = 0x290281, + ["MauvilleCity_PokemonCenter_1F_Text_HaveYouAnyLegendaryTales"] = 0x28E7EE, + ["MauvilleCity_PokemonCenter_1F_Text_HaveYouHeardOfWord"] = 0x290666, + ["MauvilleCity_PokemonCenter_1F_Text_HearAnotherLegendaryTale"] = 0x28E844, + ["MauvilleCity_PokemonCenter_1F_Text_HearMyStory"] = 0x29022F, + ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsAction"] = 0x2900B5, + ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsStory"] = 0x2900CB, + ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsTitle"] = 0x290097, + ["MauvilleCity_PokemonCenter_1F_Text_IAlreadyTaughtYou"] = 0x290598, + ["MauvilleCity_PokemonCenter_1F_Text_IKnowNoTales"] = 0x28E6AE, + ["MauvilleCity_PokemonCenter_1F_Text_IWishMorePeopleWereInterested"] = 0x28E8D9, + ["MauvilleCity_PokemonCenter_1F_Text_IllSingThisSongForAWhile"] = 0x290514, + ["MauvilleCity_PokemonCenter_1F_Text_InterviewsAction"] = 0x28EBCD, + ["MauvilleCity_PokemonCenter_1F_Text_InterviewsStory"] = 0x28EBDD, + ["MauvilleCity_PokemonCenter_1F_Text_InterviewsTitle"] = 0x28EBB5, + ["MauvilleCity_PokemonCenter_1F_Text_IveGotNothingNewToTeach"] = 0x290602, + ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedAction"] = 0x28FC85, + ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedStory"] = 0x28FC98, + ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedTitle"] = 0x28FC6B, + ["MauvilleCity_PokemonCenter_1F_Text_LetMeSingItForYou"] = 0x2904C1, + ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksAction"] = 0x28F76A, + ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksStory"] = 0x28F776, + ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksTitle"] = 0x28F751, + ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesAction"] = 0x28F314, + ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesStory"] = 0x28F32A, + ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesTitle"] = 0x28F2FC, + ["MauvilleCity_PokemonCenter_1F_Text_NotWorthyOfLegend"] = 0x28E881, + ["MauvilleCity_PokemonCenter_1F_Text_OnceBelongedToPlayerDoYouWantIt"] = 0x28E2A9, + ["MauvilleCity_PokemonCenter_1F_Text_PickADecorItem"] = 0x28E23F, + ["MauvilleCity_PokemonCenter_1F_Text_PickTheDecorToTrade"] = 0x28E323, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtAction"] = 0x28ED21, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtStory"] = 0x28ED30, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtTitle"] = 0x28ED04, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedAction"] = 0x28EEF1, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedStory"] = 0x28EF01, + ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedTitle"] = 0x28EEDD, + ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsAction"] = 0x28FBD9, + ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsStory"] = 0x28FBEA, + ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsTitle"] = 0x28FBC4, + ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeAction"] = 0x28F05A, + ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeStory"] = 0x28F071, + ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeTitle"] = 0x28F045, + ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarAction"] = 0x28FFFA, + ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarStory"] = 0x29000D, + ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarTitle"] = 0x28FFDD, + ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsAction"] = 0x28F5D1, + ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsStory"] = 0x28F5F2, + ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsTitle"] = 0x28F5BE, + ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesAction"] = 0x28F10D, + ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesStory"] = 0x28F125, + ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesTitle"] = 0x28F0F3, + ["MauvilleCity_PokemonCenter_1F_Text_SavedGameAction"] = 0x28E947, + ["MauvilleCity_PokemonCenter_1F_Text_SavedGameStory"] = 0x28E956, + ["MauvilleCity_PokemonCenter_1F_Text_SavedGameTitle"] = 0x28E930, + ["MauvilleCity_PokemonCenter_1F_Text_SendDecorToYourPC"] = 0x28E424, + ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsAction"] = 0x28F51B, + ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsStory"] = 0x28F538, + ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsTitle"] = 0x28F50C, + ["MauvilleCity_PokemonCenter_1F_Text_SoWellTradeTheseDecor"] = 0x28E3C4, + ["MauvilleCity_PokemonCenter_1F_Text_StorytellerFeelingTheBlues"] = 0x28E64D, + ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedAction"] = 0x28FD35, + ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedStory"] = 0x28FD40, + ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedTitle"] = 0x28FD1D, + ["MauvilleCity_PokemonCenter_1F_Text_TeachWhatsHipAndHappening"] = 0x29054C, + ["MauvilleCity_PokemonCenter_1F_Text_ThatDecorIsInUse"] = 0x28E3EC, + ["MauvilleCity_PokemonCenter_1F_Text_ThatHowYouWantedSongToGo"] = 0x2904EB, + ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedAction"] = 0x28FA99, + ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedStory"] = 0x28FAA7, + ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedTitle"] = 0x28FA81, + ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedAction"] = 0x28F941, + ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedStory"] = 0x28F949, + ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedTitle"] = 0x28F92F, + ["MauvilleCity_PokemonCenter_1F_Text_TraderFeelingTheBlues"] = 0x28E1E8, + ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesAction"] = 0x28EC79, + ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesStory"] = 0x28EC81, + ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesTitle"] = 0x28EC60, + ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedAction"] = 0x28E9EF, + ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedStory"] = 0x28E9FE, + ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedTitle"] = 0x28E9D7, + ["MauvilleCity_PokemonCenter_1F_Text_UsedCutAction"] = 0x28F1D5, + ["MauvilleCity_PokemonCenter_1F_Text_UsedCutStory"] = 0x28F1DE, + ["MauvilleCity_PokemonCenter_1F_Text_UsedCutTitle"] = 0x28F1BE, + ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareAction"] = 0x28FF27, + ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareStory"] = 0x28FF44, + ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareTitle"] = 0x28FF0C, + ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderAction"] = 0x28F9EA, + ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderStory"] = 0x28F9FD, + ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderTitle"] = 0x28F9D1, + ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterAction"] = 0x28EF95, + ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterStory"] = 0x28EFAA, + ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterTitle"] = 0x28EF73, + ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashAction"] = 0x28F269, + ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashStory"] = 0x28F277, + ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashTitle"] = 0x28F24F, + ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashAction"] = 0x28F3C6, + ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashStory"] = 0x28F3D2, + ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashTitle"] = 0x28F3AD, + ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleAction"] = 0x28F461, + ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleStory"] = 0x28F47C, + ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleTitle"] = 0x28F44B, + ["MauvilleCity_PokemonCenter_1F_Text_WantToTradeDecor"] = 0x28E1B1, + ["MauvilleCity_PokemonCenter_1F_Text_WeShouldChatAgain"] = 0x2902BD, + ["MauvilleCity_PokemonCenter_1F_Text_WeveAlreadyTraded"] = 0x28E20E, + ["MauvilleCity_PokemonCenter_1F_Text_WhichTaleToTell"] = 0x28E673, + ["MauvilleCity_PokemonCenter_1F_Text_WillYouHearMyTale"] = 0x28E5F6, + ["MauvilleCity_PokemonCenter_1F_Text_WishICouldPlaySongForOthers"] = 0x2903E6, + ["MauvilleCity_PokemonCenter_1F_Text_WonContestsAction"] = 0x28F8AF, + ["MauvilleCity_PokemonCenter_1F_Text_WonContestsStory"] = 0x28F8BC, + ["MauvilleCity_PokemonCenter_1F_Text_WonContestsTitle"] = 0x28F89C, + ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryAction"] = 0x28FE72, + ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryStory"] = 0x28FE88, + ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryTitle"] = 0x28FE57, + ["MauvilleCity_PokemonCenter_1F_Text_WouldYouLikeToHearMySong"] = 0x29038E, + ["MauvilleCity_PokemonCenter_1F_Text_WouldYouLikeToWriteSomeLyrics"] = 0x290421, + ["MauvilleCity_PokemonCenter_1F_Text_YouDidStatXTimes"] = 0x28E726, + ["MauvilleCity_PokemonCenter_1F_Text_YouDontHaveAnyDecor"] = 0x28E2E3, + ["MauvilleCity_PokemonCenter_1F_Text_YouDontWantAnything"] = 0x28E27F, + ["MauvilleCity_PokemonCenter_1F_Text_YouDontWantToTrade"] = 0x28E356, + ["MauvilleCity_PokemonCenter_1F_Text_YouveNoRoomForThis"] = 0x28E380, + ["MossdeepCity_GameCorner_1F_Text_AdapterNotConnected"] = 0x278D68, + ["MossdeepCity_GameCorner_1F_Text_AllGoodToGo"] = 0x278E00, + ["MossdeepCity_GameCorner_1F_Text_ComeAgain"] = 0x278D51, + ["MossdeepCity_GameCorner_1F_Text_DescribeWhichGame"] = 0x278A7D, + ["MossdeepCity_GameCorner_1F_Text_DodrioBerryPickingInfo"] = 0x278BF1, + ["MossdeepCity_GameCorner_1F_Text_EnterWhichPokemon"] = 0x278DD9, + ["MossdeepCity_GameCorner_1F_Text_ExplainRequiredMon"] = 0x278E60, + ["MossdeepCity_GameCorner_1F_Text_LeavingDoComeAgain"] = 0x278E3C, + ["MossdeepCity_GameCorner_1F_Text_OnlyDodrioAllowed"] = 0x278FA4, + ["MossdeepCity_GameCorner_1F_Text_PlayWhichGame"] = 0x278DAD, + ["MossdeepCity_GameCorner_1F_Text_PokemonJumpInfo"] = 0x278ACB, + ["MossdeepCity_GameCorner_1F_Text_RetryPlease"] = 0x278FE0, + ["MossdeepCity_GameCorner_1F_Text_ShortJumpingPokemonAllowed"] = 0x278EDC, + ["MossdeepCity_GameCorner_1F_Text_TalkToOldManToPlay"] = 0x278CAC, + ["MossdeepCity_GameCorner_1F_Text_WelcomeCanYouWait"] = 0x278CEB, + ["MoveTutor_MimicDeclined"] = 0x2C737F, + ["MoveTutor_MimicTeach"] = 0x2C72B6, + ["MoveTutor_Text_DoubleEdgeDeclined"] = 0x2C7C7E, + ["MoveTutor_Text_DoubleEdgeTaught"] = 0x2C7CC8, + ["MoveTutor_Text_DoubleEdgeTeach"] = 0x2C7B8E, + ["MoveTutor_Text_DoubleEdgeWhichMon"] = 0x2C7C98, + ["MoveTutor_Text_DynamicPunchDeclined"] = 0x2C7AD4, + ["MoveTutor_Text_DynamicPunchTaught"] = 0x2C7B4F, + ["MoveTutor_Text_DynamicPunchTeach"] = 0x2C79A6, + ["MoveTutor_Text_DynamicPunchWhichMon"] = 0x2C7B0D, + ["MoveTutor_Text_ExplosionDeclined"] = 0x2C7E04, + ["MoveTutor_Text_ExplosionTaught"] = 0x2C7E7A, + ["MoveTutor_Text_ExplosionTeach"] = 0x2C7CFA, + ["MoveTutor_Text_ExplosionWhichMon"] = 0x2C7E40, + ["MoveTutor_Text_FuryCutterDeclined"] = 0x2C7221, + ["MoveTutor_Text_FuryCutterTaught"] = 0x2C726E, + ["MoveTutor_Text_FuryCutterTeach"] = 0x2C7174, + ["MoveTutor_Text_FuryCutterWhichMon"] = 0x2C7243, + ["MoveTutor_Text_MetronomeDeclined"] = 0x2C7556, + ["MoveTutor_Text_MetronomeTaught"] = 0x2C75B5, + ["MoveTutor_Text_MetronomeTeach"] = 0x2C7449, + ["MoveTutor_Text_MetronomeWhichMon"] = 0x2C7582, + ["MoveTutor_Text_MimicTaught"] = 0x2C73F6, + ["MoveTutor_Text_MimicWhichMon"] = 0x2C73B1, + ["MoveTutor_Text_RolloutDeclined"] = 0x2C70C4, + ["MoveTutor_Text_RolloutTaught"] = 0x2C7133, + ["MoveTutor_Text_RolloutTeach"] = 0x2C6FDB, + ["MoveTutor_Text_RolloutWhichMon"] = 0x2C70F3, + ["MoveTutor_Text_SleepTalkDeclined"] = 0x2C76E2, + ["MoveTutor_Text_SleepTalkTaught"] = 0x2C775A, + ["MoveTutor_Text_SleepTalkTeach"] = 0x2C7637, + ["MoveTutor_Text_SleepTalkWhichMon"] = 0x2C7721, + ["MoveTutor_Text_SubstituteDeclined"] = 0x2C78D1, + ["MoveTutor_Text_SubstituteTaught"] = 0x2C794B, + ["MoveTutor_Text_SubstituteTeach"] = 0x2C77C6, + ["MoveTutor_Text_SubstituteWhichMon"] = 0x2C7911, + ["MoveTutor_Text_SwaggerDeclined"] = 0x2C6F33, + ["MoveTutor_Text_SwaggerTaught"] = 0x2C6F9E, + ["MoveTutor_Text_SwaggerTeach"] = 0x2C6E37, + ["MoveTutor_Text_SwaggerWhichMon"] = 0x2C6F66, + ["MoveTutor_Text_ThisMoveCanOnlyBeLearnedOnce"] = 0x2C6E05, + ["MysteryGift_Text_TheresATicketForYou"] = 0x273594, + ["MysteryGift_Text_TryUsingItAtLilycovePort"] = 0x2735F2, + ["NavelRock_Harbor_Text_SailorReturn"] = 0x2C6CE6, + ["PetalburgCity_Gym_Text_GiveEnigmaBerry"] = 0x2A6D3D, + ["ProfileMan_Text_CollectTrainerProfiles"] = 0x291687, + ["ProfileMan_Text_EasyChatExplanation"] = 0x291729, + ["ProfileMan_Text_EvenBetterThanLastProfile"] = 0x291A6B, + ["ProfileMan_Text_FantasticProfile"] = 0x291B22, + ["ProfileMan_Text_ImagineYouWouldHaveWonderfulProfile"] = 0x291991, + ["ProfileMan_Text_LetsSeeItThen"] = 0x291969, + ["ProfileMan_Text_LikeProfileWayItIs"] = 0x291ABA, + ["ProfileMan_Text_MayISeeYourNewProfile"] = 0x291A4F, + ["ProfileMan_Text_MayISeeYourProfile"] = 0x2916FF, + ["ProfileMan_Text_NotIntoItRightNow"] = 0x2919DC, + ["ProfileMan_Text_YouHaveWonderfulSmile"] = 0x291A1B, + ["ProfileMan_Text_YouKnowSecretSaying"] = 0x291BB7, + ["Questionnaire_Text_FillOut"] = 0x27339F, + ["Questionnaire_Text_ThankYou"] = 0x2733D8, + ["Questionnaire_Text_YouCanAccessMysteryEvent"] = 0x273559, + ["Questionnaire_Text_YouCanAccessMysteryGift"] = 0x2734CC, + ["Questionnaire_Text_YouKnowThoseWordsEvent"] = 0x273506, + ["Questionnaire_Text_YouKnowThoseWordsGift"] = 0x273446, + ["RecordCorner_Text_PlayerSentOverOneX"] = 0x27863C, + ["RecordCorner_Text_TakeSeatAndWait"] = 0x27861C, + ["RecordCorner_Text_ThanksForComing"] = 0x27850E, + ["Roulette_Text_BoardWillBeCleared"] = 0x2A5C21, + ["Roulette_Text_CoinCaseIsFull"] = 0x2A5C61, + ["Roulette_Text_ControlsInstruction"] = 0x2A5B89, + ["Roulette_Text_ItsAHit"] = 0x2A5BCB, + ["Roulette_Text_Jackpot"] = 0x2A5BD7, + ["Roulette_Text_KeepPlaying"] = 0x2A5C13, + ["Roulette_Text_NoCoinsLeft"] = 0x2A5C04, + ["Roulette_Text_NotEnoughCoins"] = 0x2A5B4E, + ["Roulette_Text_NothingDoing"] = 0x2A5BE0, + ["Roulette_Text_PlayMinimumWagerIsX"] = 0x2A5B12, + ["Roulette_Text_SpecialRateTable"] = 0x2A5B6B, + ["Roulette_Text_YouDontHaveACoinCase"] = 0x2A5C45, + ["Roulette_Text_YouveWonXCoins"] = 0x2A5BEF, + ["Route102_Text_AllenDefeated"] = 0x2947AA, + ["Route102_Text_AllenIntro"] = 0x294775, + ["Route102_Text_AllenPostBattle"] = 0x2947DB, + ["Route102_Text_CalvinDefeated"] = 0x29457C, + ["Route102_Text_CalvinIntro"] = 0x294513, + ["Route102_Text_CalvinPostBattle"] = 0x2945AC, + ["Route102_Text_CalvinRegister"] = 0x2945EB, + ["Route102_Text_CalvinRegisterShort"] = 0x294668, + ["Route102_Text_CalvinRematchDefeated"] = 0x29470C, + ["Route102_Text_CalvinRematchIntro"] = 0x2946AE, + ["Route102_Text_CalvinRematchPostBattle"] = 0x29473C, + ["Route102_Text_RickDefeated"] = 0x294847, + ["Route102_Text_RickIntro"] = 0x29480C, + ["Route102_Text_RickPostBattle"] = 0x294859, + ["Route102_Text_TianaDefeated"] = 0x2948F3, + ["Route102_Text_TianaIntro"] = 0x29489E, + ["Route102_Text_TianaPostBattle"] = 0x294916, + ["Route103_Text_AmyDefeated"] = 0x294A3D, + ["Route103_Text_AmyIntro"] = 0x2949F9, + ["Route103_Text_AmyLivRegister"] = 0x294B8A, + ["Route103_Text_AmyNotEnoughPokemon"] = 0x294AAE, + ["Route103_Text_AmyPostBattle"] = 0x294A52, + ["Route103_Text_AmyRematchDefeated"] = 0x294C6D, + ["Route103_Text_AmyRematchIntro"] = 0x294C29, + ["Route103_Text_AmyRematchNotEnoughPokemon"] = 0x294CEF, + ["Route103_Text_AmyRematchPostBattle"] = 0x294C93, + ["Route103_Text_AndrewDefeated"] = 0x294E92, + ["Route103_Text_AndrewIntro"] = 0x294E34, + ["Route103_Text_AndrewPostBattle"] = 0x294EBB, + ["Route103_Text_DaisyDefeated"] = 0x29498F, + ["Route103_Text_DaisyIntro"] = 0x29495A, + ["Route103_Text_DaisyPostBattle"] = 0x2949B4, + ["Route103_Text_IsabelleDefeated"] = 0x295146, + ["Route103_Text_IsabelleIntro"] = 0x295116, + ["Route103_Text_IsabellePostBattle"] = 0x29514D, + ["Route103_Text_LivDefeated"] = 0x294B22, + ["Route103_Text_LivIntro"] = 0x294AFD, + ["Route103_Text_LivNotEnoughPokemon"] = 0x294BD0, + ["Route103_Text_LivPostBattle"] = 0x294B40, + ["Route103_Text_LivRematchDefeated"] = 0x294D63, + ["Route103_Text_LivRematchIntro"] = 0x294D3E, + ["Route103_Text_LivRematchNotEnoughPokemon"] = 0x294DDB, + ["Route103_Text_LivRematchPostBattle"] = 0x294D89, + ["Route103_Text_MarcosDefeated"] = 0x29522B, + ["Route103_Text_MarcosIntro"] = 0x295206, + ["Route103_Text_MarcosPostBattle"] = 0x295246, + ["Route103_Text_MiguelDefeated"] = 0x294F21, + ["Route103_Text_MiguelIntro"] = 0x294EDF, + ["Route103_Text_MiguelPostBattle"] = 0x294F42, + ["Route103_Text_MiguelRegister"] = 0x294F7E, + ["Route103_Text_MiguelRematchDefeated"] = 0x295006, + ["Route103_Text_MiguelRematchIntro"] = 0x294FC3, + ["Route103_Text_MiguelRematchPostBattle"] = 0x29501E, + ["Route103_Text_PeteDefeated"] = 0x295093, + ["Route103_Text_PeteIntro"] = 0x295063, + ["Route103_Text_PetePostBattle"] = 0x2950AA, + ["Route103_Text_RhettDefeated"] = 0x2951C6, + ["Route103_Text_RhettIntro"] = 0x295199, + ["Route103_Text_RhettPostBattle"] = 0x2951DE, + ["Route104_PrettyPetalFlowerShop_Text_BerriesExplanation"] = 0x2A775B, + ["Route104_PrettyPetalFlowerShop_Text_FlowersBringHappiness"] = 0x2A78DF, + ["Route104_PrettyPetalFlowerShop_Text_ImGrowingFlowers"] = 0x2A7A98, + ["Route104_PrettyPetalFlowerShop_Text_IntroLearnAboutBerries"] = 0x2A7706, + ["Route104_PrettyPetalFlowerShop_Text_LearnAboutBerries"] = 0x2A76D9, + ["Route104_PrettyPetalFlowerShop_Text_MachineMixesBerries"] = 0x2A7AF3, + ["Route104_PrettyPetalFlowerShop_Text_ThisIsPrettyPetalFlowerShop"] = 0x2A7686, + ["Route104_PrettyPetalFlowerShop_Text_WailmerPailExplanation"] = 0x2A79A6, + ["Route104_PrettyPetalFlowerShop_Text_YouCanHaveThis"] = 0x2A7916, + ["Route104_Text_BillyDefeat"] = 0x2955B4, + ["Route104_Text_BillyIntro"] = 0x29558A, + ["Route104_Text_BillyPostBattle"] = 0x2955E8, + ["Route104_Text_CindyDefeat"] = 0x295ABB, + ["Route104_Text_CindyIntro"] = 0x295A7E, + ["Route104_Text_CindyPostBattle"] = 0x295AC3, + ["Route104_Text_CindyPostRematch"] = 0x295C1F, + ["Route104_Text_CindyRegister1"] = 0x295B01, + ["Route104_Text_CindyRegister2"] = 0x295B60, + ["Route104_Text_CindyRematchDefeat"] = 0x295BFA, + ["Route104_Text_CindyRematchIntro"] = 0x295BC8, + ["Route104_Text_DarianDefeat"] = 0x295CC9, + ["Route104_Text_DarianIntro"] = 0x295C5D, + ["Route104_Text_DarianPostBattle"] = 0x295CD3, + ["Route104_Text_GinaDefeat"] = 0x2952E6, + ["Route104_Text_GinaIntro"] = 0x2952BB, + ["Route104_Text_GinaNotEnoughMons"] = 0x295330, + ["Route104_Text_GinaPostBattle"] = 0x2952FE, + ["Route104_Text_HaleyDefeat"] = 0x29566F, + ["Route104_Text_HaleyIntro"] = 0x29563A, + ["Route104_Text_HaleyPostBattle"] = 0x295689, + ["Route104_Text_HaleyPostRematch"] = 0x29580C, + ["Route104_Text_HaleyRegister1"] = 0x2956FF, + ["Route104_Text_HaleyRegister2"] = 0x29576B, + ["Route104_Text_HaleyRematchDefeat"] = 0x2957F0, + ["Route104_Text_HaleyRematchIntro"] = 0x2957D7, + ["Route104_Text_IvanDefeat"] = 0x295509, + ["Route104_Text_IvanIntro"] = 0x2954BD, + ["Route104_Text_IvanPostBattle"] = 0x29554E, + ["Route104_Text_MiaDefeat"] = 0x2953E1, + ["Route104_Text_MiaIntro"] = 0x2953AF, + ["Route104_Text_MiaNotEnoughMons"] = 0x295449, + ["Route104_Text_MiaPostBattle"] = 0x29540D, + ["Route104_Text_PlantBerriesInSoilTakeThis"] = 0x2A6D86, + ["Route104_Text_TrainersOftenMakeMonHoldBerries"] = 0x2A6E32, + ["Route104_Text_WinstonDefeat"] = 0x2958AD, + ["Route104_Text_WinstonIntro"] = 0x295870, + ["Route104_Text_WinstonPostBattle"] = 0x2958C1, + ["Route104_Text_WinstonPostRematch"] = 0x295A1E, + ["Route104_Text_WinstonRegister1"] = 0x2958F8, + ["Route104_Text_WinstonRegister2"] = 0x29595A, + ["Route104_Text_WinstonRematchDefeat"] = 0x2959FC, + ["Route104_Text_WinstonRematchIntro"] = 0x2959BC, + ["Route105_Text_AndresDefeated"] = 0x2960DB, + ["Route105_Text_AndresIntro"] = 0x2960A9, + ["Route105_Text_AndresPostBattle"] = 0x2960FA, + ["Route105_Text_AndresRegister"] = 0x296159, + ["Route105_Text_AndresRematchDefeated"] = 0x2961DD, + ["Route105_Text_AndresRematchIntro"] = 0x29619E, + ["Route105_Text_AndresRematchPostBattle"] = 0x2961FE, + ["Route105_Text_BeverlyDefeated"] = 0x295FA0, + ["Route105_Text_BeverlyIntro"] = 0x295F5C, + ["Route105_Text_DominikDefeated"] = 0x295EEA, + ["Route105_Text_DominikIntro"] = 0x295EB7, + ["Route105_Text_DominikPostBattle"] = 0x295F08, + ["Route105_Text_FosterDefeated"] = 0x295D62, + ["Route105_Text_FosterIntro"] = 0x295D0F, + ["Route105_Text_FosterPostBattle"] = 0x295DAB, + ["Route105_Text_ImaniDefeated"] = 0x296059, + ["Route105_Text_ImaniIntro"] = 0x296025, + ["Route105_Text_ImaniPostBattle"] = 0x296076, + ["Route105_Text_JosueDefeated"] = 0x2962CB, + ["Route105_Text_JosueIntro"] = 0x29626F, + ["Route105_Text_JosuePostBattle"] = 0x2962EC, + ["Route105_Text_LuisDefeated"] = 0x295E66, + ["Route105_Text_LuisIntro"] = 0x295DF5, + ["Route105_Text_LuisPostBattle"] = 0x295E72, + ["Route105_Text_PostBattle"] = 0x295FAE, + ["Route106_Text_DouglasDefeated"] = 0x2965FD, + ["Route106_Text_DouglasIntro"] = 0x2965BB, + ["Route106_Text_DouglasPostBattle"] = 0x296608, + ["Route106_Text_ElliotDefeated"] = 0x29635C, + ["Route106_Text_ElliotIntro"] = 0x296327, + ["Route106_Text_ElliotPostBattle"] = 0x29638C, + ["Route106_Text_ElliotRegister"] = 0x2963E3, + ["Route106_Text_ElliotRematchDefeated"] = 0x296477, + ["Route106_Text_ElliotRematchIntro"] = 0x29642E, + ["Route106_Text_ElliotRematchPostBattle"] = 0x296493, + ["Route106_Text_KylaDefeated"] = 0x296674, + ["Route106_Text_KylaIntro"] = 0x296628, + ["Route106_Text_KylaPostBattle"] = 0x29669E, + ["Route106_Text_NedDefeated"] = 0x296553, + ["Route106_Text_NedIntro"] = 0x2964D4, + ["Route106_Text_NedPostBattle"] = 0x296588, + ["Route107_Text_BethDefeated"] = 0x296A17, + ["Route107_Text_BethIntro"] = 0x2969E4, + ["Route107_Text_BethPostBattle"] = 0x296A35, + ["Route107_Text_CamronDefeated"] = 0x296CAD, + ["Route107_Text_CamronIntro"] = 0x296C6F, + ["Route107_Text_CamronPostBattle"] = 0x296CC0, + ["Route107_Text_DarrinDefeated"] = 0x29672F, + ["Route107_Text_DarrinIntro"] = 0x2966E5, + ["Route107_Text_DarrinPostBattle"] = 0x29675B, + ["Route107_Text_DeniseDefeated"] = 0x29699E, + ["Route107_Text_DeniseIntro"] = 0x296974, + ["Route107_Text_DenisePostBattle"] = 0x2969AB, + ["Route107_Text_LisaDefeated"] = 0x296AA7, + ["Route107_Text_LisaIntro"] = 0x296A77, + ["Route107_Text_LisaNotEnoughPokemon"] = 0x296B1E, + ["Route107_Text_LisaPostBattle"] = 0x296ADC, + ["Route107_Text_RayDefeated"] = 0x296BB3, + ["Route107_Text_RayIntro"] = 0x296B57, + ["Route107_Text_RayNotEnoughPokemon"] = 0x296C34, + ["Route107_Text_RayPostBattle"] = 0x296BE1, + ["Route107_Text_TonyDefeated"] = 0x2967D8, + ["Route107_Text_TonyIntro"] = 0x2967AD, + ["Route107_Text_TonyPostBattle"] = 0x2967FF, + ["Route107_Text_TonyRegister"] = 0x29685A, + ["Route107_Text_TonyRematchDefeated"] = 0x2968D5, + ["Route107_Text_TonyRematchIntro"] = 0x296897, + ["Route107_Text_TonyRematchPostBattle"] = 0x296923, + ["Route108_Text_CarolinaDefeated"] = 0x2970D7, + ["Route108_Text_CarolinaIntro"] = 0x297094, + ["Route108_Text_CarolinaPostBattle"] = 0x2970F0, + ["Route108_Text_CoryDefeated"] = 0x296F93, + ["Route108_Text_CoryIntro"] = 0x296F5D, + ["Route108_Text_CoryPostBattle"] = 0x296FB0, + ["Route108_Text_CoryRegister"] = 0x296FD8, + ["Route108_Text_CoryRematchDefeated"] = 0x297036, + ["Route108_Text_CoryRematchIntro"] = 0x297011, + ["Route108_Text_CoryRematchPostBattle"] = 0x297059, + ["Route108_Text_JeromeDefeated"] = 0x296D3C, + ["Route108_Text_JeromeIntro"] = 0x296D10, + ["Route108_Text_JeromePostBattle"] = 0x296D6E, + ["Route108_Text_MatthewDefeated"] = 0x296DEA, + ["Route108_Text_MatthewIntro"] = 0x296DAF, + ["Route108_Text_MatthewPostBattle"] = 0x296E03, + ["Route108_Text_MissyDefeated"] = 0x296EFC, + ["Route108_Text_MissyIntro"] = 0x296EC7, + ["Route108_Text_MissyPostBattle"] = 0x296F2A, + ["Route108_Text_TaraDefeated"] = 0x296E71, + ["Route108_Text_TaraIntro"] = 0x296E33, + ["Route108_Text_TaraPostBattle"] = 0x296E7A, + ["Route109_Text_AliceDefeated"] = 0x2971FD, + ["Route109_Text_AliceIntro"] = 0x2971D1, + ["Route109_Text_AlicePostBattle"] = 0x29720F, + ["Route109_Text_AustinaDefeated"] = 0x297601, + ["Route109_Text_AustinaIntro"] = 0x2975C0, + ["Route109_Text_AustinaPostBattle"] = 0x29762A, + ["Route109_Text_CarterDefeated"] = 0x2976FC, + ["Route109_Text_CarterIntro"] = 0x2976C5, + ["Route109_Text_CarterPostBattle"] = 0x297715, + ["Route109_Text_ChandlerDefeated"] = 0x2979AE, + ["Route109_Text_ChandlerIntro"] = 0x29798A, + ["Route109_Text_ChandlerPostBattle"] = 0x2979BF, + ["Route109_Text_DavidDefeated"] = 0x29717D, + ["Route109_Text_DavidIntro"] = 0x297140, + ["Route109_Text_DavidPostBattle"] = 0x297191, + ["Route109_Text_EdmondDefeated"] = 0x2972CE, + ["Route109_Text_EdmondIntro"] = 0x2972B3, + ["Route109_Text_EdmondPostBattle"] = 0x2972ED, + ["Route109_Text_ElijahDefeated"] = 0x297AC7, + ["Route109_Text_ElijahIntro"] = 0x297A82, + ["Route109_Text_ElijahPostBattle"] = 0x297AE5, + ["Route109_Text_GwenDefeated"] = 0x297691, + ["Route109_Text_GwenIntro"] = 0x297667, + ["Route109_Text_GwenPostBattle"] = 0x2976A4, + ["Route109_Text_HaileyDefeated"] = 0x297A15, + ["Route109_Text_HaileyIntro"] = 0x2979EC, + ["Route109_Text_HaileyPostBattle"] = 0x297A40, + ["Route109_Text_HueyDefeated"] = 0x29727B, + ["Route109_Text_HueyIntro"] = 0x297235, + ["Route109_Text_HueyPostBattle"] = 0x29728C, + ["Route109_Text_LolaDefeated"] = 0x2974CF, + ["Route109_Text_LolaIntro"] = 0x29749C, + ["Route109_Text_LolaPostBattle"] = 0x2974D6, + ["Route109_Text_LolaRegister"] = 0x297520, + ["Route109_Text_LolaRematchDefeated"] = 0x297576, + ["Route109_Text_LolaRematchIntro"] = 0x297538, + ["Route109_Text_LolaRematchPostBattle"] = 0x29757D, + ["Route109_Text_MelDefeated"] = 0x2978BD, + ["Route109_Text_MelIntro"] = 0x297872, + ["Route109_Text_MelNotEnoughPokemon"] = 0x29792E, + ["Route109_Text_MelPostBattle"] = 0x2978F1, + ["Route109_Text_PaulDefeated"] = 0x2977B0, + ["Route109_Text_PaulIntro"] = 0x297754, + ["Route109_Text_PaulNotEnoughPokemon"] = 0x29781D, + ["Route109_Text_PaulPostBattle"] = 0x2977C7, + ["Route109_Text_RickyDefeated"] = 0x297379, + ["Route109_Text_RickyIntro"] = 0x29733B, + ["Route109_Text_RickyPostBattle"] = 0x297380, + ["Route109_Text_RickyRegister"] = 0x2973C1, + ["Route109_Text_RickyRematchDefeated"] = 0x297437, + ["Route109_Text_RickyRematchIntro"] = 0x2973FF, + ["Route109_Text_RickyRematchPostBattle"] = 0x297458, + ["Route110_Text_AbigailDefeated"] = 0x297E69, + ["Route110_Text_AbigailIntro"] = 0x297DFA, + ["Route110_Text_AbigailPostBattle"] = 0x297E88, + ["Route110_Text_AbigailRegister"] = 0x297ECD, + ["Route110_Text_AbigailRematchDefeated"] = 0x297F37, + ["Route110_Text_AbigailRematchIntro"] = 0x297F09, + ["Route110_Text_AbigailRematchPostBattle"] = 0x297F58, + ["Route110_Text_AlyssaDefeated"] = 0x2985F9, + ["Route110_Text_AlyssaIntro"] = 0x2985AB, + ["Route110_Text_AlyssaPostBattle"] = 0x298612, + ["Route110_Text_AnthonyDefeated"] = 0x297C0F, + ["Route110_Text_AnthonyIntro"] = 0x297BE7, + ["Route110_Text_AnthonyPostBattle"] = 0x297C1F, + ["Route110_Text_BenjaminDefeated"] = 0x297C8A, + ["Route110_Text_BenjaminIntro"] = 0x297C63, + ["Route110_Text_BenjaminPostBattle"] = 0x297CB4, + ["Route110_Text_BenjaminRegister"] = 0x297CFE, + ["Route110_Text_BenjaminRematchDefeated"] = 0x297D8E, + ["Route110_Text_BenjaminRematchIntro"] = 0x297D4B, + ["Route110_Text_BenjaminRematchPostBattle"] = 0x297DB0, + ["Route110_Text_DaleDefeated"] = 0x2982F5, + ["Route110_Text_DaleIntro"] = 0x2982CC, + ["Route110_Text_DalePostBattle"] = 0x298303, + ["Route110_Text_EdwardDefeated"] = 0x298064, + ["Route110_Text_EdwardIntro"] = 0x29802B, + ["Route110_Text_EdwardPostBattle"] = 0x29808A, + ["Route110_Text_EdwinDefeated"] = 0x29818F, + ["Route110_Text_EdwinIntro"] = 0x29815E, + ["Route110_Text_EdwinPostBattle"] = 0x2981B3, + ["Route110_Text_EdwinRegister"] = 0x298201, + ["Route110_Text_EdwinRematchDefeated"] = 0x298288, + ["Route110_Text_EdwinRematchIntro"] = 0x298232, + ["Route110_Text_EdwinRematchPostBattle"] = 0x2982A2, + ["Route110_Text_IsabelDefeated"] = 0x298389, + ["Route110_Text_IsabelIntro"] = 0x298349, + ["Route110_Text_IsabelPostBattle"] = 0x2983A2, + ["Route110_Text_IsabelRegister"] = 0x2983EE, + ["Route110_Text_IsabelRematchDefeated"] = 0x2984AF, + ["Route110_Text_IsabelRematchIntro"] = 0x298466, + ["Route110_Text_IsabelRematchPostBattle"] = 0x2984C8, + ["Route110_Text_JaclynDefeated"] = 0x2980E5, + ["Route110_Text_JaclynIntro"] = 0x2980B9, + ["Route110_Text_JaclynPostBattle"] = 0x2980F8, + ["Route110_Text_JacobDefeated"] = 0x297B77, + ["Route110_Text_JacobIntro"] = 0x297B3F, + ["Route110_Text_JacobPostBattle"] = 0x297B8F, + ["Route110_Text_JasmineDefeated"] = 0x297FD0, + ["Route110_Text_JasmineIntro"] = 0x297F93, + ["Route110_Text_JasminePostBattle"] = 0x297FF1, + ["Route110_Text_JosephDefeated"] = 0x298686, + ["Route110_Text_JosephIntro"] = 0x298642, + ["Route110_Text_JosephPostBattle"] = 0x2986A9, + ["Route110_Text_KalebDefeated"] = 0x298735, + ["Route110_Text_KalebIntro"] = 0x2986ED, + ["Route110_Text_KalebPostBattle"] = 0x298755, + ["Route110_Text_TimmyDefeated"] = 0x298559, + ["Route110_Text_TimmyIntro"] = 0x298525, + ["Route110_Text_TimmyPostBattle"] = 0x298579, + ["Route111_Text_BeauDefeat"] = 0x2989E6, + ["Route111_Text_BeauIntro"] = 0x29898E, + ["Route111_Text_BeauPostBattle"] = 0x298A03, + ["Route111_Text_BeckyDefeat"] = 0x298AAF, + ["Route111_Text_BeckyIntro"] = 0x298A65, + ["Route111_Text_BeckyPostBattle"] = 0x298AC0, + ["Route111_Text_BiancaDefeat"] = 0x29973D, + ["Route111_Text_BiancaIntro"] = 0x2996FE, + ["Route111_Text_BiancaPostBattle"] = 0x29975D, + ["Route111_Text_BrandenDefeat"] = 0x2994C5, + ["Route111_Text_BrandenIntro"] = 0x299493, + ["Route111_Text_BrandenPostBattle"] = 0x2994FB, + ["Route111_Text_BrookeDefeat"] = 0x299193, + ["Route111_Text_BrookeIntro"] = 0x299143, + ["Route111_Text_BrookePostBattle"] = 0x2991C2, + ["Route111_Text_BrookePostRematch"] = 0x2992F5, + ["Route111_Text_BrookeRegister"] = 0x29921D, + ["Route111_Text_BrookeRematchDefeat"] = 0x2992D6, + ["Route111_Text_BrookeRematchIntro"] = 0x29925D, + ["Route111_Text_BryanDefeat"] = 0x299431, + ["Route111_Text_BryanIntro"] = 0x299401, + ["Route111_Text_BryanPostBattle"] = 0x29945F, + ["Route111_Text_CeliaDefeat"] = 0x299384, + ["Route111_Text_CeliaIntro"] = 0x29934B, + ["Route111_Text_CeliaPostBattle"] = 0x2993A7, + ["Route111_Text_CelinaDefeat"] = 0x299625, + ["Route111_Text_CelinaIntro"] = 0x2995F0, + ["Route111_Text_CelinaPostBattle"] = 0x29964B, + ["Route111_Text_DaisukeDefeat"] = 0x298F14, + ["Route111_Text_DaisukeIntro"] = 0x298EE6, + ["Route111_Text_DaisukePostBattle"] = 0x298F26, + ["Route111_Text_DontHaveAnyRoom"] = 0x27659D, + ["Route111_Text_DontWantThis"] = 0x27655C, + ["Route111_Text_DrewDefeat"] = 0x29880A, + ["Route111_Text_DrewIntro"] = 0x29878E, + ["Route111_Text_DrewPostBattle"] = 0x298853, + ["Route111_Text_DustyDefeat"] = 0x298B53, + ["Route111_Text_DustyIntro"] = 0x298B08, + ["Route111_Text_DustyPostBattle"] = 0x298B9A, + ["Route111_Text_DustyPostRematch"] = 0x298D0C, + ["Route111_Text_DustyRegister"] = 0x298C00, + ["Route111_Text_DustyRematchDefeat"] = 0x298CD2, + ["Route111_Text_DustyRematchIntro"] = 0x298C5F, + ["Route111_Text_ExplainSecretPower"] = 0x27636E, + ["Route111_Text_GoingToTryToMakeDifferentColorBerries"] = 0x2A6F3D, + ["Route111_Text_HaydenDefeat"] = 0x2996BE, + ["Route111_Text_HaydenIntro"] = 0x299682, + ["Route111_Text_HaydenPostBattle"] = 0x2996C5, + ["Route111_Text_HeidiDefeat"] = 0x298908, + ["Route111_Text_HeidiIntro"] = 0x2988A3, + ["Route111_Text_HeidiPostBattle"] = 0x29891B, + ["Route111_Text_IreneDefeat"] = 0x298E6E, + ["Route111_Text_IreneIntro"] = 0x298E2F, + ["Route111_Text_IrenePostBattle"] = 0x298E8C, + ["Route111_Text_MakingRoomUseTMToMakeYourOwn"] = 0x2762C9, + ["Route111_Text_TravisDefeat"] = 0x298DD8, + ["Route111_Text_TravisIntro"] = 0x298DA9, + ["Route111_Text_TravisPostBattle"] = 0x298DF1, + ["Route111_Text_TyronDefeat"] = 0x299549, + ["Route111_Text_TyronIntro"] = 0x299524, + ["Route111_Text_TyronPostBattle"] = 0x299576, + ["Route111_Text_WateredPlantsEveryDayTakeBerry"] = 0x2A6EBD, + ["Route111_Text_WhatColorBerriesToLookForToday"] = 0x2A6F9A, + ["Route111_Text_WiltonDefeat"] = 0x298FA9, + ["Route111_Text_WiltonIntro"] = 0x298F79, + ["Route111_Text_WiltonPostBattle"] = 0x298FD4, + ["Route111_Text_WiltonPostRematch"] = 0x299102, + ["Route111_Text_WiltonRegister"] = 0x29903D, + ["Route111_Text_WiltonRematchDefeat"] = 0x2990EE, + ["Route111_Text_WiltonRematchIntro"] = 0x29909B, + ["Route112_Text_BriceDefeat"] = 0x2997CA, + ["Route112_Text_BriceIntro"] = 0x29978E, + ["Route112_Text_BricePostBattle"] = 0x2997DC, + ["Route112_Text_BryantDefeat"] = 0x299BDF, + ["Route112_Text_BryantIntro"] = 0x299BAE, + ["Route112_Text_BryantPostBattle"] = 0x299BFB, + ["Route112_Text_CarolDefeat"] = 0x299B36, + ["Route112_Text_CarolIntro"] = 0x299AE4, + ["Route112_Text_CarolPostBattle"] = 0x299B4C, + ["Route112_Text_LarryDefeat"] = 0x299AAC, + ["Route112_Text_LarryIntro"] = 0x299A89, + ["Route112_Text_LarryPostBattle"] = 0x299AB4, + ["Route112_Text_ShaylaDefeat"] = 0x299C8F, + ["Route112_Text_ShaylaIntro"] = 0x299C34, + ["Route112_Text_ShaylaPostBattle"] = 0x299CC6, + ["Route112_Text_TrentDefeat"] = 0x29987C, + ["Route112_Text_TrentIntro"] = 0x299815, + ["Route112_Text_TrentPostBattle"] = 0x299896, + ["Route112_Text_TrentRegister"] = 0x29993C, + ["Route112_Text_TrentRematchDefeat"] = 0x2999F7, + ["Route112_Text_TrentRematchIntro"] = 0x2999BD, + ["Route112_Text_TrentRematchPostBattle"] = 0x299A0F, + ["Route113_Text_CobyDefeat"] = 0x29A480, + ["Route113_Text_CobyIntro"] = 0x29A453, + ["Route113_Text_CobyPostBattle"] = 0x29A489, + ["Route113_Text_DillonDefeat"] = 0x299E1C, + ["Route113_Text_DillonIntro"] = 0x299DE3, + ["Route113_Text_DillonPostBattle"] = 0x299E38, + ["Route113_Text_JaylenDefeat"] = 0x299D68, + ["Route113_Text_JaylenIntro"] = 0x299D3C, + ["Route113_Text_JaylenPostBattle"] = 0x299D7D, + ["Route113_Text_LaoDefeat"] = 0x29A04C, + ["Route113_Text_LaoIntro"] = 0x29A015, + ["Route113_Text_LaoPostBattle"] = 0x29A067, + ["Route113_Text_LaoPostRematch"] = 0x29A13A, + ["Route113_Text_LaoRegister"] = 0x29A0A2, + ["Route113_Text_LaoRematchDefeat"] = 0x29A11F, + ["Route113_Text_LaoRematchIntro"] = 0x29A0E8, + ["Route113_Text_LawrenceDefeat"] = 0x29A5A0, + ["Route113_Text_LawrenceIntro"] = 0x29A566, + ["Route113_Text_LawrencePostBattle"] = 0x29A5BE, + ["Route113_Text_LungDefeat"] = 0x29A1C6, + ["Route113_Text_LungIntro"] = 0x29A192, + ["Route113_Text_LungPostBattle"] = 0x29A219, + ["Route113_Text_MadelineDefeat"] = 0x299EF9, + ["Route113_Text_MadelineIntro"] = 0x299EA6, + ["Route113_Text_MadelinePostBattle"] = 0x299F15, + ["Route113_Text_MadelinePostRematch"] = 0x299FD8, + ["Route113_Text_MadelineRegister"] = 0x299F49, + ["Route113_Text_MadelineRematchDefeat"] = 0x299FC9, + ["Route113_Text_MadelineRematchIntro"] = 0x299F8A, + ["Route113_Text_SophieDefeat"] = 0x29A508, + ["Route113_Text_SophieIntro"] = 0x29A4BF, + ["Route113_Text_SophiePostBattle"] = 0x29A529, + ["Route113_Text_TiaDefeat"] = 0x29A397, + ["Route113_Text_TiaIntro"] = 0x29A35C, + ["Route113_Text_TiaNotEnoughMons"] = 0x29A419, + ["Route113_Text_TiaPostBattle"] = 0x29A3DA, + ["Route113_Text_ToriDefeat"] = 0x29A29D, + ["Route113_Text_ToriIntro"] = 0x29A261, + ["Route113_Text_ToriNotEnoughMons"] = 0x29A31F, + ["Route113_Text_ToriPostBattle"] = 0x29A2DE, + ["Route113_Text_WyattDefeat"] = 0x29A628, + ["Route113_Text_WyattIntro"] = 0x29A5E4, + ["Route113_Text_WyattPostBattle"] = 0x29A65D, + ["Route114_Text_AngelinaDefeat"] = 0x29B19C, + ["Route114_Text_AngelinaIntro"] = 0x29B16F, + ["Route114_Text_AngelinaPostBattle"] = 0x29B1BC, + ["Route114_Text_BernieDefeat"] = 0x29AB1A, + ["Route114_Text_BernieIntro"] = 0x29AADB, + ["Route114_Text_BerniePostBattle"] = 0x29AB36, + ["Route114_Text_BerniePostRematch"] = 0x29AC43, + ["Route114_Text_BernieRegister"] = 0x29ABA6, + ["Route114_Text_BernieRematchDefeat"] = 0x29AC10, + ["Route114_Text_BernieRematchIntro"] = 0x29ABDC, + ["Route114_Text_CharlotteDefeat"] = 0x29B0FB, + ["Route114_Text_CharlotteIntro"] = 0x29B0DB, + ["Route114_Text_CharlottePostBattle"] = 0x29B11A, + ["Route114_Text_ClaudeDefeat"] = 0x29AD0A, + ["Route114_Text_ClaudeIntro"] = 0x29ACB3, + ["Route114_Text_ClaudePostBattle"] = 0x29AD2E, + ["Route114_Text_FunToThinkAboutBerries"] = 0x2A706E, + ["Route114_Text_IvyDefeat"] = 0x29AFAC, + ["Route114_Text_IvyIntro"] = 0x29AF89, + ["Route114_Text_IvyNotEnoughMons"] = 0x29B01B, + ["Route114_Text_IvyPostBattle"] = 0x29AFCF, + ["Route114_Text_KaiDefeat"] = 0x29B089, + ["Route114_Text_KaiIntro"] = 0x29B05D, + ["Route114_Text_KaiPostBattle"] = 0x29B0B5, + ["Route114_Text_LennyDefeat"] = 0x29A707, + ["Route114_Text_LennyIntro"] = 0x29A6B1, + ["Route114_Text_LennyPostBattle"] = 0x29A715, + ["Route114_Text_LoveUsingBerryCrushShareBerry"] = 0x2A6FCB, + ["Route114_Text_LucasDefeat"] = 0x29A7B5, + ["Route114_Text_LucasIntro"] = 0x29A777, + ["Route114_Text_LucasPostBattle"] = 0x29A7D4, + ["Route114_Text_NancyDefeat"] = 0x29A90E, + ["Route114_Text_NancyIntro"] = 0x29A8D9, + ["Route114_Text_NancyPostBattle"] = 0x29A916, + ["Route114_Text_NolanDefeat"] = 0x29AE05, + ["Route114_Text_NolanIntro"] = 0x29ADA1, + ["Route114_Text_NolanPostBattle"] = 0x29AE3D, + ["Route114_Text_ShaneDefeat"] = 0x29A88F, + ["Route114_Text_ShaneIntro"] = 0x29A818, + ["Route114_Text_ShanePostBattle"] = 0x29A89F, + ["Route114_Text_SteveDefeat"] = 0x29A973, + ["Route114_Text_SteveIntro"] = 0x29A943, + ["Route114_Text_StevePostBattle"] = 0x29A981, + ["Route114_Text_StevePostRematch"] = 0x29AA94, + ["Route114_Text_SteveRegister"] = 0x29A9F7, + ["Route114_Text_SteveRematchDefeat"] = 0x29AA67, + ["Route114_Text_SteveRematchIntro"] = 0x29AA3E, + ["Route114_Text_TryBerryCrushWithFriends"] = 0x2A7034, + ["Route114_Text_TyraDefeat"] = 0x29AEE7, + ["Route114_Text_TyraIntro"] = 0x29AE96, + ["Route114_Text_TyraNotEnoughMons"] = 0x29AF3D, + ["Route114_Text_TyraPostBattle"] = 0x29AF0B, + ["Route115_Text_AlixDefeat"] = 0x29B9F6, + ["Route115_Text_AlixIntro"] = 0x29B9CB, + ["Route115_Text_AlixPostBattle"] = 0x29BA04, + ["Route115_Text_CyndyDefeat"] = 0x29B62D, + ["Route115_Text_CyndyIntro"] = 0x29B5EF, + ["Route115_Text_CyndyPostBattle"] = 0x29B647, + ["Route115_Text_CyndyPostRematch"] = 0x29B737, + ["Route115_Text_CyndyRegister"] = 0x29B6AB, + ["Route115_Text_CyndyRematchDefeat"] = 0x29B71A, + ["Route115_Text_CyndyRematchIntro"] = 0x29B6FA, + ["Route115_Text_HectorDefeat"] = 0x29B7C5, + ["Route115_Text_HectorIntro"] = 0x29B78F, + ["Route115_Text_HectorPostBattle"] = 0x29B7EA, + ["Route115_Text_HeleneDefeat"] = 0x29B958, + ["Route115_Text_HeleneIntro"] = 0x29B92D, + ["Route115_Text_HelenePostBattle"] = 0x29B971, + ["Route115_Text_JaidenDefeat"] = 0x29B8E8, + ["Route115_Text_JaidenIntro"] = 0x29B8C1, + ["Route115_Text_JaidenPostBattle"] = 0x29B904, + ["Route115_Text_KoichiDefeat"] = 0x29B3ED, + ["Route115_Text_KoichiIntro"] = 0x29B3CC, + ["Route115_Text_KoichiPostBattle"] = 0x29B3FF, + ["Route115_Text_KyraDefeat"] = 0x29B85D, + ["Route115_Text_KyraIntro"] = 0x29B826, + ["Route115_Text_KyraPostBattle"] = 0x29B869, + ["Route115_Text_MarleneDefeat"] = 0x29BA67, + ["Route115_Text_MarleneIntro"] = 0x29BA2C, + ["Route115_Text_MarlenePostBattle"] = 0x29BA87, + ["Route115_Text_NobDefeat"] = 0x29B480, + ["Route115_Text_NobIntro"] = 0x29B449, + ["Route115_Text_NobPostBattle"] = 0x29B49D, + ["Route115_Text_NobPostRematch"] = 0x29B5B5, + ["Route115_Text_NobRegister"] = 0x29B50B, + ["Route115_Text_NobRematchDefeat"] = 0x29B59D, + ["Route115_Text_NobRematchIntro"] = 0x29B547, + ["Route115_Text_TimothyDefeat"] = 0x29B230, + ["Route115_Text_TimothyIntro"] = 0x29B1FA, + ["Route115_Text_TimothyPostBattle"] = 0x29B258, + ["Route115_Text_TimothyPostRematch"] = 0x29B385, + ["Route115_Text_TimothyRegister"] = 0x29B2B3, + ["Route115_Text_TimothyRematchDefeat"] = 0x29B372, + ["Route115_Text_TimothyRematchIntro"] = 0x29B32C, + ["Route116_Text_ClarkDefeat"] = 0x29BB0F, + ["Route116_Text_ClarkIntro"] = 0x29BACD, + ["Route116_Text_ClarkPostBattle"] = 0x29BB31, + ["Route116_Text_DawsonDefeat"] = 0x29C2C2, + ["Route116_Text_DawsonIntro"] = 0x29C266, + ["Route116_Text_DawsonPostBattle"] = 0x29C2DD, + ["Route116_Text_DevanDefeat"] = 0x29C365, + ["Route116_Text_DevanIntro"] = 0x29C350, + ["Route116_Text_DevanPostBattle"] = 0x29C380, + ["Route116_Text_JaniceDefeat"] = 0x29BCB3, + ["Route116_Text_JaniceIntro"] = 0x29BC7F, + ["Route116_Text_JanicePostBattle"] = 0x29BCCC, + ["Route116_Text_JerryDefeat"] = 0x29BD66, + ["Route116_Text_JerryIntro"] = 0x29BD0C, + ["Route116_Text_JerryPostBattle"] = 0x29BD92, + ["Route116_Text_JerryPostRematch"] = 0x29BF68, + ["Route116_Text_JerryRegister1"] = 0x29BDEF, + ["Route116_Text_JerryRegister2"] = 0x29BE71, + ["Route116_Text_JerryRematchDefeat"] = 0x29BF4C, + ["Route116_Text_JerryRematchIntro"] = 0x29BEF3, + ["Route116_Text_JoeyDefeat"] = 0x29BB9A, + ["Route116_Text_JoeyIntro"] = 0x29BB79, + ["Route116_Text_JoeyPostBattle"] = 0x29BBC6, + ["Route116_Text_JohnsonDefeat"] = 0x29C3F7, + ["Route116_Text_JohnsonIntro"] = 0x29C3C1, + ["Route116_Text_JohnsonPostBattle"] = 0x29C418, + ["Route116_Text_JoseDefeat"] = 0x29BC25, + ["Route116_Text_JoseIntro"] = 0x29BBFD, + ["Route116_Text_JosePostBattle"] = 0x29BC42, + ["Route116_Text_KarenDefeat"] = 0x29BFFA, + ["Route116_Text_KarenIntro"] = 0x29BFC5, + ["Route116_Text_KarenPostBattle"] = 0x29C010, + ["Route116_Text_KarenPostRematch"] = 0x29C13B, + ["Route116_Text_KarenRegister1"] = 0x29C052, + ["Route116_Text_KarenRegister2"] = 0x29C096, + ["Route116_Text_KarenRematchDefeat"] = 0x29C11F, + ["Route116_Text_KarenRematchIntro"] = 0x29C0DA, + ["Route116_Text_SarahDefeat"] = 0x29C1B8, + ["Route116_Text_SarahIntro"] = 0x29C173, + ["Route116_Text_SarahPostBattle"] = 0x29C1EA, + ["Route117_PokemonDayCare_Text_ComeAgain"] = 0x292476, + ["Route117_PokemonDayCare_Text_FineThenComeAgain"] = 0x292416, + ["Route117_PokemonDayCare_Text_GoodToSeeYou"] = 0x292488, + ["Route117_PokemonDayCare_Text_HeresYourMon"] = 0x292575, + ["Route117_PokemonDayCare_Text_HusbandWasLookingForYou"] = 0x2923F6, + ["Route117_PokemonDayCare_Text_ItWillCostX"] = 0x292549, + ["Route117_PokemonDayCare_Text_NotEnoughMoney"] = 0x292432, + ["Route117_PokemonDayCare_Text_TakeBackWhichMon"] = 0x292527, + ["Route117_PokemonDayCare_Text_TakeOtherOneBackToo"] = 0x29244F, + ["Route117_PokemonDayCare_Text_TakeYourMonBack"] = 0x2925F6, + ["Route117_PokemonDayCare_Text_TookBackMon"] = 0x292593, + ["Route117_PokemonDayCare_Text_WeCanRaiseOneMore"] = 0x2923AF, + ["Route117_PokemonDayCare_Text_WellRaiseYourMon"] = 0x292370, + ["Route117_PokemonDayCare_Text_WhatWillYouBattleWith"] = 0x292617, + ["Route117_PokemonDayCare_Text_WhichMonShouldWeRaise"] = 0x292349, + ["Route117_PokemonDayCare_Text_WouldYouLikeUsToRaiseAMon"] = 0x2922F4, + ["Route117_PokemonDayCare_Text_YouHaveJustOneMon"] = 0x2925BB, + ["Route117_PokemonDayCare_Text_YoullBeLeftWithJustOne"] = 0x29266D, + ["Route117_PokemonDayCare_Text_YourMonHasGrownXLevels"] = 0x2924CC, + ["Route117_PokemonDayCare_Text_YourTeamIsFull"] = 0x2924EF, + ["Route117_Text_AishaDefeat"] = 0x29D206, + ["Route117_Text_AishaIntro"] = 0x29D1D1, + ["Route117_Text_AishaPostBattle"] = 0x29D24C, + ["Route117_Text_AnnaAndMegRegister"] = 0x29CD1D, + ["Route117_Text_AnnaDefeat"] = 0x29CC8E, + ["Route117_Text_AnnaIntro"] = 0x29CC4A, + ["Route117_Text_AnnaNotEnoughMons"] = 0x29CD61, + ["Route117_Text_AnnaPostBattle"] = 0x29CCCB, + ["Route117_Text_AnnaPostRematch"] = 0x29CF1C, + ["Route117_Text_AnnaRematchDefeat"] = 0x29CEF6, + ["Route117_Text_AnnaRematchIntro"] = 0x29CEB6, + ["Route117_Text_AnnaRematchNotEnoughMons"] = 0x29CF6E, + ["Route117_Text_BrandiDefeat"] = 0x29D189, + ["Route117_Text_BrandiIntro"] = 0x29D14B, + ["Route117_Text_BrandiPostBattle"] = 0x29D196, + ["Route117_Text_DerekDefeat"] = 0x29CB87, + ["Route117_Text_DerekIntro"] = 0x29CB32, + ["Route117_Text_DerekPostBattle"] = 0x29CBAD, + ["Route117_Text_DoYouWantEgg"] = 0x29205D, + ["Route117_Text_DylanDefeat"] = 0x29C7EB, + ["Route117_Text_DylanIntro"] = 0x29C7A5, + ["Route117_Text_DylanPostBattle"] = 0x29C800, + ["Route117_Text_DylanPostRematch"] = 0x29C8FE, + ["Route117_Text_DylanRegister"] = 0x29C846, + ["Route117_Text_DylanRematchDefeat"] = 0x29C8E3, + ["Route117_Text_DylanRematchIntro"] = 0x29C880, + ["Route117_Text_FriendlyWithOtherTrainersMon"] = 0x2921E5, + ["Route117_Text_IWillKeepDoYouWantIt"] = 0x2922C6, + ["Route117_Text_IllKeepIt"] = 0x292149, + ["Route117_Text_IsaacDefeat"] = 0x29C47A, + ["Route117_Text_IsaacIntro"] = 0x29C43F, + ["Route117_Text_IsaacPostBattle"] = 0x29C498, + ["Route117_Text_IsaacPostRematch"] = 0x29C5D0, + ["Route117_Text_IsaacRegister"] = 0x29C508, + ["Route117_Text_IsaacRematchDefeat"] = 0x29C58B, + ["Route117_Text_IsaacRematchIntro"] = 0x29C549, + ["Route117_Text_LydiaDefeat"] = 0x29C659, + ["Route117_Text_LydiaIntro"] = 0x29C612, + ["Route117_Text_LydiaPostBattle"] = 0x29C679, + ["Route117_Text_LydiaPostRematch"] = 0x29C75A, + ["Route117_Text_LydiaRegister"] = 0x29C6BC, + ["Route117_Text_LydiaRematchDefeat"] = 0x29C73E, + ["Route117_Text_LydiaRematchIntro"] = 0x29C6FD, + ["Route117_Text_MariaDefeat"] = 0x29C9A6, + ["Route117_Text_MariaIntro"] = 0x29C955, + ["Route117_Text_MariaPostBattle"] = 0x29C9D0, + ["Route117_Text_MariaPostRematch"] = 0x29CAF6, + ["Route117_Text_MariaRegister"] = 0x29CA32, + ["Route117_Text_MariaRematchDefeat"] = 0x29CACC, + ["Route117_Text_MariaRematchIntro"] = 0x29CA7C, + ["Route117_Text_MegDefeat"] = 0x29CDE9, + ["Route117_Text_MegIntro"] = 0x29CD9D, + ["Route117_Text_MegNotEnoughMons"] = 0x29CE52, + ["Route117_Text_MegPostBattle"] = 0x29CE17, + ["Route117_Text_MegPostRematch"] = 0x29CFFE, + ["Route117_Text_MegRematchDefeat"] = 0x29CFED, + ["Route117_Text_MegRematchIntro"] = 0x29CFAA, + ["Route117_Text_MegRematchNotEnoughMons"] = 0x29D053, + ["Route117_Text_MelinaDefeat"] = 0x29D0F1, + ["Route117_Text_MelinaIntro"] = 0x29D0B7, + ["Route117_Text_MelinaPostBattle"] = 0x29D10E, + ["Route117_Text_ReceivedEgg"] = 0x2921A4, + ["Route117_Text_SeeWifeIfYouWantToPickUpMon"] = 0x29225A, + ["Route117_Text_SeeWifeIfYoudLikeMeToRaiseMon"] = 0x291FCF, + ["Route117_Text_TakeGoodCareOfIt"] = 0x2921CF, + ["Route117_Text_YouHaveNoRoomForIt"] = 0x29216A, + ["Route117_Text_YourMonIsDoingFine"] = 0x292114, + ["Route117_Text_YourMonsAreDoingFine"] = 0x292299, + ["Route118_Text_BarnyDefeat"] = 0x29D55C, + ["Route118_Text_BarnyIntro"] = 0x29D515, + ["Route118_Text_BarnyPostBattle"] = 0x29D587, + ["Route118_Text_ChesterDefeat"] = 0x29D4C4, + ["Route118_Text_ChesterIntro"] = 0x29D4A6, + ["Route118_Text_ChesterPostBattle"] = 0x29D4DA, + ["Route118_Text_DaltonDefeat"] = 0x29D6CD, + ["Route118_Text_DaltonIntro"] = 0x29D6AF, + ["Route118_Text_DaltonPostBattle"] = 0x29D6D8, + ["Route118_Text_DaltonPostRematch"] = 0x29D7D5, + ["Route118_Text_DaltonRegister"] = 0x29D74A, + ["Route118_Text_DaltonRematchDefeat"] = 0x29D7CA, + ["Route118_Text_DaltonRematchIntro"] = 0x29D789, + ["Route118_Text_DeandreDefeat"] = 0x29D83C, + ["Route118_Text_DeandreIntro"] = 0x29D81B, + ["Route118_Text_DeandrePostBattle"] = 0x29D872, + ["Route118_Text_PerryDefeat"] = 0x29D45B, + ["Route118_Text_PerryIntro"] = 0x29D41D, + ["Route118_Text_PerryPostBattle"] = 0x29D46C, + ["Route118_Text_RoseDefeat"] = 0x29D2D8, + ["Route118_Text_RoseIntro"] = 0x29D290, + ["Route118_Text_RosePostBattle"] = 0x29D2FA, + ["Route118_Text_RosePostRematch"] = 0x29D3D9, + ["Route118_Text_RoseRegister"] = 0x29D343, + ["Route118_Text_RoseRematchDefeat"] = 0x29D3AA, + ["Route118_Text_RoseRematchIntro"] = 0x29D382, + ["Route118_Text_WadeDefeat"] = 0x29D64C, + ["Route118_Text_WadeIntro"] = 0x29D5DF, + ["Route118_Text_WadePostBattle"] = 0x29D663, + ["Route119_Text_BrentDefeat"] = 0x29D8F0, + ["Route119_Text_BrentIntro"] = 0x29D8C2, + ["Route119_Text_BrentPostBattle"] = 0x29D902, + ["Route119_Text_CatherineDefeat"] = 0x29DE5D, + ["Route119_Text_CatherineIntro"] = 0x29DE0F, + ["Route119_Text_CatherinePostBattle"] = 0x29DE88, + ["Route119_Text_CatherinePostRematch"] = 0x29DFAF, + ["Route119_Text_CatherineRegister"] = 0x29DEF7, + ["Route119_Text_CatherineRematchDefeat"] = 0x29DF92, + ["Route119_Text_CatherineRematchIntro"] = 0x29DF6B, + ["Route119_Text_ChrisDefeat"] = 0x29E372, + ["Route119_Text_ChrisIntro"] = 0x29E30D, + ["Route119_Text_ChrisPostBattle"] = 0x29E3A2, + ["Route119_Text_DaytonDefeat"] = 0x29E513, + ["Route119_Text_DaytonIntro"] = 0x29E4DF, + ["Route119_Text_DaytonPostBattle"] = 0x29E532, + ["Route119_Text_DonaldDefeat"] = 0x29D97C, + ["Route119_Text_DonaldIntro"] = 0x29D941, + ["Route119_Text_DonaldPostBattle"] = 0x29D993, + ["Route119_Text_DougDefeat"] = 0x29DABC, + ["Route119_Text_DougIntro"] = 0x29DA7D, + ["Route119_Text_DougPostBattle"] = 0x29DADB, + ["Route119_Text_FabianDefeat"] = 0x29E44F, + ["Route119_Text_FabianIntro"] = 0x29E3FF, + ["Route119_Text_FabianPostBattle"] = 0x29E492, + ["Route119_Text_GregDefeat"] = 0x29DB66, + ["Route119_Text_GregIntro"] = 0x29DB17, + ["Route119_Text_GregPostBattle"] = 0x29DB7C, + ["Route119_Text_HideoDefeat"] = 0x29E288, + ["Route119_Text_HideoIntro"] = 0x29E26A, + ["Route119_Text_HideoPostBattle"] = 0x29E2A3, + ["Route119_Text_HughDefeat"] = 0x29E063, + ["Route119_Text_HughIntro"] = 0x29E007, + ["Route119_Text_HughPostBattle"] = 0x29E071, + ["Route119_Text_JacksonDefeat"] = 0x29DC9F, + ["Route119_Text_JacksonIntro"] = 0x29DC4C, + ["Route119_Text_JacksonPostBattle"] = 0x29DCC6, + ["Route119_Text_JacksonPostRematch"] = 0x29DDBD, + ["Route119_Text_JacksonRegister"] = 0x29DD1C, + ["Route119_Text_JacksonRematchDefeat"] = 0x29DDA5, + ["Route119_Text_JacksonRematchIntro"] = 0x29DD62, + ["Route119_Text_KentDefeat"] = 0x29DC20, + ["Route119_Text_KentIntro"] = 0x29DBC2, + ["Route119_Text_KentPostBattle"] = 0x29DC2B, + ["Route119_Text_PhilDefeat"] = 0x29E0E1, + ["Route119_Text_PhilIntro"] = 0x29E0A5, + ["Route119_Text_PhilPostBattle"] = 0x29E0F6, + ["Route119_Text_RachelDefeat"] = 0x29E5B1, + ["Route119_Text_RachelIntro"] = 0x29E56F, + ["Route119_Text_RachelPostBattle"] = 0x29E5CB, + ["Route119_Text_TakashiDefeat"] = 0x29E22B, + ["Route119_Text_TakashiIntro"] = 0x29E1F5, + ["Route119_Text_TakashiPostBattle"] = 0x29E245, + ["Route119_Text_TaylorDefeat"] = 0x29DA14, + ["Route119_Text_TaylorIntro"] = 0x29D9CD, + ["Route119_Text_TaylorPostBattle"] = 0x29DA2C, + ["Route119_Text_YasuDefeat"] = 0x29E18D, + ["Route119_Text_YasuIntro"] = 0x29E134, + ["Route119_Text_YasuPostBattle"] = 0x29E19D, + ["Route120_Text_AngelicaDefeat"] = 0x29EDD3, + ["Route120_Text_AngelicaIntro"] = 0x29ED71, + ["Route120_Text_AngelicaPostBattle"] = 0x29EDF7, + ["Route120_Text_BerriesExpressionOfLoveIsntIt"] = 0x2A70C7, + ["Route120_Text_BerryIsRareRaiseItWithCare"] = 0x2A71D5, + ["Route120_Text_CallieDefeat"] = 0x29EFE1, + ["Route120_Text_CallieIntro"] = 0x29EFB1, + ["Route120_Text_CalliePostBattle"] = 0x29F002, + ["Route120_Text_ChipDefeat"] = 0x29EC25, + ["Route120_Text_ChipIntro"] = 0x29EBAF, + ["Route120_Text_ChipPostBattle"] = 0x29EC41, + ["Route120_Text_ClarissaDefeat"] = 0x29ECEF, + ["Route120_Text_ClarissaIntro"] = 0x29ECA6, + ["Route120_Text_ClarissaPostBattle"] = 0x29ED19, + ["Route120_Text_ColinDefeat"] = 0x29E665, + ["Route120_Text_ColinIntro"] = 0x29E62D, + ["Route120_Text_ColinPostBattle"] = 0x29E67A, + ["Route120_Text_IllGetMoreBerriesFromBerryMaster"] = 0x2A7217, + ["Route120_Text_JeffreyDefeat"] = 0x29E9FF, + ["Route120_Text_JeffreyIntro"] = 0x29E9D7, + ["Route120_Text_JeffreyPostBattle"] = 0x29EA08, + ["Route120_Text_JeffreyPostRematch"] = 0x29EAA9, + ["Route120_Text_JeffreyRegister"] = 0x29EA31, + ["Route120_Text_JeffreyRematchDefeat"] = 0x29EA8F, + ["Route120_Text_JeffreyRematchIntro"] = 0x29EA61, + ["Route120_Text_JennaDefeat"] = 0x29E980, + ["Route120_Text_JennaIntro"] = 0x29E91C, + ["Route120_Text_JennaPostBattle"] = 0x29E996, + ["Route120_Text_JenniferDefeat"] = 0x29EB53, + ["Route120_Text_JenniferIntro"] = 0x29EAEF, + ["Route120_Text_JenniferPostBattle"] = 0x29EB6E, + ["Route120_Text_KeigoDefeat"] = 0x29EE97, + ["Route120_Text_KeigoIntro"] = 0x29EE50, + ["Route120_Text_KeigoPostBattle"] = 0x29EED4, + ["Route120_Text_LeonelDefeat"] = 0x29F080, + ["Route120_Text_LeonelIntro"] = 0x29F04F, + ["Route120_Text_LeonelPostBattle"] = 0x29F0A1, + ["Route120_Text_LorenzoDefeat"] = 0x29E884, + ["Route120_Text_LorenzoIntro"] = 0x29E843, + ["Route120_Text_LorenzoPostBattle"] = 0x29E8C5, + ["Route120_Text_MakeYourOwnImpressions"] = 0x2A7183, + ["Route120_Text_RileyDefeat"] = 0x29EF63, + ["Route120_Text_RileyIntro"] = 0x29EF08, + ["Route120_Text_RileyPostBattle"] = 0x29EF89, + ["Route120_Text_RobertDefeat"] = 0x29E70A, + ["Route120_Text_RobertIntro"] = 0x29E6E3, + ["Route120_Text_RobertPostBattle"] = 0x29E726, + ["Route120_Text_RobertPostRematch"] = 0x29E804, + ["Route120_Text_RobertRegister"] = 0x29E75D, + ["Route120_Text_RobertRematchDefeat"] = 0x29E7E1, + ["Route120_Text_RobertRematchIntro"] = 0x29E7AA, + ["Route120_Text_YesYouUnderstand"] = 0x2A7147, + ["Route121_SafariZoneEntrance_Text_ComeInAndEnjoy"] = 0x2A4E7E, + ["Route121_SafariZoneEntrance_Text_FirstTimeInfo"] = 0x2A4EA1, + ["Route121_SafariZoneEntrance_Text_HereAreYourSafariBalls"] = 0x2A5036, + ["Route121_SafariZoneEntrance_Text_NotEnoughMoney"] = 0x2A4FF7, + ["Route121_SafariZoneEntrance_Text_PCIsFull"] = 0x2A50E5, + ["Route121_SafariZoneEntrance_Text_PlayAnotherTime"] = 0x2A4FD7, + ["Route121_SafariZoneEntrance_Text_PleaseEnjoyYourself"] = 0x2A506F, + ["Route121_SafariZoneEntrance_Text_Received30SafariBalls"] = 0x2A5052, + ["Route121_SafariZoneEntrance_Text_ThatWillBe500Please"] = 0x2A501B, + ["Route121_SafariZoneEntrance_Text_TrainerTip"] = 0x2A55BB, + ["Route121_SafariZoneEntrance_Text_WelcomeFirstTime"] = 0x2A4E46, + ["Route121_SafariZoneEntrance_Text_WelcomeToSafariZone"] = 0x2A4D12, + ["Route121_SafariZoneEntrance_Text_WouldYouLikeToPlay"] = 0x2A4F74, + ["Route121_SafariZoneEntrance_Text_YouNeedPokeblockCase"] = 0x2A5105, + ["Route121_Text_CaleDefeat"] = 0x29F97B, + ["Route121_Text_CaleIntro"] = 0x29F91A, + ["Route121_Text_CalePostBattle"] = 0x29F9AE, + ["Route121_Text_CristinDefeat"] = 0x29F7C1, + ["Route121_Text_CristinIntro"] = 0x29F770, + ["Route121_Text_CristinPostBattle"] = 0x29F7D4, + ["Route121_Text_CristinPostRematch"] = 0x29F8D0, + ["Route121_Text_CristinRegister"] = 0x29F80D, + ["Route121_Text_CristinRematchDefeat"] = 0x29F8A8, + ["Route121_Text_CristinRematchIntro"] = 0x29F855, + ["Route121_Text_JessicaDefeat"] = 0x29F641, + ["Route121_Text_JessicaIntro"] = 0x29F612, + ["Route121_Text_JessicaPostBattle"] = 0x29F66F, + ["Route121_Text_JessicaPostRematch"] = 0x29F740, + ["Route121_Text_JessicaRegister"] = 0x29F69F, + ["Route121_Text_JessicaRematchDefeat"] = 0x29F710, + ["Route121_Text_JessicaRematchIntro"] = 0x29F6E4, + ["Route121_Text_JoyDefeat"] = 0x29F564, + ["Route121_Text_JoyIntro"] = 0x29F521, + ["Route121_Text_JoyNotEnoughMons"] = 0x29F5CE, + ["Route121_Text_JoyPostBattle"] = 0x29F582, + ["Route121_Text_KateDefeat"] = 0x29F43F, + ["Route121_Text_KateIntro"] = 0x29F3FB, + ["Route121_Text_KateNotEnoughMons"] = 0x29F4C9, + ["Route121_Text_KatePostBattle"] = 0x29F476, + ["Route121_Text_MarcelDefeat"] = 0x29FB8C, + ["Route121_Text_MarcelIntro"] = 0x29FB35, + ["Route121_Text_MarcelPostBattle"] = 0x29FBA8, + ["Route121_Text_MylesDefeat"] = 0x29FA64, + ["Route121_Text_MylesIntro"] = 0x29FA1E, + ["Route121_Text_MylesPostBattle"] = 0x29FA73, + ["Route121_Text_PatDefeat"] = 0x29FAD8, + ["Route121_Text_PatIntro"] = 0x29FAA7, + ["Route121_Text_PatPostBattle"] = 0x29FAEA, + ["Route121_Text_TammyDefeat"] = 0x29F3B9, + ["Route121_Text_TammyIntro"] = 0x29F381, + ["Route121_Text_TammyPostBattle"] = 0x29F3C6, + ["Route121_Text_VanessaDefeat"] = 0x29F11F, + ["Route121_Text_VanessaIntro"] = 0x29F0EC, + ["Route121_Text_VanessaPostBattle"] = 0x29F138, + ["Route121_Text_WalterDefeat"] = 0x29F20D, + ["Route121_Text_WalterIntro"] = 0x29F199, + ["Route121_Text_WalterPostBattle"] = 0x29F21E, + ["Route121_Text_WalterPostRematch"] = 0x29F32B, + ["Route121_Text_WalterRegister"] = 0x29F25B, + ["Route121_Text_WalterRematchDefeat"] = 0x29F31A, + ["Route121_Text_WalterRematchIntro"] = 0x29F2A6, + ["Route123_BerryMastersHouse_Text_Ah"] = 0x2A7682, + ["Route123_BerryMastersHouse_Text_DoneForToday"] = 0x2A749E, + ["Route123_BerryMastersHouse_Text_GoodSayingTakeThis"] = 0x2A75D0, + ["Route123_BerryMastersHouse_Text_HeardAGoodSayingLately"] = 0x2A74E6, + ["Route123_BerryMastersHouse_Text_InspirationalTakeThis"] = 0x2A7583, + ["Route123_BerryMastersHouse_Text_JoyNeverGoesOutOfMyLife"] = 0x2A761B, + ["Route123_BerryMastersHouse_Text_VisitPrettyPetalFlowerShop"] = 0x2A7445, + ["Route123_BerryMastersHouse_Text_WhyBeStingyTakeAnother"] = 0x2A7428, + ["Route123_BerryMastersHouse_Text_YoureDeservingOfBerry"] = 0x2A7386, + ["Route123_Text_AlbertoDefeat"] = 0x2A0704, + ["Route123_Text_AlbertoIntro"] = 0x2A06AF, + ["Route123_Text_AlbertoPostBattle"] = 0x2A072B, + ["Route123_Text_BraxtonDefeat"] = 0x29FCD6, + ["Route123_Text_BraxtonIntro"] = 0x29FC6F, + ["Route123_Text_BraxtonPostBattle"] = 0x29FCF4, + ["Route123_Text_CameronDefeat"] = 0x29FE1E, + ["Route123_Text_CameronIntro"] = 0x29FDBA, + ["Route123_Text_CameronPostBattle"] = 0x29FE2A, + ["Route123_Text_CameronPostRematch"] = 0x29FF27, + ["Route123_Text_CameronRegister"] = 0x29FE70, + ["Route123_Text_CameronRematchDefeat"] = 0x29FF1B, + ["Route123_Text_CameronRematchIntro"] = 0x29FED4, + ["Route123_Text_DavisDefeat"] = 0x2A04FE, + ["Route123_Text_DavisIntro"] = 0x2A04C0, + ["Route123_Text_DavisPostBattle"] = 0x2A0512, + ["Route123_Text_EdDefeat"] = 0x2A07F0, + ["Route123_Text_EdIntro"] = 0x2A079B, + ["Route123_Text_EdPostBattle"] = 0x2A080D, + ["Route123_Text_FernandoDefeat"] = 0x2A035C, + ["Route123_Text_FernandoIntro"] = 0x2A0323, + ["Route123_Text_FernandoPostBattle"] = 0x2A0389, + ["Route123_Text_FernandoPostRematch"] = 0x2A0487, + ["Route123_Text_FernandoRegister"] = 0x2A03C1, + ["Route123_Text_FernandoRematchDefeat"] = 0x2A045A, + ["Route123_Text_FernandoRematchIntro"] = 0x2A03FB, + ["Route123_Text_FrederickDefeat"] = 0x2A0631, + ["Route123_Text_FrederickIntro"] = 0x2A060A, + ["Route123_Text_FrederickPostBattle"] = 0x2A0672, + ["Route123_Text_JackiDefeat"] = 0x29FFD8, + ["Route123_Text_JackiIntro"] = 0x29FF61, + ["Route123_Text_JackiPostBattle"] = 0x29FFE5, + ["Route123_Text_JackiPostRematch"] = 0x2A0099, + ["Route123_Text_JackiRegister"] = 0x2A0027, + ["Route123_Text_JackiRematchDefeat"] = 0x2A008D, + ["Route123_Text_JackiRematchIntro"] = 0x2A005E, + ["Route123_Text_JazmynDefeat"] = 0x2A0592, + ["Route123_Text_JazmynIntro"] = 0x2A054E, + ["Route123_Text_JazmynPostBattle"] = 0x2A05AC, + ["Route123_Text_JonasDefeat"] = 0x2A088A, + ["Route123_Text_JonasIntro"] = 0x2A0854, + ["Route123_Text_JonasPostBattle"] = 0x2A08CA, + ["Route123_Text_KayleyDefeat"] = 0x2A0943, + ["Route123_Text_KayleyIntro"] = 0x2A0902, + ["Route123_Text_KayleyPostBattle"] = 0x2A096E, + ["Route123_Text_KindraDefeat"] = 0x2A02A6, + ["Route123_Text_KindraIntro"] = 0x2A025F, + ["Route123_Text_KindraPostBattle"] = 0x2A02C1, + ["Route123_Text_MiuDefeat"] = 0x2A0119, + ["Route123_Text_MiuIntro"] = 0x2A00D6, + ["Route123_Text_MiuNotEnoughMons"] = 0x2A016D, + ["Route123_Text_MiuPostBattle"] = 0x2A012E, + ["Route123_Text_VioletDefeat"] = 0x29FD53, + ["Route123_Text_VioletIntro"] = 0x29FD1D, + ["Route123_Text_VioletPostBattle"] = 0x29FD7B, + ["Route123_Text_WendyDefeat"] = 0x29FC23, + ["Route123_Text_WendyIntro"] = 0x29FBEB, + ["Route123_Text_WendyPostBattle"] = 0x29FC42, + ["Route123_Text_YukiDefeat"] = 0x2A01D8, + ["Route123_Text_YukiIntro"] = 0x2A01A7, + ["Route123_Text_YukiNotEnoughMons"] = 0x2A0224, + ["Route123_Text_YukiPostBattle"] = 0x2A01EE, + ["Route124_Text_ChadDefeat"] = 0x2A0DFF, + ["Route124_Text_ChadIntro"] = 0x2A0DA0, + ["Route124_Text_ChadPostBattle"] = 0x2A0E1E, + ["Route124_Text_DeclanDefeat"] = 0x2A142C, + ["Route124_Text_DeclanIntro"] = 0x2A13BD, + ["Route124_Text_DeclanPostBattle"] = 0x2A144F, + ["Route124_Text_GraceDefeat"] = 0x2A0D3D, + ["Route124_Text_GraceIntro"] = 0x2A0D0A, + ["Route124_Text_GracePostBattle"] = 0x2A0D66, + ["Route124_Text_IsabellaDefeat"] = 0x2A14BC, + ["Route124_Text_IsabellaIntro"] = 0x2A148E, + ["Route124_Text_IsabellaPostBattle"] = 0x2A14ED, + ["Route124_Text_JennyDefeat"] = 0x2A0B7C, + ["Route124_Text_JennyIntro"] = 0x2A0B37, + ["Route124_Text_JennyPostBattle"] = 0x2A0B9A, + ["Route124_Text_JennyPostRematch"] = 0x2A0CBE, + ["Route124_Text_JennyRegister"] = 0x2A0C14, + ["Route124_Text_JennyRematchDefeat"] = 0x2A0CA0, + ["Route124_Text_JennyRematchIntro"] = 0x2A0C60, + ["Route124_Text_LilaDefeat"] = 0x2A0EFE, + ["Route124_Text_LilaIntro"] = 0x2A0E87, + ["Route124_Text_LilaNotEnoughMons"] = 0x2A0F8C, + ["Route124_Text_LilaPostBattle"] = 0x2A0F3A, + ["Route124_Text_LilaPostRematch"] = 0x2A1203, + ["Route124_Text_LilaRematchDefeat"] = 0x2A11B2, + ["Route124_Text_LilaRematchIntro"] = 0x2A111E, + ["Route124_Text_LilaRematchNotEnoughMons"] = 0x2A1255, + ["Route124_Text_LilaRoyRegister"] = 0x2A109F, + ["Route124_Text_RolandDefeat"] = 0x2A0ACC, + ["Route124_Text_RolandIntro"] = 0x2A0A84, + ["Route124_Text_RolandPostBattle"] = 0x2A0AD9, + ["Route124_Text_RoyDefeat"] = 0x2A1012, + ["Route124_Text_RoyIntro"] = 0x2A0FD1, + ["Route124_Text_RoyNotEnoughMons"] = 0x2A10E5, + ["Route124_Text_RoyPostBattle"] = 0x2A103E, + ["Route124_Text_RoyPostRematch"] = 0x2A130F, + ["Route124_Text_RoyRematchDefeat"] = 0x2A12DD, + ["Route124_Text_RoyRematchIntro"] = 0x2A129A, + ["Route124_Text_RoyRematchNotEnoughMons"] = 0x2A1384, + ["Route124_Text_SpencerDefeat"] = 0x2A0A02, + ["Route124_Text_SpencerIntro"] = 0x2A09B1, + ["Route124_Text_SpencerPostBattle"] = 0x2A0A20, + ["Route125_Text_AuronDefeat"] = 0x2A1BFD, + ["Route125_Text_AuronIntro"] = 0x2A1BCE, + ["Route125_Text_AuronPostBattle"] = 0x2A1C2A, + ["Route125_Text_ErnestDefeat"] = 0x2A173A, + ["Route125_Text_ErnestIntro"] = 0x2A16FC, + ["Route125_Text_ErnestPostBattle"] = 0x2A1755, + ["Route125_Text_ErnestRegister"] = 0x2A17CF, + ["Route125_Text_ErnestRematchDefeat"] = 0x2A1851, + ["Route125_Text_ErnestRematchIntro"] = 0x2A180E, + ["Route125_Text_ErnestRematchPostBattle"] = 0x2A187A, + ["Route125_Text_IrisDefeat"] = 0x2A1A71, + ["Route125_Text_IrisIntro"] = 0x2A1A35, + ["Route125_Text_IrisNotEnoughMons"] = 0x2A1AD9, + ["Route125_Text_IrisPostBattle"] = 0x2A1A92, + ["Route125_Text_KimDefeat"] = 0x2A1989, + ["Route125_Text_KimIntro"] = 0x2A192B, + ["Route125_Text_KimNotEnoughMons"] = 0x2A19F8, + ["Route125_Text_KimPostBattle"] = 0x2A19A6, + ["Route125_Text_NolenDefeat"] = 0x2A155A, + ["Route125_Text_NolenIntro"] = 0x2A1527, + ["Route125_Text_NolenPostBattle"] = 0x2A1567, + ["Route125_Text_PresleyDefeat"] = 0x2A1B4E, + ["Route125_Text_PresleyIntro"] = 0x2A1B1A, + ["Route125_Text_PresleyPostBattle"] = 0x2A1B70, + ["Route125_Text_SharonDefeat"] = 0x2A16C5, + ["Route125_Text_SharonIntro"] = 0x2A1686, + ["Route125_Text_SharonPostBattle"] = 0x2A16CE, + ["Route125_Text_StanDefeat"] = 0x2A15C7, + ["Route125_Text_StanIntro"] = 0x2A159F, + ["Route125_Text_StanPostBattle"] = 0x2A15D5, + ["Route125_Text_TanyaDefeat"] = 0x2A164D, + ["Route125_Text_TanyaIntro"] = 0x2A1615, + ["Route125_Text_TanyaPostBattle"] = 0x2A165E, + ["Route126_Text_BarryDefeat"] = 0x2A1CC8, + ["Route126_Text_BarryIntro"] = 0x2A1C95, + ["Route126_Text_BarryPostBattle"] = 0x2A1CDD, + ["Route126_Text_BrendaDefeat"] = 0x2A1E63, + ["Route126_Text_BrendaIntro"] = 0x2A1E3E, + ["Route126_Text_BrendaPostBattle"] = 0x2A1E70, + ["Route126_Text_DeanDefeat"] = 0x2A1D63, + ["Route126_Text_DeanIntro"] = 0x2A1D2A, + ["Route126_Text_DeanPostBattle"] = 0x2A1D72, + ["Route126_Text_IsobelDefeat"] = 0x2A21A3, + ["Route126_Text_IsobelIntro"] = 0x2A216A, + ["Route126_Text_IsobelPostBattle"] = 0x2A21B8, + ["Route126_Text_LeonardoDefeat"] = 0x2A20C6, + ["Route126_Text_LeonardoIntro"] = 0x2A2063, + ["Route126_Text_LeonardoPostBattle"] = 0x2A20F7, + ["Route126_Text_NikkiDefeat"] = 0x2A1DC8, + ["Route126_Text_NikkiIntro"] = 0x2A1DAE, + ["Route126_Text_NikkiPostBattle"] = 0x2A1E03, + ["Route126_Text_PabloDefeat"] = 0x2A1EE3, + ["Route126_Text_PabloIntro"] = 0x2A1EA5, + ["Route126_Text_PabloPostBattle"] = 0x2A1F10, + ["Route126_Text_PabloPostRematch"] = 0x2A200C, + ["Route126_Text_PabloRegister"] = 0x2A1F5A, + ["Route126_Text_PabloRematchDefeat"] = 0x2A1FD8, + ["Route126_Text_PabloRematchIntro"] = 0x2A1F98, + ["Route126_Text_SiennaDefeat"] = 0x2A2216, + ["Route126_Text_SiennaIntro"] = 0x2A21EF, + ["Route126_Text_SiennaPostBattle"] = 0x2A222A, + ["Route127_Text_AidanDefeat"] = 0x2A25C1, + ["Route127_Text_AidanIntro"] = 0x2A257A, + ["Route127_Text_AidanPostBattle"] = 0x2A25D2, + ["Route127_Text_AthenaDefeat"] = 0x2A27FC, + ["Route127_Text_AthenaIntro"] = 0x2A27D0, + ["Route127_Text_AthenaPostBattle"] = 0x2A2832, + ["Route127_Text_CamdenDefeat"] = 0x2A2294, + ["Route127_Text_CamdenIntro"] = 0x2A225F, + ["Route127_Text_CamdenPostBattle"] = 0x2A22A1, + ["Route127_Text_DonnyDefeat"] = 0x2A2315, + ["Route127_Text_DonnyIntro"] = 0x2A22DD, + ["Route127_Text_DonnyPostBattle"] = 0x2A232C, + ["Route127_Text_HenryDefeat"] = 0x2A2483, + ["Route127_Text_HenryIntro"] = 0x2A2450, + ["Route127_Text_HenryPostBattle"] = 0x2A2494, + ["Route127_Text_JonahDefeat"] = 0x2A23E1, + ["Route127_Text_JonahIntro"] = 0x2A2381, + ["Route127_Text_JonahPostBattle"] = 0x2A240C, + ["Route127_Text_KojiDefeat"] = 0x2A2685, + ["Route127_Text_KojiIntro"] = 0x2A264D, + ["Route127_Text_KojiPostBattle"] = 0x2A26AC, + ["Route127_Text_KojiPostRematch"] = 0x2A278E, + ["Route127_Text_KojiRegister"] = 0x2A26EE, + ["Route127_Text_KojiRematchDefeat"] = 0x2A276B, + ["Route127_Text_KojiRematchIntro"] = 0x2A2734, + ["Route127_Text_RogerDefeat"] = 0x2A250B, + ["Route127_Text_RogerIntro"] = 0x2A24C6, + ["Route127_Text_RogerPostBattle"] = 0x2A2538, + ["Route128_Text_AlexaDefeat"] = 0x2A2C79, + ["Route128_Text_AlexaIntro"] = 0x2A2C1C, + ["Route128_Text_AlexaPostBattle"] = 0x2A2C95, + ["Route128_Text_CarleeDefeat"] = 0x2A2EFA, + ["Route128_Text_CarleeIntro"] = 0x2A2EC8, + ["Route128_Text_CarleePostBattle"] = 0x2A2F2F, + ["Route128_Text_HarrisonDefeat"] = 0x2A2E44, + ["Route128_Text_HarrisonIntro"] = 0x2A2E0F, + ["Route128_Text_HarrisonPostBattle"] = 0x2A2E6C, + ["Route128_Text_IsaiahDefeat"] = 0x2A28AB, + ["Route128_Text_IsaiahIntro"] = 0x2A287F, + ["Route128_Text_IsaiahPostBattle"] = 0x2A28D7, + ["Route128_Text_IsaiahPostRematch"] = 0x2A29DC, + ["Route128_Text_IsaiahRegister"] = 0x2A2916, + ["Route128_Text_IsaiahRematchDefeat"] = 0x2A29B8, + ["Route128_Text_IsaiahRematchIntro"] = 0x2A2975, + ["Route128_Text_KatelynDefeat"] = 0x2A2A94, + ["Route128_Text_KatelynIntro"] = 0x2A2A1F, + ["Route128_Text_KatelynPostBattle"] = 0x2A2AB7, + ["Route128_Text_KatelynPostRematch"] = 0x2A2BDD, + ["Route128_Text_KatelynRegister"] = 0x2A2AF9, + ["Route128_Text_KatelynRematchDefeat"] = 0x2A2BAB, + ["Route128_Text_KatelynRematchIntro"] = 0x2A2B41, + ["Route128_Text_RubenDefeat"] = 0x2A2CFE, + ["Route128_Text_RubenIntro"] = 0x2A2CD9, + ["Route128_Text_RubenPostBattle"] = 0x2A2D0D, + ["Route128_Text_WayneDefeat"] = 0x2A2DA9, + ["Route128_Text_WayneIntro"] = 0x2A2D3D, + ["Route128_Text_WaynePostBattle"] = 0x2A2DBA, + ["Route129_Text_AllisonDefeat"] = 0x2A3034, + ["Route129_Text_AllisonIntro"] = 0x2A2FF2, + ["Route129_Text_AllisonPostBattle"] = 0x2A3048, + ["Route129_Text_ChaseDefeat"] = 0x2A2F9D, + ["Route129_Text_ChaseIntro"] = 0x2A2F66, + ["Route129_Text_ChasePostBattle"] = 0x2A2FB9, + ["Route129_Text_ClarenceDefeat"] = 0x2A3239, + ["Route129_Text_ClarenceIntro"] = 0x2A3204, + ["Route129_Text_ClarencePostBattle"] = 0x2A3252, + ["Route129_Text_ReedDefeat"] = 0x2A30F1, + ["Route129_Text_ReedIntro"] = 0x2A30CD, + ["Route129_Text_ReedPostBattle"] = 0x2A3114, + ["Route129_Text_TishaDefeat"] = 0x2A317D, + ["Route129_Text_TishaIntro"] = 0x2A314E, + ["Route129_Text_TishaPostBattle"] = 0x2A31A6, + ["Route130_Text_KatieDefeat"] = 0x2A33AC, + ["Route130_Text_KatieIntro"] = 0x2A3363, + ["Route130_Text_KatiePostBattle"] = 0x2A33F5, + ["Route130_Text_RodneyDefeat"] = 0x2A32E6, + ["Route130_Text_RodneyIntro"] = 0x2A328A, + ["Route130_Text_RodneyPostBattle"] = 0x2A3300, + ["Route130_Text_SantiagoDefeat"] = 0x2A346D, + ["Route130_Text_SantiagoIntro"] = 0x2A343A, + ["Route130_Text_SantiagoPostBattle"] = 0x2A3494, + ["Route131_Text_HermanDefeat"] = 0x2A3626, + ["Route131_Text_HermanIntro"] = 0x2A35C6, + ["Route131_Text_HermanPostBattle"] = 0x2A362D, + ["Route131_Text_IanDefeat"] = 0x2A3925, + ["Route131_Text_IanIntro"] = 0x2A38F8, + ["Route131_Text_IanNotEnoughMons"] = 0x2A39CD, + ["Route131_Text_IanPostBattle"] = 0x2A3960, + ["Route131_Text_KaraDefeat"] = 0x2A378A, + ["Route131_Text_KaraIntro"] = 0x2A3751, + ["Route131_Text_KaraPostBattle"] = 0x2A379F, + ["Route131_Text_KevinDefeat"] = 0x2A3AFC, + ["Route131_Text_KevinIntro"] = 0x2A3AA0, + ["Route131_Text_KevinPostBattle"] = 0x2A3B02, + ["Route131_Text_ReliDefeat"] = 0x2A381F, + ["Route131_Text_ReliIntro"] = 0x2A37E9, + ["Route131_Text_ReliNotEnoughMons"] = 0x2A38B7, + ["Route131_Text_ReliPostBattle"] = 0x2A3855, + ["Route131_Text_RichardDefeat"] = 0x2A350D, + ["Route131_Text_RichardIntro"] = 0x2A34C8, + ["Route131_Text_RichardPostBattle"] = 0x2A353D, + ["Route131_Text_SusieDefeat"] = 0x2A36AB, + ["Route131_Text_SusieIntro"] = 0x2A367B, + ["Route131_Text_SusiePostBattle"] = 0x2A36D6, + ["Route131_Text_TaliaDefeat"] = 0x2A3A38, + ["Route131_Text_TaliaIntro"] = 0x2A39FE, + ["Route131_Text_TaliaPostBattle"] = 0x2A3A48, + ["Route132_Text_DanaDefeat"] = 0x2A3C39, + ["Route132_Text_DanaIntro"] = 0x2A3C04, + ["Route132_Text_DanaPostBattle"] = 0x2A3C49, + ["Route132_Text_DarcyDefeat"] = 0x2A406E, + ["Route132_Text_DarcyIntro"] = 0x2A4026, + ["Route132_Text_DarcyPostBattle"] = 0x2A40A4, + ["Route132_Text_GilbertDefeat"] = 0x2A3BAC, + ["Route132_Text_GilbertIntro"] = 0x2A3B4C, + ["Route132_Text_GilbertPostBattle"] = 0x2A3BC0, + ["Route132_Text_JonathanDefeat"] = 0x2A3EDD, + ["Route132_Text_JonathanIntro"] = 0x2A3EAC, + ["Route132_Text_JonathanPostBattle"] = 0x2A3F03, + ["Route132_Text_KiyoDefeat"] = 0x2A3D6C, + ["Route132_Text_KiyoIntro"] = 0x2A3D26, + ["Route132_Text_KiyoPostBattle"] = 0x2A3D8B, + ["Route132_Text_MakaylaDefeat"] = 0x2A3E2C, + ["Route132_Text_MakaylaIntro"] = 0x2A3DF0, + ["Route132_Text_MakaylaPostBattle"] = 0x2A3E4E, + ["Route132_Text_PaxtonDefeat"] = 0x2A3FA8, + ["Route132_Text_PaxtonIntro"] = 0x2A3F4F, + ["Route132_Text_PaxtonPostBattle"] = 0x2A3FE5, + ["Route132_Text_RonaldDefeat"] = 0x2A3CAD, + ["Route132_Text_RonaldIntro"] = 0x2A3C7F, + ["Route132_Text_RonaldPostBattle"] = 0x2A3CC5, + ["Route133_Text_BeckDefeat"] = 0x2A439C, + ["Route133_Text_BeckIntro"] = 0x2A436A, + ["Route133_Text_BeckPostBattle"] = 0x2A43B9, + ["Route133_Text_ConorDefeat"] = 0x2A4505, + ["Route133_Text_ConorIntro"] = 0x2A44BA, + ["Route133_Text_ConorPostBattle"] = 0x2A4527, + ["Route133_Text_DebraDefeat"] = 0x2A4200, + ["Route133_Text_DebraIntro"] = 0x2A41A8, + ["Route133_Text_DebraPostBattle"] = 0x2A420E, + ["Route133_Text_FranklinDefeat"] = 0x2A413F, + ["Route133_Text_FranklinIntro"] = 0x2A40F0, + ["Route133_Text_FranklinPostBattle"] = 0x2A4154, + ["Route133_Text_LindaDefeat"] = 0x2A4258, + ["Route133_Text_LindaIntro"] = 0x2A4236, + ["Route133_Text_LindaPostBattle"] = 0x2A4264, + ["Route133_Text_MollieDefeat"] = 0x2A443A, + ["Route133_Text_MollieIntro"] = 0x2A4401, + ["Route133_Text_MolliePostBattle"] = 0x2A4477, + ["Route133_Text_WarrenDefeat"] = 0x2A42E0, + ["Route133_Text_WarrenIntro"] = 0x2A4290, + ["Route133_Text_WarrenPostBattle"] = 0x2A4304, + ["Route134_Text_AaronDefeat"] = 0x2A47E1, + ["Route134_Text_AaronIntro"] = 0x2A479E, + ["Route134_Text_AaronPostBattle"] = 0x2A47FD, + ["Route134_Text_AlexDefeat"] = 0x2A46BE, + ["Route134_Text_AlexIntro"] = 0x2A4682, + ["Route134_Text_AlexPostBattle"] = 0x2A46D2, + ["Route134_Text_HitoshiDefeat"] = 0x2A4745, + ["Route134_Text_HitoshiIntro"] = 0x2A4709, + ["Route134_Text_HitoshiPostBattle"] = 0x2A475D, + ["Route134_Text_HudsonDefeat"] = 0x2A4AC1, + ["Route134_Text_HudsonIntro"] = 0x2A4A8F, + ["Route134_Text_HudsonPostBattle"] = 0x2A4AD8, + ["Route134_Text_JackDefeat"] = 0x2A45BB, + ["Route134_Text_JackIntro"] = 0x2A4571, + ["Route134_Text_JackPostBattle"] = 0x2A45C5, + ["Route134_Text_KelvinDefeat"] = 0x2A489B, + ["Route134_Text_KelvinIntro"] = 0x2A4875, + ["Route134_Text_KelvinPostBattle"] = 0x2A48BD, + ["Route134_Text_LaurelDefeat"] = 0x2A4643, + ["Route134_Text_LaurelIntro"] = 0x2A4609, + ["Route134_Text_LaurelPostBattle"] = 0x2A464B, + ["Route134_Text_MarleyDefeat"] = 0x2A496B, + ["Route134_Text_MarleyIntro"] = 0x2A4937, + ["Route134_Text_MarleyPostBattle"] = 0x2A49B1, + ["Route134_Text_ReynaDefeat"] = 0x2A4A10, + ["Route134_Text_ReynaIntro"] = 0x2A49E9, + ["Route134_Text_ReynaPostBattle"] = 0x2A4A34, + ["SafariZone_North_Text_Fisherman"] = 0x2A5489, + ["SafariZone_North_Text_Man"] = 0x2A54F0, + ["SafariZone_Northeast_Text_Boy"] = 0x2A59A4, + ["SafariZone_Northeast_Text_Girl"] = 0x2A5A44, + ["SafariZone_Northeast_Text_Woman"] = 0x2A5A09, + ["SafariZone_Northwest_Text_Man"] = 0x2A542C, + ["SafariZone_RestHouse_Text_FatMan"] = 0x2A5764, + ["SafariZone_RestHouse_Text_PsychicM"] = 0x2A56E1, + ["SafariZone_RestHouse_Text_Youngster"] = 0x2A5639, + ["SafariZone_South_Text_AreaOffLimits1"] = 0x2A57EE, + ["SafariZone_South_Text_AreaOffLimits2"] = 0x2A5887, + ["SafariZone_South_Text_Boy"] = 0x2A52EF, + ["SafariZone_South_Text_EnjoyTheRestOfYourAdventure"] = 0x2A521A, + ["SafariZone_South_Text_ExitEarlyThankYouForPlaying"] = 0x2A5248, + ["SafariZone_South_Text_GoodLuck"] = 0x2A52AB, + ["SafariZone_South_Text_Man"] = 0x2A533B, + ["SafariZone_South_Text_StillHaveTimeExit"] = 0x2A51D4, + ["SafariZone_South_Text_Youngster"] = 0x2A553E, + ["SafariZone_Southeast_Text_ExpansionIsFinished"] = 0x2A582D, + ["SafariZone_Southeast_Text_FatMan"] = 0x2A58FD, + ["SafariZone_Southeast_Text_LittleGirl"] = 0x2A58C6, + ["SafariZone_Southeast_Text_RichBoy"] = 0x2A5960, + ["SafariZone_Southwest_Text_RestHouseSign"] = 0x2A5613, + ["SafariZone_Southwest_Text_Woman"] = 0x2A53B7, + ["SafariZone_Text_OutOfBalls"] = 0x2A4C56, + ["SafariZone_Text_PlacePokeblockOnFeeder"] = 0x2A4C90, + ["SafariZone_Text_PokeblockStillHere"] = 0x2A4CC5, + ["SafariZone_Text_PokeblockWasPlaced"] = 0x2A4CEB, + ["SafariZone_Text_TimesUp"] = 0x2A4C26, + ["SafariZone_Text_WouldYouLikeToExit"] = 0x2A4BF4, + ["SecretBase_Text_AllDecorationsWillBeReturned"] = 0x2766AA, + ["SecretBase_Text_AlreadyRegisteredDelete"] = 0x276731, + ["SecretBase_Text_BattleTowerShield"] = 0x27692B, + ["SecretBase_Text_BootUpPC"] = 0x276805, + ["SecretBase_Text_ClumpOfGrass"] = 0x2747DD, + ["SecretBase_Text_ClumpUseSecretPower"] = 0x274825, + ["SecretBase_Text_DataUnregistered"] = 0x2767E9, + ["SecretBase_Text_DiscoveredSmallCavern"] = 0x23B73E, + ["SecretBase_Text_DiscoveredSmallEntrance"] = 0x274883, + ["SecretBase_Text_IndentUseSecretPower"] = 0x23B704, + ["SecretBase_Text_MovingCompletedUseSecretPower"] = 0x276A95, + ["SecretBase_Text_RegistrationCompleted"] = 0x2767D1, + ["SecretBase_Text_RegistryInfo"] = 0x276835, + ["SecretBase_Text_SeedotTV"] = 0x2769B8, + ["SecretBase_Text_SkittyTV"] = 0x2769FF, + ["SecretBase_Text_SmallIndentInWall"] = 0x23B6E0, + ["SecretBase_Text_TooManyBasesDeleteSome"] = 0x27676F, + ["SecretBase_Text_ToyTV"] = 0x276974, + ["SecretBase_Text_Trainer0AcceptBattle"] = 0x274939, + ["SecretBase_Text_Trainer0DeclineBattle"] = 0x27494D, + ["SecretBase_Text_Trainer0Defeated"] = 0x274966, + ["SecretBase_Text_Trainer0Intro"] = 0x2748A0, + ["SecretBase_Text_Trainer0PostBattle"] = 0x2749AA, + ["SecretBase_Text_Trainer0PreChampion"] = 0x2749ED, + ["SecretBase_Text_Trainer1AcceptBattle"] = 0x274CB0, + ["SecretBase_Text_Trainer1DeclineBattle"] = 0x274CDA, + ["SecretBase_Text_Trainer1Defeated"] = 0x274D13, + ["SecretBase_Text_Trainer1Intro"] = 0x274C13, + ["SecretBase_Text_Trainer1PostBattle"] = 0x274D34, + ["SecretBase_Text_Trainer1PreChampion"] = 0x274D69, + ["SecretBase_Text_Trainer2AcceptBattle"] = 0x274FCA, + ["SecretBase_Text_Trainer2DeclineBattle"] = 0x274FDA, + ["SecretBase_Text_Trainer2Defeated"] = 0x274FFE, + ["SecretBase_Text_Trainer2Intro"] = 0x274F39, + ["SecretBase_Text_Trainer2PostBattle"] = 0x27502A, + ["SecretBase_Text_Trainer2PreChampion"] = 0x2750A4, + ["SecretBase_Text_Trainer3AcceptBattle"] = 0x275315, + ["SecretBase_Text_Trainer3DeclineBattle"] = 0x275330, + ["SecretBase_Text_Trainer3Defeated"] = 0x275367, + ["SecretBase_Text_Trainer3Intro"] = 0x275287, + ["SecretBase_Text_Trainer3PostBattle"] = 0x275374, + ["SecretBase_Text_Trainer3PreChampion"] = 0x2753AB, + ["SecretBase_Text_Trainer4AcceptBattle"] = 0x275679, + ["SecretBase_Text_Trainer4DeclineBattle"] = 0x275696, + ["SecretBase_Text_Trainer4Defeated"] = 0x2756C7, + ["SecretBase_Text_Trainer4Intro"] = 0x2755D2, + ["SecretBase_Text_Trainer4PostBattle"] = 0x2756EF, + ["SecretBase_Text_Trainer4PreChampion"] = 0x27571E, + ["SecretBase_Text_Trainer5AcceptBattle"] = 0x274AFA, + ["SecretBase_Text_Trainer5DeclineBattle"] = 0x274B0B, + ["SecretBase_Text_Trainer5Defeated"] = 0x274B24, + ["SecretBase_Text_Trainer5Intro"] = 0x274A64, + ["SecretBase_Text_Trainer5PostBattle"] = 0x274B6C, + ["SecretBase_Text_Trainer5PreChampion"] = 0x274BA2, + ["SecretBase_Text_Trainer6AcceptBattle"] = 0x274E41, + ["SecretBase_Text_Trainer6DeclineBattle"] = 0x274E5A, + ["SecretBase_Text_Trainer6Defeated"] = 0x274E75, + ["SecretBase_Text_Trainer6Intro"] = 0x274DD2, + ["SecretBase_Text_Trainer6PostBattle"] = 0x274EB3, + ["SecretBase_Text_Trainer6PreChampion"] = 0x274EF1, + ["SecretBase_Text_Trainer7AcceptBattle"] = 0x2751AF, + ["SecretBase_Text_Trainer7DeclineBattle"] = 0x2751CA, + ["SecretBase_Text_Trainer7Defeated"] = 0x2751E1, + ["SecretBase_Text_Trainer7Intro"] = 0x275114, + ["SecretBase_Text_Trainer7PostBattle"] = 0x2751EC, + ["SecretBase_Text_Trainer7PreChampion"] = 0x275226, + ["SecretBase_Text_Trainer8AcceptBattle"] = 0x2754B2, + ["SecretBase_Text_Trainer8DeclineBattle"] = 0x2754D8, + ["SecretBase_Text_Trainer8Defeated"] = 0x2754F6, + ["SecretBase_Text_Trainer8Intro"] = 0x275405, + ["SecretBase_Text_Trainer8PostBattle"] = 0x27550C, + ["SecretBase_Text_Trainer8PreChampion"] = 0x275546, + ["SecretBase_Text_Trainer9AcceptBattle"] = 0x275884, + ["SecretBase_Text_Trainer9DeclineBattle"] = 0x27589D, + ["SecretBase_Text_Trainer9Defeated"] = 0x2758CC, + ["SecretBase_Text_Trainer9Intro"] = 0x2757B5, + ["SecretBase_Text_Trainer9PostBattle"] = 0x275909, + ["SecretBase_Text_Trainer9PreChampion"] = 0x275944, + ["SecretBase_Text_TreeCanBeClimbed"] = 0x274746, + ["SecretBase_Text_TreeUseSecretPower"] = 0x274779, + ["SecretBase_Text_VineDroppedDown"] = 0x2747C2, + ["SecretBase_Text_WantToMakeYourSecretBaseHere"] = 0x23B759, + ["SecretBase_Text_WantToRegisterSecretBase"] = 0x276707, + ["SecretBase_Text_WhatWouldYouLikeToDo"] = 0x27681A, + ["SecretBase_Text_WouldYouLikeToMoveBases"] = 0x276A3D, + ["ShoalCave_LowTideEntranceRoom_Text_AreYouPlanningOnGoingInThere"] = 0x2A7E0E, + ["ShoalCave_LowTideEntranceRoom_Text_BringMe4ShoalSaltAndShells"] = 0x2A7EB3, + ["ShoalCave_LowTideEntranceRoom_Text_ExplainShellBell"] = 0x2A8012, + ["ShoalCave_LowTideEntranceRoom_Text_MakeShellBellRightAway"] = 0x2A7FAC, + ["ShoalCave_LowTideEntranceRoom_Text_NoSpaceInYourBag"] = 0x2A80F5, + ["ShoalCave_LowTideEntranceRoom_Text_WantedToMakeShellBell"] = 0x2A80C6, + ["ShoalCave_LowTideEntranceRoom_Text_WouldYouLikeShellBell"] = 0x2A7F37, + ["ShoalCave_Text_WasShoalSaltNowNothing"] = 0x2A8169, + ["ShoalCave_Text_WasShoallShellNowNothing"] = 0x2A81A8, + ["SlateportCity_BattleTentLobby_Text_AwardYouThisPrize"] = 0x2C5D14, + ["SlateportCity_BattleTentLobby_Text_BeenWaitingForYou"] = 0x2C5DA7, + ["SlateportCity_BattleTentLobby_Text_DidntSaveBeforeQuitting"] = 0x2C5DFA, + ["SlateportCity_BattleTentLobby_Text_ExplainBasicRules"] = 0x2C5F08, + ["SlateportCity_BattleTentLobby_Text_ExplainMonRules"] = 0x2C61FE, + ["SlateportCity_BattleTentLobby_Text_ExplainSlateportTent"] = 0x2C589C, + ["SlateportCity_BattleTentLobby_Text_ExplainSwapNotes"] = 0x2C60C0, + ["SlateportCity_BattleTentLobby_Text_ExplainSwapNumberRules"] = 0x2C6020, + ["SlateportCity_BattleTentLobby_Text_ExplainSwapPartnerRules"] = 0x2C5F9D, + ["SlateportCity_BattleTentLobby_Text_HoldMonsForSafekeeping"] = 0x2C5B4A, + ["SlateportCity_BattleTentLobby_Text_LookForwardToNextVisit"] = 0x2C5AA5, + ["SlateportCity_BattleTentLobby_Text_NoRoomInBagMakeRoom"] = 0x2C5D52, + ["SlateportCity_BattleTentLobby_Text_ReadWhichHeading"] = 0x2C5EE5, + ["SlateportCity_BattleTentLobby_Text_ReceivedPrize"] = 0x2C5EA9, + ["SlateportCity_BattleTentLobby_Text_ReturnMonsExchangeRentals"] = 0x2C5C30, + ["SlateportCity_BattleTentLobby_Text_ReturnPersonalMons"] = 0x2C5E85, + ["SlateportCity_BattleTentLobby_Text_ReturnRentalMonsSaveResults"] = 0x2C5BA2, + ["SlateportCity_BattleTentLobby_Text_RulesAreListed"] = 0x2C5EC3, + ["SlateportCity_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C5B06, + ["SlateportCity_BattleTentLobby_Text_StepThisWay"] = 0x2C5B8C, + ["SlateportCity_BattleTentLobby_Text_TakeChallenge"] = 0x2C586A, + ["SlateportCity_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C5810, + ["SlateportCity_BattleTentLobby_Text_WhichLevelMode"] = 0x2C5AC9, + ["SlateportCity_BattleTentLobby_Text_WonThreeMatchesReturnMons"] = 0x2C5C6F, + ["SlateportCity_OceanicMuseum_1F_Text_BetterWriteUpStory"] = 0x28144D, + ["SlateportCity_OceanicMuseum_1F_Text_InterviewRequest"] = 0x2811A0, + ["SlateportCity_OceanicMuseum_1F_Text_InterviewRequestShort"] = 0x28126D, + ["SlateportCity_OceanicMuseum_1F_Text_LetMeKnowIfYouHaveStory"] = 0x281367, + ["SlateportCity_OceanicMuseum_1F_Text_TellMeExperienceInvolvingPokemon"] = 0x2812F2, + ["SlateportCity_OceanicMuseum_1F_Text_ThatsAllForInterview"] = 0x2813B9, + ["SlateportCity_PokemonFanClub_Text_DescribeFeelingsFirstMetMon"] = 0x280393, + ["SlateportCity_PokemonFanClub_Text_EnjoyDoingInterviews"] = 0x28062E, + ["SlateportCity_PokemonFanClub_Text_HereGoesQuickAnswers"] = 0x28034F, + ["SlateportCity_PokemonFanClub_Text_HereIfYouGetUrgeToTellMe"] = 0x2805E2, + ["SlateportCity_PokemonFanClub_Text_InterviewRequest"] = 0x280674, + ["SlateportCity_PokemonFanClub_Text_InterviewRequestHasName"] = 0x280270, + ["SlateportCity_PokemonFanClub_Text_LikenMonToSomethingYouLike"] = 0x2803EF, + ["SlateportCity_PokemonFanClub_Text_TellMeAnythingAboutYourMon"] = 0x28073B, + ["SlateportCity_PokemonFanClub_Text_ThatsAllForInterview"] = 0x280523, + ["SlateportCity_PokemonFanClub_Text_ThatsAllForInterview2"] = 0x280789, + ["SlateportCity_PokemonFanClub_Text_WhatAttractedYouAboutMon"] = 0x280454, + ["SlateportCity_PokemonFanClub_Text_WhatDoPokemonMeanToYou"] = 0x2804AC, + ["SmartShopper_Text_ClerkMax"] = 0x2840CE, + ["SmartShopper_Text_ClerkNormal"] = 0x283BAF, + ["SmartShopper_Text_DuringSale"] = 0x283FA9, + ["SmartShopper_Text_Intro"] = 0x283B05, + ["SmartShopper_Text_IsVIP"] = 0x28409E, + ["SmartShopper_Text_OutroMax"] = 0x2842E6, + ["SmartShopper_Text_OutroNormal"] = 0x283FE7, + ["SmartShopper_Text_RandomComment1"] = 0x283C81, + ["SmartShopper_Text_RandomComment2"] = 0x283D32, + ["SmartShopper_Text_RandomComment3"] = 0x283D99, + ["SmartShopper_Text_RandomComment4"] = 0x283E28, + ["SmartShopper_Text_SecondItem"] = 0x283F01, + ["SmartShopper_Text_ThirdItem"] = 0x283F72, + ["SootopolisCity_Text_GiveYouThisBerryToo"] = 0x2A7C7C, + ["SootopolisCity_Text_LikeSeasonBornIn"] = 0x2A7CEC, + ["SootopolisCity_Text_NameIsKiriHaveOneOfThese"] = 0x2A7BB0, + ["SootopolisCity_Text_OhDoesntMatter"] = 0x2A7DD2, + ["SootopolisCity_Text_ThenILoveAutumn"] = 0x2A7D80, + ["SootopolisCity_Text_WhatKindOfWishInYourName"] = 0x2A7CB7, + ["SouthernIsland_Exterior_Text_Sign"] = 0x2A6AD5, + ["SouthernIsland_Interior_Text_Sign"] = 0x2A6AA1, + ["TVSecretBaseSecrets_Text_BaseFailedToInterestPlayer"] = 0x288739, + ["TVSecretBaseSecrets_Text_BattledDraw"] = 0x2890F0, + ["TVSecretBaseSecrets_Text_BattledLost"] = 0x288EC9, + ["TVSecretBaseSecrets_Text_BattledWon"] = 0x288E1E, + ["TVSecretBaseSecrets_Text_BrokeDoor"] = 0x2895EB, + ["TVSecretBaseSecrets_Text_DeclinedBattle"] = 0x288F58, + ["TVSecretBaseSecrets_Text_HitCushion"] = 0x288DA0, + ["TVSecretBaseSecrets_Text_HuggedCushion"] = 0x288DDA, + ["TVSecretBaseSecrets_Text_Intro"] = 0x288608, + ["TVSecretBaseSecrets_Text_Outro"] = 0x2887E9, + ["TVSecretBaseSecrets_Text_PlayerEnjoyedBase"] = 0x288777, + ["TVSecretBaseSecrets_Text_PlayerHugeFanOfBase"] = 0x2887AF, + ["TVSecretBaseSecrets_Text_StoppedMoving1"] = 0x288868, + ["TVSecretBaseSecrets_Text_StoppedMoving2"] = 0x2888CA, + ["TVSecretBaseSecrets_Text_TookXStepsBeforeLeaving"] = 0x2886F8, + ["TVSecretBaseSecrets_Text_UsedBag"] = 0x288CC8, + ["TVSecretBaseSecrets_Text_UsedBalloon"] = 0x2889C0, + ["TVSecretBaseSecrets_Text_UsedBrick"] = 0x289313, + ["TVSecretBaseSecrets_Text_UsedChair"] = 0x288930, + ["TVSecretBaseSecrets_Text_UsedCushion"] = 0x288D7F, + ["TVSecretBaseSecrets_Text_UsedDesk"] = 0x28927C, + ["TVSecretBaseSecrets_Text_UsedDoll"] = 0x289645, + ["TVSecretBaseSecrets_Text_UsedFence"] = 0x2893FF, + ["TVSecretBaseSecrets_Text_UsedGlassOrnament"] = 0x288BC9, + ["TVSecretBaseSecrets_Text_UsedGlitterMat"] = 0x289465, + ["TVSecretBaseSecrets_Text_UsedGoldShield"] = 0x288B1E, + ["TVSecretBaseSecrets_Text_UsedJumpMat"] = 0x28977D, + ["TVSecretBaseSecrets_Text_UsedMudBall"] = 0x288C88, + ["TVSecretBaseSecrets_Text_UsedNoteMat"] = 0x2890A4, + ["TVSecretBaseSecrets_Text_UsedPlant"] = 0x288ACA, + ["TVSecretBaseSecrets_Text_UsedPoster"] = 0x289011, + ["TVSecretBaseSecrets_Text_UsedSandOrnament"] = 0x289204, + ["TVSecretBaseSecrets_Text_UsedSilverShield"] = 0x288B7A, + ["TVSecretBaseSecrets_Text_UsedSlide"] = 0x28967E, + ["TVSecretBaseSecrets_Text_UsedSlideButDidntGoDown"] = 0x2896FC, + ["TVSecretBaseSecrets_Text_UsedSolidBoard"] = 0x289375, + ["TVSecretBaseSecrets_Text_UsedSpinMat"] = 0x289193, + ["TVSecretBaseSecrets_Text_UsedStand"] = 0x289564, + ["TVSecretBaseSecrets_Text_UsedTV"] = 0x288C40, + ["TVSecretBaseSecrets_Text_UsedTent"] = 0x288A3A, + ["TVSecretBaseSecrets_Text_UsedTire"] = 0x2894F2, + ["TVSecretBaseSecrets_Text_WhatWillPlayerDoNext1"] = 0x2886C8, + ["TVSecretBaseSecrets_Text_WhatWillPlayerDoNext2"] = 0x2886DE, + ["TVSpotTheCuties_Text_Intro"] = 0x286D8F, + ["TVSpotTheCuties_Text_Outro"] = 0x287779, + ["TVSpotTheCuties_Text_RibbonArtist"] = 0x2875D9, + ["TVSpotTheCuties_Text_RibbonBeauty"] = 0x28715A, + ["TVSpotTheCuties_Text_RibbonChampion"] = 0x286FE4, + ["TVSpotTheCuties_Text_RibbonCool"] = 0x2870A3, + ["TVSpotTheCuties_Text_RibbonCute"] = 0x287215, + ["TVSpotTheCuties_Text_RibbonEffort"] = 0x2876A1, + ["TVSpotTheCuties_Text_RibbonIntro"] = 0x286FAA, + ["TVSpotTheCuties_Text_RibbonSmart"] = 0x2872CC, + ["TVSpotTheCuties_Text_RibbonTough"] = 0x287387, + ["TVSpotTheCuties_Text_RibbonVictory"] = 0x287508, + ["TVSpotTheCuties_Text_RibbonWinning"] = 0x287442, + ["TVSpotTheCuties_Text_RibbonsHigh"] = 0x286F54, + ["TVSpotTheCuties_Text_RibbonsLow"] = 0x286E9D, + ["TVSpotTheCuties_Text_RibbonsMid"] = 0x286EFC, + ["Text_Blueprint"] = 0x2A82F7, + ["Text_BookShelf"] = 0x2A820D, + ["Text_BootUpPC"] = 0x27265A, + ["Text_CantCut"] = 0x29077D, + ["Text_CantDive"] = 0x290BAA, + ["Text_CantSmash"] = 0x29087F, + ["Text_CantStrength"] = 0x2909D6, + ["Text_CantSurface"] = 0x290C28, + ["Text_CantWaterfall"] = 0x290A8E, + ["Text_EggHatchHuh"] = 0x292668, + ["Text_EmptyTrashCan"] = 0x2A82B3, + ["Text_FailSweetScent"] = 0x290CB7, + ["Text_LinkErrorPleaseReset"] = 0x27821C, + ["Text_MonUsedDive"] = 0x290C1A, + ["Text_MonUsedFieldMove"] = 0x290771, + ["Text_MonUsedStrength"] = 0x29098C, + ["Text_MonUsedWaterfall"] = 0x290AFC, + ["Text_PictureBookShelf"] = 0x2A81E5, + ["Text_PlayersMadeDifferentSelections"] = 0x278255, + ["Text_PokemonCenterBookShelf"] = 0x2A8232, + ["Text_RepelWoreOff"] = 0x2A4B33, + ["Text_ShopShelf"] = 0x2A82BF, + ["Text_SomeoneIsNotReadyToLink"] = 0x2781C7, + ["Text_StrengthActivated"] = 0x290A16, + ["Text_TerminateLinkConfirmation"] = 0x2783A8, + ["Text_TerminateLinkPleaseWait"] = 0x2783E9, + ["Text_ThisIsATestSignpostMsg"] = 0x2C840A, + ["Text_Vase"] = 0x2A8276, + ["Text_WantToCut"] = 0x29072E, + ["Text_WantToDive"] = 0x290BE8, + ["Text_WantToSmash"] = 0x29083A, + ["Text_WantToStrength"] = 0x29092C, + ["Text_WantToSurface"] = 0x290C6E, + ["Text_WantToWaterfall"] = 0x290AC3, + ["Text_WeHopeToSeeYouAgain"] = 0x272640, + ["Text_WouldYouLikeToMixRecords"] = 0x27260D, + ["TradeCenter_Text_TakeSeatStartTrade"] = 0x2784E2, + ["TrendWatcher_Text_FemaleTaughtMePhrase"] = 0x284C9B, + ["TrendWatcher_Text_FemaleTellMeBigger"] = 0x284DF5, + ["TrendWatcher_Text_Intro"] = 0x284A3E, + ["TrendWatcher_Text_MaleTaughtMePhrase"] = 0x284C55, + ["TrendWatcher_Text_MaleTellMeBigger"] = 0x284DB6, + ["TrendWatcher_Text_Outro"] = 0x284E34, + ["TrendWatcher_Text_PhraseWasHopeless"] = 0x284CE1, + ["VerdanturfTown_BattleTentLobby_Text_AchievedThreeWinStreak"] = 0x2C5731, + ["VerdanturfTown_BattleTentLobby_Text_ExplainBasicRules"] = 0x2C62B1, + ["VerdanturfTown_BattleTentLobby_Text_ExplainMoveRules"] = 0x2C64BA, + ["VerdanturfTown_BattleTentLobby_Text_ExplainNatureRules"] = 0x2C636D, + ["VerdanturfTown_BattleTentLobby_Text_ExplainUnderpoweredRules"] = 0x2C6612, + ["VerdanturfTown_BattleTentLobby_Text_ExplainVerdanturfTent"] = 0x2C5163, + ["VerdanturfTown_BattleTentLobby_Text_ExplainWhenInDangerRules"] = 0x2C671E, + ["VerdanturfTown_BattleTentLobby_Text_FeatWillBeRecorded"] = 0x2C5791, + ["VerdanturfTown_BattleTentLobby_Text_NotEnoughValidMonsLv50"] = 0x2C543D, + ["VerdanturfTown_BattleTentLobby_Text_NotEnoughValidMonsLvOpen"] = 0x2C5538, + ["VerdanturfTown_BattleTentLobby_Text_NowFollowMe"] = 0x2C56A2, + ["VerdanturfTown_BattleTentLobby_Text_PresentYouWithPrize"] = 0x2C57CD, + ["VerdanturfTown_BattleTentLobby_Text_ReadWhichHeading"] = 0x2C628E, + ["VerdanturfTown_BattleTentLobby_Text_ResultsWillBeRecorded"] = 0x2C56B8, + ["VerdanturfTown_BattleTentLobby_Text_ReturnFortified"] = 0x2C539A, + ["VerdanturfTown_BattleTentLobby_Text_RulesAreListed"] = 0x2C6878, + ["VerdanturfTown_BattleTentLobby_Text_RulesAreListed2"] = 0x2C6261, + ["VerdanturfTown_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C5662, + ["VerdanturfTown_BattleTentLobby_Text_SelectThreeMons"] = 0x2C5633, + ["VerdanturfTown_BattleTentLobby_Text_TakeChallenge"] = 0x2C5129, + ["VerdanturfTown_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C50C3, + ["VerdanturfTown_BattleTentLobby_Text_WhichLevelMode"] = 0x2C53DB, + ["gBerryFirmnessString_Hard"] = 0x5E9249, + ["gBerryFirmnessString_Soft"] = 0x5E9244, + ["gBerryFirmnessString_SuperHard"] = 0x5E9258, + ["gBerryFirmnessString_VeryHard"] = 0x5E924E, + ["gBerryFirmnessString_VerySoft"] = 0x5E923A, + ["gBirchDexRatingText_AreYouCurious"] = 0x2A5C9C, + ["gBirchDexRatingText_Cancel"] = 0x2A5CEB, + ["gBirchDexRatingText_DexCompleted"] = 0x2A6312, + ["gBirchDexRatingText_LessThan10"] = 0x2A5D6C, + ["gBirchDexRatingText_LessThan100"] = 0x2A5FB9, + ["gBirchDexRatingText_LessThan110"] = 0x2A6018, + ["gBirchDexRatingText_LessThan120"] = 0x2A6061, + ["gBirchDexRatingText_LessThan130"] = 0x2A609C, + ["gBirchDexRatingText_LessThan140"] = 0x2A60D5, + ["gBirchDexRatingText_LessThan150"] = 0x2A6124, + ["gBirchDexRatingText_LessThan160"] = 0x2A616F, + ["gBirchDexRatingText_LessThan170"] = 0x2A61D6, + ["gBirchDexRatingText_LessThan180"] = 0x2A623A, + ["gBirchDexRatingText_LessThan190"] = 0x2A6287, + ["gBirchDexRatingText_LessThan20"] = 0x2A5DAB, + ["gBirchDexRatingText_LessThan200"] = 0x2A62C9, + ["gBirchDexRatingText_LessThan30"] = 0x2A5DF1, + ["gBirchDexRatingText_LessThan40"] = 0x2A5E34, + ["gBirchDexRatingText_LessThan50"] = 0x2A5E83, + ["gBirchDexRatingText_LessThan60"] = 0x2A5EB9, + ["gBirchDexRatingText_LessThan70"] = 0x2A5EF4, + ["gBirchDexRatingText_LessThan80"] = 0x2A5F39, + ["gBirchDexRatingText_LessThan90"] = 0x2A5F82, + ["gBirchDexRatingText_OnANationwideBasis"] = 0x2A633D, + ["gBirchDexRatingText_SoYouveSeenAndCaught"] = 0x2A5D2C, + ["gContestBeauty"] = 0x27EA5B, + ["gContestCoolness"] = 0x27EA52, + ["gContestCuteness"] = 0x27EA62, + ["gContestHallPaintingCaption"] = 0x27EA0C, + ["gContestLink"] = 0x27EA4D, + ["gContestMoveTypeBeautyText"] = 0x27D4EF, + ["gContestMoveTypeCoolText"] = 0x27D4EA, + ["gContestMoveTypeCuteText"] = 0x27D4F6, + ["gContestMoveTypeSmartText"] = 0x27D4FB, + ["gContestMoveTypeToughText"] = 0x27D501, + ["gContestPaintingBeauty1"] = 0x27EAFA, + ["gContestPaintingBeauty2"] = 0x27EB27, + ["gContestPaintingBeauty3"] = 0x27EB47, + ["gContestPaintingContest"] = 0x27EA17, + ["gContestPaintingCool1"] = 0x27EA7F, + ["gContestPaintingCool2"] = 0x27EAA6, + ["gContestPaintingCool3"] = 0x27EACE, + ["gContestPaintingCute1"] = 0x27EB79, + ["gContestPaintingCute2"] = 0x27EB91, + ["gContestPaintingCute3"] = 0x27EBB2, + ["gContestPaintingSmart1"] = 0x27EBD7, + ["gContestPaintingSmart2"] = 0x27EC03, + ["gContestPaintingSmart3"] = 0x27EC28, + ["gContestPaintingTough1"] = 0x27EC4E, + ["gContestPaintingTough2"] = 0x27EC73, + ["gContestPaintingTough3"] = 0x27EC9A, + ["gContestRankHyper"] = 0x27EA36, + ["gContestRankMaster"] = 0x27EA41, + ["gContestRankNormal"] = 0x27EA1F, + ["gContestRankSuper"] = 0x27EA2B, + ["gContestSmartness"] = 0x27EA6B, + ["gContestToughness"] = 0x27EA75, + ["gDaycareText_DontLikeOther"] = 0x5EF81A, + ["gDaycareText_GetAlong"] = 0x5EF7FF, + ["gDaycareText_GetAlongVeryWell"] = 0x5EF7DA, + ["gDaycareText_PlayOther"] = 0x5EF846, + ["gEasyChatGroupName_Actions"] = 0x5EE9E6, + ["gEasyChatGroupName_Adjectives"] = 0x5EEA0B, + ["gEasyChatGroupName_Battle"] = 0x5EE9A4, + ["gEasyChatGroupName_Conditions"] = 0x5EE9DB, + ["gEasyChatGroupName_Endings"] = 0x5EE9CA, + ["gEasyChatGroupName_Events"] = 0x5EEA16, + ["gEasyChatGroupName_Feelings"] = 0x5EE9D2, + ["gEasyChatGroupName_Greetings"] = 0x5EE9AB, + ["gEasyChatGroupName_Hobbies"] = 0x5EE9F8, + ["gEasyChatGroupName_Lifestyle"] = 0x5EE9EE, + ["gEasyChatGroupName_Misc"] = 0x5EEA05, + ["gEasyChatGroupName_Move1"] = 0x5EEA1D, + ["gEasyChatGroupName_Move2"] = 0x5EEA24, + ["gEasyChatGroupName_People"] = 0x5EE9B5, + ["gEasyChatGroupName_Pokemon"] = 0x5EE98D, + ["gEasyChatGroupName_Pokemon2"] = 0x5EEA39, + ["gEasyChatGroupName_Speech"] = 0x5EE9C3, + ["gEasyChatGroupName_Status"] = 0x5EE99D, + ["gEasyChatGroupName_Time"] = 0x5EEA00, + ["gEasyChatGroupName_Trainer"] = 0x5EE995, + ["gEasyChatGroupName_TrendySaying"] = 0x5EEA2B, + ["gEasyChatGroupName_Voices"] = 0x5EE9BC, + ["gGiftRibbonDescriptionPart1_100StraightWin"] = 0x623C3C, + ["gGiftRibbonDescriptionPart1_2003GlobalCup"] = 0x623AB0, + ["gGiftRibbonDescriptionPart1_2003NationalTourney"] = 0x623A9A, + ["gGiftRibbonDescriptionPart1_2003RegionalTourney"] = 0x623A74, + ["gGiftRibbonDescriptionPart1_2004GlobalCup"] = 0x623B11, + ["gGiftRibbonDescriptionPart1_2004NationalTourney"] = 0x623AFB, + ["gGiftRibbonDescriptionPart1_2004RegionalTourney"] = 0x623AE5, + ["gGiftRibbonDescriptionPart1_2005GlobalCup"] = 0x623B4D, + ["gGiftRibbonDescriptionPart1_2005NationalTourney"] = 0x623B37, + ["gGiftRibbonDescriptionPart1_2005RegionalTourney"] = 0x623B21, + ["gGiftRibbonDescriptionPart1_AdvanceCup"] = 0x623B94, + ["gGiftRibbonDescriptionPart1_BlackironTower"] = 0x623C72, + ["gGiftRibbonDescriptionPart1_ClearingAllChallenges"] = 0x623C10, + ["gGiftRibbonDescriptionPart1_DarknessTower"] = 0x623C4D, + ["gGiftRibbonDescriptionPart1_DifficultyClearing"] = 0x623BE7, + ["gGiftRibbonDescriptionPart1_Evergreen"] = 0x623D40, + ["gGiftRibbonDescriptionPart1_FinalTower"] = 0x623C88, + ["gGiftRibbonDescriptionPart1_FullOfEnergy"] = 0x623D92, + ["gGiftRibbonDescriptionPart1_HardWorker"] = 0x623D68, + ["gGiftRibbonDescriptionPart1_LegendMaking"] = 0x623C9A, + ["gGiftRibbonDescriptionPart1_LotsOfFriends"] = 0x623D7B, + ["gGiftRibbonDescriptionPart1_LoveForPokemon"] = 0x623DD4, + ["gGiftRibbonDescriptionPart1_LovedPokemon"] = 0x623DA8, + ["gGiftRibbonDescriptionPart1_PokemonBattleCup"] = 0x623B5D, + ["gGiftRibbonDescriptionPart1_PokemonCenterNY"] = 0x623CE8, + ["gGiftRibbonDescriptionPart1_PokemonCenterNagoya"] = 0x623CD2, + ["gGiftRibbonDescriptionPart1_PokemonCenterOsaka"] = 0x623CBD, + ["gGiftRibbonDescriptionPart1_PokemonCenterTokyo"] = 0x623CA8, + ["gGiftRibbonDescriptionPart1_PokemonEvent"] = 0x623BC8, + ["gGiftRibbonDescriptionPart1_PokemonFestival"] = 0x623BD6, + ["gGiftRibbonDescriptionPart1_PokemonLeague"] = 0x623B85, + ["gGiftRibbonDescriptionPart1_PokemonTournament"] = 0x623BA0, + ["gGiftRibbonDescriptionPart1_RedTower"] = 0x623C62, + ["gGiftRibbonDescriptionPart1_SpecialHoliday"] = 0x623D51, + ["gGiftRibbonDescriptionPart1_SpringHolidays"] = 0x623D29, + ["gGiftRibbonDescriptionPart1_SummerHolidays"] = 0x623CFA, + ["gGiftRibbonDescriptionPart1_WinterHolidays"] = 0x623D12, + ["gGiftRibbonDescriptionPart2_Champion"] = 0x623A8A, + ["gGiftRibbonDescriptionPart2_ClearingAllChallenges"] = 0x623C23, + ["gGiftRibbonDescriptionPart2_Commemorative"] = 0x623BFB, + ["gGiftRibbonDescriptionPart2_EmptyString"] = 0x623D11, + ["gGiftRibbonDescriptionPart2_LoveForPokemon"] = 0x623DE6, + ["gGiftRibbonDescriptionPart2_LovedPokemon"] = 0x623DBF, + ["gGiftRibbonDescriptionPart2_Participation"] = 0x623B70, + ["gGiftRibbonDescriptionPart2_Participation2"] = 0x623BB3, + ["gGiftRibbonDescriptionPart2_RunnerUp"] = 0x623AC0, + ["gGiftRibbonDescriptionPart2_Semifinalist"] = 0x623AD1, + ["gJPText_AllowEReaderToLoadCard"] = 0x5EE0DC, + ["gJPText_AreYouSureYouWantToSpinTradeMon"] = 0x5EA1CD, + ["gJPText_BattlePoints"] = 0x5ECF9E, + ["gJPText_CardReadingHasBeenHalted"] = 0x5EE06B, + ["gJPText_Connecting"] = 0x5EE097, + ["gJPText_ConnectionComplete"] = 0x5EE0FA, + ["gJPText_ConnectionErrorCheckLink"] = 0x5EE0A3, + ["gJPText_ConnectionErrorTryAgain"] = 0x5EE0BF, + ["gJPText_DecideStop"] = 0x5EDFC9, + ["gJPText_GameFreak"] = 0x5EE8DC, + ["gJPText_LinkIsIncorrect"] = 0x5EE05C, + ["gJPText_MysteryGift"] = 0x5EDFBF, + ["gJPText_NewTrainerHasComeToHoenn"] = 0x5EE107, + ["gJPText_No1MSubCircuit"] = 0x5E8440, + ["gJPText_Player"] = 0x5EE846, + ["gJPText_PlayersXPokemon"] = 0x5ED2CF, + ["gJPText_PlayersXPokmonDoesNotExist"] = 0x5ED2D9, + ["gJPText_PleaseWaitAMoment"] = 0x5EE120, + ["gJPText_ReceiveMysteryGiftWithEReader"] = 0x5EDFD6, + ["gJPText_Sama"] = 0x5EE84C, + ["gJPText_SelectConnectAndPressA"] = 0x5EE035, + ["gJPText_SelectConnectFromEReaderMenu"] = 0x5EDFF5, + ["gJPText_SelectConnectWithGBA"] = 0x5EE014, + ["gJPText_UnableConnectWithEReader"] = 0x5EE080, + ["gJPText_WhatIsTheQuizAnswer"] = 0x5EC645, + ["gJPText_WriteErrorUnableToSaveData"] = 0x5EE12D, + ["gMenuText_Check"] = 0x5E8D20, + ["gMenuText_CheckTag"] = 0x5E8CD9, + ["gMenuText_Confirm"] = 0x5E8CE3, + ["gMenuText_Deselect"] = 0x5E8D2B, + ["gMenuText_Give"] = 0x5E8CD4, + ["gMenuText_Give2"] = 0x5E8D9B, + ["gMenuText_Register"] = 0x5E8CCB, + ["gMenuText_Show"] = 0x5E8CFE, + ["gMenuText_Toss"] = 0x5E8CC6, + ["gMenuText_Use"] = 0x5E8CC2, + ["gMenuText_Walk"] = 0x5E8CEB, + ["gPCText_Bag"] = 0x5EBAAF, + ["gPCText_Beach"] = 0x5EBB19, + ["gPCText_Cancel"] = 0x5EBA4E, + ["gPCText_Cave"] = 0x5EBB14, + ["gPCText_City"] = 0x5EBAEE, + ["gPCText_Crag"] = 0x5EBB02, + ["gPCText_Desert"] = 0x5EBAF3, + ["gPCText_Etcetera"] = 0x5EBAD6, + ["gPCText_Forest"] = 0x5EBAE7, + ["gPCText_Friends"] = 0x5EBADF, + ["gPCText_Give"] = 0x5EBAA3, + ["gPCText_Info"] = 0x5EBAB3, + ["gPCText_Jump"] = 0x5EBA8F, + ["gPCText_Machine"] = 0x5EBB47, + ["gPCText_Mark"] = 0x5EBA85, + ["gPCText_Move"] = 0x5EBA6A, + ["gPCText_Name"] = 0x5EBA8A, + ["gPCText_Place"] = 0x5EBA6F, + ["gPCText_Pokecenter"] = 0x5EBB3C, + ["gPCText_PolkaDot"] = 0x5EBB32, + ["gPCText_Release"] = 0x5EBA7D, + ["gPCText_River"] = 0x5EBB28, + ["gPCText_Savanna"] = 0x5EBAFA, + ["gPCText_Scenery1"] = 0x5EBAB8, + ["gPCText_Scenery2"] = 0x5EBAC2, + ["gPCText_Scenery3"] = 0x5EBACC, + ["gPCText_Seafloor"] = 0x5EBB1F, + ["gPCText_Shift"] = 0x5EBA64, + ["gPCText_Simple"] = 0x5EBB4F, + ["gPCText_Sky"] = 0x5EBB2E, + ["gPCText_Snow"] = 0x5EBB0F, + ["gPCText_Store"] = 0x5EBA55, + ["gPCText_Summary"] = 0x5EBA75, + ["gPCText_Switch"] = 0x5EBAA8, + ["gPCText_Take"] = 0x5EBA9E, + ["gPCText_Volcano"] = 0x5EBB07, + ["gPCText_Wallpaper"] = 0x5EBA94, + ["gPCText_Withdraw"] = 0x5EBA5B, + ["gPokeNewsTextBlendMaster_Ending"] = 0x28DF05, + ["gPokeNewsTextBlendMaster_Ongoing"] = 0x28DD9E, + ["gPokeNewsTextBlendMaster_Upcoming"] = 0x28DBEC, + ["gPokeNewsTextGameCorner_Ending"] = 0x28D768, + ["gPokeNewsTextGameCorner_Ongoing"] = 0x28D66F, + ["gPokeNewsTextGameCorner_Upcoming"] = 0x28D571, + ["gPokeNewsTextLilycove_Ending"] = 0x28DAC0, + ["gPokeNewsTextLilycove_Ongoing"] = 0x28D994, + ["gPokeNewsTextLilycove_Upcoming"] = 0x28D875, + ["gPokeNewsTextSlateport_Ending"] = 0x28D3F5, + ["gPokeNewsTextSlateport_Ongoing"] = 0x28D2A1, + ["gPokeNewsTextSlateport_Upcoming"] = 0x28D0F8, + ["gRibbonDescriptionPart1_Artist"] = 0x623950, + ["gRibbonDescriptionPart1_BeautyContest"] = 0x623876, + ["gRibbonDescriptionPart1_Champion"] = 0x62383C, + ["gRibbonDescriptionPart1_CoolContest"] = 0x623869, + ["gRibbonDescriptionPart1_CuteContest"] = 0x623885, + ["gRibbonDescriptionPart1_Effort"] = 0x623981, + ["gRibbonDescriptionPart1_SmartContest"] = 0x623892, + ["gRibbonDescriptionPart1_ToughContest"] = 0x6238A0, + ["gRibbonDescriptionPart1_Victory"] = 0x623923, + ["gRibbonDescriptionPart1_Winning"] = 0x6238FC, + ["gRibbonDescriptionPart2_Artist"] = 0x623968, + ["gRibbonDescriptionPart2_Champion"] = 0x623853, + ["gRibbonDescriptionPart2_Effort"] = 0x623994, + ["gRibbonDescriptionPart2_HyperRank"] = 0x6238D5, + ["gRibbonDescriptionPart2_MasterRank"] = 0x6238E8, + ["gRibbonDescriptionPart2_NormalRank"] = 0x6238AE, + ["gRibbonDescriptionPart2_SuperRank"] = 0x6238C2, + ["gRibbonDescriptionPart2_Victory"] = 0x62393B, + ["gRibbonDescriptionPart2_Winning"] = 0x62390E, + ["gStatusConditionString_BurnJpn"] = 0x31BC88, + ["gStatusConditionString_ConfusionJpn"] = 0x31BC98, + ["gStatusConditionString_IceJpn"] = 0x31BC90, + ["gStatusConditionString_LoveJpn"] = 0x31BCA0, + ["gStatusConditionString_ParalysisJpn"] = 0x31BC80, + ["gStatusConditionString_PoisonJpn"] = 0x31BC70, + ["gStatusConditionString_SleepJpn"] = 0x31BC78, + ["gTV3CheersForPokeblocksText00"] = 0x2818F4, + ["gTV3CheersForPokeblocksText01"] = 0x2819C7, + ["gTV3CheersForPokeblocksText02"] = 0x281A2F, + ["gTV3CheersForPokeblocksText03"] = 0x281AAC, + ["gTV3CheersForPokeblocksText04"] = 0x281B28, + ["gTV3CheersForPokeblocksText05"] = 0x281B93, + ["gTVBravoTrainerText00"] = 0x27F275, + ["gTVBravoTrainerText01"] = 0x27F304, + ["gTVBravoTrainerText02"] = 0x27F361, + ["gTVBravoTrainerText03"] = 0x27F3E4, + ["gTVBravoTrainerText04"] = 0x27F49F, + ["gTVBravoTrainerText05"] = 0x27F565, + ["gTVBravoTrainerText06"] = 0x27F624, + ["gTVBravoTrainerText07"] = 0x27F65C, + ["gTVBravoTrainerText08"] = 0x27F6E6, + ["gTVBreakingNewsText00"] = 0x2855BF, + ["gTVBreakingNewsText01"] = 0x2855D1, + ["gTVBreakingNewsText02"] = 0x28563C, + ["gTVBreakingNewsText03"] = 0x285682, + ["gTVBreakingNewsText04"] = 0x285705, + ["gTVBreakingNewsText05"] = 0x2857B0, + ["gTVBreakingNewsText06"] = 0x285824, + ["gTVBreakingNewsText07"] = 0x28586A, + ["gTVBreakingNewsText08"] = 0x28596F, + ["gTVBreakingNewsText09"] = 0x2859AC, + ["gTVBreakingNewsText10"] = 0x285A50, + ["gTVBreakingNewsText11"] = 0x285AB3, + ["gTVBreakingNewsText12"] = 0x2858D0, + ["gTVFanClubOpinionsText00"] = 0x280886, + ["gTVFanClubOpinionsText01"] = 0x280A44, + ["gTVFanClubOpinionsText02"] = 0x280AFC, + ["gTVFanClubOpinionsText03"] = 0x280BC4, + ["gTVFanClubOpinionsText04"] = 0x280C7A, + ["gTVFanClubText00"] = 0x280DEE, + ["gTVFanClubText01"] = 0x280F69, + ["gTVFanClubText02"] = 0x280F88, + ["gTVFanClubText03"] = 0x280FA9, + ["gTVFanClubText04"] = 0x280FD5, + ["gTVFanClubText05"] = 0x281040, + ["gTVFanClubText06"] = 0x281073, + ["gTVFanClubText07"] = 0x2810E7, + ["gTVFindThatGamerText00"] = 0x285240, + ["gTVFindThatGamerText01"] = 0x2852F4, + ["gTVFindThatGamerText02"] = 0x285463, + ["gTVFindThatGamerText03"] = 0x285500, + ["gTVHoennTreasureInvestigatorsText00"] = 0x284EDF, + ["gTVHoennTreasureInvestigatorsText01"] = 0x284FA1, + ["gTVHoennTreasureInvestigatorsText02"] = 0x2850F5, + ["gTVInSearchOfTrainersText00"] = 0x28B92C, + ["gTVInSearchOfTrainersText01"] = 0x28BA20, + ["gTVInSearchOfTrainersText02"] = 0x28BAC8, + ["gTVInSearchOfTrainersText03"] = 0x28BC18, + ["gTVInSearchOfTrainersText04"] = 0x28BD20, + ["gTVInSearchOfTrainersText05"] = 0x28BE01, + ["gTVInSearchOfTrainersText06"] = 0x28BEEE, + ["gTVInSearchOfTrainersText07"] = 0x28BF50, + ["gTVInSearchOfTrainersText08"] = 0x28C011, + ["gTVMassOutbreakText00"] = 0x28178A, + ["gTVNameRaterText00"] = 0x28218A, + ["gTVNameRaterText01"] = 0x28229E, + ["gTVNameRaterText02"] = 0x282317, + ["gTVNameRaterText03"] = 0x28239D, + ["gTVNameRaterText04"] = 0x282418, + ["gTVNameRaterText05"] = 0x2824A6, + ["gTVNameRaterText06"] = 0x282522, + ["gTVNameRaterText07"] = 0x2825A1, + ["gTVNameRaterText08"] = 0x282623, + ["gTVNameRaterText09"] = 0x2826B4, + ["gTVNameRaterText10"] = 0x28274D, + ["gTVNameRaterText11"] = 0x2827CB, + ["gTVNameRaterText12"] = 0x282849, + ["gTVNameRaterText13"] = 0x282883, + ["gTVNameRaterText14"] = 0x2828E4, + ["gTVNameRaterText15"] = 0x282912, + ["gTVNameRaterText16"] = 0x28296C, + ["gTVNameRaterText17"] = 0x282996, + ["gTVNameRaterText18"] = 0x282A36, + ["gTVPokemonAnglerText00"] = 0x282ACF, + ["gTVPokemonAnglerText01"] = 0x282D7C, + ["gTVPokemonBattleUpdateText00"] = 0x28ABCC, + ["gTVPokemonBattleUpdateText01"] = 0x28AC22, + ["gTVPokemonBattleUpdateText02"] = 0x28AC7E, + ["gTVPokemonBattleUpdateText03"] = 0x28ACB6, + ["gTVPokemonBattleUpdateText04"] = 0x28ACE3, + ["gTVPokemonBattleUpdateText05"] = 0x28AD80, + ["gTVPokemonBattleUpdateText06"] = 0x28ADE2, + ["gTVPokemonBattleUpdateText07"] = 0x28AE26, + ["gTVPokemonLotteryWinnerFlashReportText00"] = 0x2863CC, + ["gTVPokemonNewsBattleFrontierText00"] = 0x2877DE, + ["gTVPokemonNewsBattleFrontierText01"] = 0x28783D, + ["gTVPokemonNewsBattleFrontierText02"] = 0x2878B3, + ["gTVPokemonNewsBattleFrontierText03"] = 0x287929, + ["gTVPokemonNewsBattleFrontierText04"] = 0x28799E, + ["gTVPokemonNewsBattleFrontierText05"] = 0x287A18, + ["gTVPokemonNewsBattleFrontierText06"] = 0x287A97, + ["gTVPokemonNewsBattleFrontierText07"] = 0x287B16, + ["gTVPokemonNewsBattleFrontierText08"] = 0x287B8E, + ["gTVPokemonNewsBattleFrontierText09"] = 0x287C06, + ["gTVPokemonNewsBattleFrontierText10"] = 0x287C7D, + ["gTVPokemonNewsBattleFrontierText11"] = 0x287CF3, + ["gTVPokemonNewsBattleFrontierText12"] = 0x287D6A, + ["gTVPokemonNewsBattleFrontierText13"] = 0x287DE1, + ["gTVPokemonNewsBattleFrontierText14"] = 0x287E5B, + ["gTVPokemonNewsBattleFrontierText15"] = 0x287EBB, + ["gTVPokemonNewsBattleFrontierText16"] = 0x287F14, + ["gTVPokemonNewsBattleFrontierText17"] = 0x287F39, + ["gTVPokemonNewsBattleFrontierText18"] = 0x287F77, + ["gTVPokemonTodayFailedText00"] = 0x282F9B, + ["gTVPokemonTodayFailedText01"] = 0x28304D, + ["gTVPokemonTodayFailedText02"] = 0x283135, + ["gTVPokemonTodayFailedText03"] = 0x2831DF, + ["gTVPokemonTodayFailedText04"] = 0x283294, + ["gTVPokemonTodayFailedText05"] = 0x283337, + ["gTVPokemonTodayFailedText06"] = 0x2833C6, + ["gTVPokemonTodaySuccessfulText00"] = 0x2834A0, + ["gTVPokemonTodaySuccessfulText01"] = 0x283552, + ["gTVPokemonTodaySuccessfulText02"] = 0x2835AE, + ["gTVPokemonTodaySuccessfulText03"] = 0x28361F, + ["gTVPokemonTodaySuccessfulText04"] = 0x283685, + ["gTVPokemonTodaySuccessfulText05"] = 0x28370C, + ["gTVPokemonTodaySuccessfulText06"] = 0x2837C2, + ["gTVPokemonTodaySuccessfulText07"] = 0x283848, + ["gTVPokemonTodaySuccessfulText08"] = 0x2838C2, + ["gTVPokemonTodaySuccessfulText09"] = 0x28394A, + ["gTVPokemonTodaySuccessfulText10"] = 0x2839EA, + ["gTVPokemonTodaySuccessfulText11"] = 0x283A5F, + ["gTVRecentHappeningsText00"] = 0x28149E, + ["gTVRecentHappeningsText01"] = 0x2815AF, + ["gTVRecentHappeningsText02"] = 0x281636, + ["gTVRecentHappeningsText03"] = 0x281666, + ["gTVRecentHappeningsText04"] = 0x2816BA, + ["gTVRecentHappeningsText05"] = 0x2816EB, + ["gTVSafariFanClubText00"] = 0x289813, + ["gTVSafariFanClubText01"] = 0x28992F, + ["gTVSafariFanClubText02"] = 0x28999D, + ["gTVSafariFanClubText03"] = 0x2899DC, + ["gTVSafariFanClubText04"] = 0x289A29, + ["gTVSafariFanClubText05"] = 0x289AC2, + ["gTVSafariFanClubText06"] = 0x289B42, + ["gTVSafariFanClubText07"] = 0x289BC5, + ["gTVSafariFanClubText08"] = 0x289C3B, + ["gTVSafariFanClubText09"] = 0x289C99, + ["gTVSafariFanClubText10"] = 0x289D33, + ["gTVSecretBaseVisitText00"] = 0x285B27, + ["gTVSecretBaseVisitText01"] = 0x285BCA, + ["gTVSecretBaseVisitText02"] = 0x285C13, + ["gTVSecretBaseVisitText03"] = 0x285CFF, + ["gTVSecretBaseVisitText04"] = 0x285D5D, + ["gTVSecretBaseVisitText05"] = 0x285DE0, + ["gTVSecretBaseVisitText06"] = 0x285E3B, + ["gTVSecretBaseVisitText07"] = 0x285E9D, + ["gTVSecretBaseVisitText08"] = 0x285EFE, + ["gTVSecretBaseVisitText09"] = 0x285F46, + ["gTVSecretBaseVisitText10"] = 0x286049, + ["gTVSecretBaseVisitText11"] = 0x28613A, + ["gTVSecretBaseVisitText12"] = 0x286248, + ["gTVSecretBaseVisitText13"] = 0x286340, + ["gTVThePokemonBattleSeminarText00"] = 0x286616, + ["gTVThePokemonBattleSeminarText01"] = 0x2866B6, + ["gTVThePokemonBattleSeminarText02"] = 0x286700, + ["gTVThePokemonBattleSeminarText03"] = 0x28673B, + ["gTVThePokemonBattleSeminarText04"] = 0x286755, + ["gTVThePokemonBattleSeminarText05"] = 0x28676A, + ["gTVThePokemonBattleSeminarText06"] = 0x286777, + ["gTVTodaysRivalTrainerText00"] = 0x284641, + ["gTVTodaysRivalTrainerText01"] = 0x28485F, + ["gTVTodaysRivalTrainerText02"] = 0x28489A, + ["gTVTodaysRivalTrainerText03"] = 0x2848CC, + ["gTVTodaysRivalTrainerText04"] = 0x28490B, + ["gTVTodaysRivalTrainerText05"] = 0x28496B, + ["gTVTodaysRivalTrainerText06"] = 0x2849AE, + ["gTVTodaysRivalTrainerText07"] = 0x2846D2, + ["gTVTodaysRivalTrainerText08"] = 0x284738, + ["gTVTodaysRivalTrainerText09"] = 0x2847A5, + ["gTVTodaysRivalTrainerText10"] = 0x2847F7, + ["gTVTrainerFanClubSpecialText00"] = 0x281DFB, + ["gTVTrainerFanClubSpecialText01"] = 0x281F90, + ["gTVTrainerFanClubSpecialText02"] = 0x281FFA, + ["gTVTrainerFanClubSpecialText03"] = 0x282052, + ["gTVTrainerFanClubSpecialText04"] = 0x2820B2, + ["gTVTrainerFanClubSpecialText05"] = 0x28210E, + ["gTVTrainerFanClubText00"] = 0x286866, + ["gTVTrainerFanClubText01"] = 0x28695E, + ["gTVTrainerFanClubText02"] = 0x28698B, + ["gTVTrainerFanClubText03"] = 0x2869B0, + ["gTVTrainerFanClubText04"] = 0x2869ED, + ["gTVTrainerFanClubText05"] = 0x286A17, + ["gTVTrainerFanClubText06"] = 0x286A48, + ["gTVTrainerFanClubText07"] = 0x286A7C, + ["gTVTrainerFanClubText08"] = 0x286AAE, + ["gTVTrainerFanClubText09"] = 0x286AE6, + ["gTVTrainerFanClubText10"] = 0x286B23, + ["gTVTrainerFanClubText11"] = 0x286B4F, + ["gTVWhatsNo1InHoennTodayText00"] = 0x287FE0, + ["gTVWhatsNo1InHoennTodayText01"] = 0x2880C0, + ["gTVWhatsNo1InHoennTodayText02"] = 0x28815F, + ["gTVWhatsNo1InHoennTodayText03"] = 0x28821A, + ["gTVWhatsNo1InHoennTodayText04"] = 0x2882BE, + ["gTVWhatsNo1InHoennTodayText05"] = 0x288355, + ["gTVWhatsNo1InHoennTodayText06"] = 0x28842B, + ["gTVWhatsNo1InHoennTodayText07"] = 0x2884A5, + ["gTVWhatsNo1InHoennTodayText08"] = 0x28858B, + ["gTVWorldOfMastersText00"] = 0x2843BA, + ["gTVWorldOfMastersText01"] = 0x2844FD, + ["gTVWorldOfMastersText02"] = 0x28455B, + ["gTest_MissedTurn"] = 0x27E8CE, + ["gText_0Pts"] = 0x5ECB8D, + ["gText_100Pts"] = 0x5ECBD2, + ["gText_10BP"] = 0x5EB290, + ["gText_10F"] = 0x5EB685, + ["gText_10P30P50P50P"] = 0x5EDD6B, + ["gText_10Pts"] = 0x5ECB93, + ["gText_11F"] = 0x5EB689, + ["gText_12PoofForgotMove"] = 0x5E9BB8, + ["gText_15BP"] = 0x5EB295, + ["gText_1Colon"] = 0x5EDDAE, + ["gText_1DotBlueF700"] = 0x5ED7D4, + ["gText_1DotF700"] = 0x5ED7E0, + ["gText_1F"] = 0x5EB66A, + ["gText_1MinutePlus"] = 0x5EB5DA, + ["gText_1st"] = 0x5EDFA0, + ["gText_20Pts"] = 0x5ECB9A, + ["gText_234Players"] = 0x5EE473, + ["gText_2Colon"] = 0x5EDDB1, + ["gText_2F"] = 0x5EB66D, + ["gText_2nd"] = 0x5EDFA4, + ["gText_30Pts"] = 0x5ECBA1, + ["gText_3Colon"] = 0x5EDDB4, + ["gText_3Dashes"] = 0x5EE154, + ["gText_3F"] = 0x5EB670, + ["gText_3QuestionMarks"] = 0x27E8CA, + ["gText_3rd"] = 0x5EDFA8, + ["gText_40Pts"] = 0x5ECBA8, + ["gText_4Colon"] = 0x5EDDB7, + ["gText_4F"] = 0x5EB673, + ["gText_4Spaces"] = 0x5EED05, + ["gText_4th"] = 0x5EDFAC, + ["gText_500CoinsAndPrice"] = 0x5EB017, + ["gText_50CoinsAndPrice"] = 0x5EB002, + ["gText_50Pts"] = 0x5ECBAF, + ["gText_5BP"] = 0x5EB28A, + ["gText_5Colon"] = 0x5EDDBA, + ["gText_5F"] = 0x5EB676, + ["gText_5MarksPokemon"] = 0x5E86B8, + ["gText_60Pts"] = 0x5ECBB6, + ["gText_6F"] = 0x5EB679, + ["gText_70Pts"] = 0x5ECBBD, + ["gText_7F"] = 0x5EB67C, + ["gText_80Pts"] = 0x5ECBC4, + ["gText_8F"] = 0x5EB67F, + ["gText_90Pts"] = 0x5ECBCB, + ["gText_99TimesPlus"] = 0x5EB5CF, + ["gText_9F"] = 0x5EB682, + ["gText_ABtnRegistrationCounter"] = 0x5EE553, + ["gText_ABtnTitleScreen"] = 0x5EE572, + ["gText_AButton"] = 0x5E84DA, + ["gText_AButtonExit"] = 0x5E8588, + ["gText_AFormerChampion"] = 0x5CCD6E, + ["gText_Able"] = 0x5EA099, + ["gText_Able2"] = 0x5EA0B1, + ["gText_Able3"] = 0x5EA0BF, + ["gText_AbnormalWeatherEnded_Rain"] = 0x273656, + ["gText_AbnormalWeatherEnded_Sun"] = 0x273684, + ["gText_AboutE_Mode"] = 0x5EB234, + ["gText_AboutG_Mode"] = 0x5EB241, + ["gText_Accept"] = 0x5EECE9, + ["gText_AcceptThisPkmn"] = 0x5EECF0, + ["gText_AccessedLanettesPC"] = 0x2726D4, + ["gText_AccessedPlayersPC"] = 0x2726C2, + ["gText_AccessedSomeonesPC"] = 0x27268C, + ["gText_Accuracy2"] = 0x5EA384, + ["gText_Acro"] = 0x5EAE2C, + ["gText_AffectedByAppealInFront"] = 0x27D23F, + ["gText_AfterYouHaveReadTheQuiz"] = 0x5EC6B7, + ["gText_AllOutOfAppealTime"] = 0x27D597, + ["gText_AllTextBeingEditedWill"] = 0x5EC410, + ["gText_AlreadyHadCard"] = 0x5EF1B4, + ["gText_AlreadyHadNews"] = 0x5EF1D8, + ["gText_AlreadyHadStamp"] = 0x5EF200, + ["gText_AlreadyHoldingOne"] = 0x5EA073, + ["gText_AlreadySavedFile"] = 0x2C87D5, + ["gText_AlreadySavedFile_Chat"] = 0x5ED368, + ["gText_AlreadySurfing"] = 0x5E9EE9, + ["gText_Amy"] = 0x5EFA77, + ["gText_AnabelDefeatGold"] = 0x2C8480, + ["gText_AnabelDefeatSilver"] = 0x2C8458, + ["gText_AnabelWonGold"] = 0x2C846C, + ["gText_AnabelWonSilver"] = 0x2C843F, + ["gText_AndCreateAQuiz"] = 0x5EC339, + ["gText_AndFillOutTheQuestionnaire"] = 0x5EC3D2, + ["gText_AndImproveTheBardsSong"] = 0x5EC168, + ["gText_AndMakeAMessage"] = 0x5EC0BE, + ["gText_AndMakeAMessage2"] = 0x5EC135, + ["gText_AndMakeATrendySaying"] = 0x5EC252, + ["gText_AndMakeYourProfile"] = 0x5EC08A, + ["gText_Anna"] = 0x5EFA8A, + ["gText_AnnouncingPrizes"] = 0x5EDD9B, + ["gText_AnnouncingRankings"] = 0x5EDD86, + ["gText_AnnouncingResults"] = 0x5ED1B8, + ["gText_Answer"] = 0x5EFCCD, + ["gText_AnticipationRoseForUpcomingAppeals"] = 0x27DAB2, + ["gText_AnticipationSwelledForMonsAppealNext"] = 0x27D986, + ["gText_AnticipationSwelledForMonsAppealNext2"] = 0x27D805, + ["gText_AnythingElseICanHelp"] = 0x5E959B, + ["gText_ApostropheSBase"] = 0x5EA72F, + ["gText_Appeal"] = 0x5EA38D, + ["gText_AppealAsGoodAsOneBeforeIt"] = 0x27D13F, + ["gText_AppealAsGoodAsThoseBeforeIt"] = 0x27D112, + ["gText_AppealBestMoreCrowdExcited"] = 0x27D401, + ["gText_AppealBetterLaterItsPerformed"] = 0x27D16E, + ["gText_AppealComboWentOverExcellently"] = 0x27E38D, + ["gText_AppealComboWentOverVeryWell"] = 0x27E35B, + ["gText_AppealComboWentOverWell"] = 0x27E32E, + ["gText_AppealExcitesAudienceInAnyContest"] = 0x27D39A, + ["gText_AppealGreatIfPerformedFirst"] = 0x27D0BD, + ["gText_AppealGreatIfPerformedLast"] = 0x27D0E8, + ["gText_AppealNumButItCantParticipate"] = 0x27D531, + ["gText_AppealNumWhichMoveWillBePlayed"] = 0x27D507, + ["gText_AppealTriedToUnnerveWaitingMons"] = 0x27DD8E, + ["gText_AppealVariesDependingOnTiming"] = 0x27D1A1, + ["gText_AppealWorksWellIfConditionGood"] = 0x27D2A3, + ["gText_AppealsOfOthersCheapenedByHalf"] = 0x27DA03, + ["gText_ApprenticeChallenge0"] = 0x2B6EA5, + ["gText_ApprenticeChallenge1"] = 0x2B6EEC, + ["gText_ApprenticeChallenge10"] = 0x2B70CC, + ["gText_ApprenticeChallenge11"] = 0x2B710A, + ["gText_ApprenticeChallenge12"] = 0x2B714D, + ["gText_ApprenticeChallenge13"] = 0x2B7185, + ["gText_ApprenticeChallenge14"] = 0x2B71C1, + ["gText_ApprenticeChallenge15"] = 0x2B71F9, + ["gText_ApprenticeChallenge2"] = 0x2B6F16, + ["gText_ApprenticeChallenge3"] = 0x2B6F4C, + ["gText_ApprenticeChallenge4"] = 0x2B6F92, + ["gText_ApprenticeChallenge5"] = 0x2B6FC9, + ["gText_ApprenticeChallenge6"] = 0x2B700C, + ["gText_ApprenticeChallenge7"] = 0x2B703A, + ["gText_ApprenticeChallenge8"] = 0x2B706A, + ["gText_ApprenticeChallenge9"] = 0x2B709C, + ["gText_ApprenticeHoldNothing0"] = 0x2BA34E, + ["gText_ApprenticeHoldNothing1"] = 0x2BA58C, + ["gText_ApprenticeHoldNothing10"] = 0x2BBC1C, + ["gText_ApprenticeHoldNothing11"] = 0x2BBEE5, + ["gText_ApprenticeHoldNothing12"] = 0x2BC213, + ["gText_ApprenticeHoldNothing13"] = 0x2BC514, + ["gText_ApprenticeHoldNothing14"] = 0x2BC808, + ["gText_ApprenticeHoldNothing15"] = 0x2BCB75, + ["gText_ApprenticeHoldNothing2"] = 0x2BA742, + ["gText_ApprenticeHoldNothing3"] = 0x2BA96B, + ["gText_ApprenticeHoldNothing4"] = 0x2BAC43, + ["gText_ApprenticeHoldNothing5"] = 0x2BAF4E, + ["gText_ApprenticeHoldNothing6"] = 0x2BB18C, + ["gText_ApprenticeHoldNothing7"] = 0x2BB4C3, + ["gText_ApprenticeHoldNothing8"] = 0x2BB6E5, + ["gText_ApprenticeHoldNothing9"] = 0x2BB970, + ["gText_ApprenticeItemAlreadyRecommended0"] = 0x2BA448, + ["gText_ApprenticeItemAlreadyRecommended1"] = 0x2BA635, + ["gText_ApprenticeItemAlreadyRecommended10"] = 0x2BBD90, + ["gText_ApprenticeItemAlreadyRecommended11"] = 0x2BC024, + ["gText_ApprenticeItemAlreadyRecommended12"] = 0x2BC373, + ["gText_ApprenticeItemAlreadyRecommended13"] = 0x2BC666, + ["gText_ApprenticeItemAlreadyRecommended14"] = 0x2BC984, + ["gText_ApprenticeItemAlreadyRecommended15"] = 0x2BCCA4, + ["gText_ApprenticeItemAlreadyRecommended2"] = 0x2BA7D8, + ["gText_ApprenticeItemAlreadyRecommended3"] = 0x2BAA81, + ["gText_ApprenticeItemAlreadyRecommended4"] = 0x2BADB6, + ["gText_ApprenticeItemAlreadyRecommended5"] = 0x2BB05F, + ["gText_ApprenticeItemAlreadyRecommended6"] = 0x2BB2D9, + ["gText_ApprenticeItemAlreadyRecommended7"] = 0x2BB5E1, + ["gText_ApprenticeItemAlreadyRecommended8"] = 0x2BB84A, + ["gText_ApprenticeItemAlreadyRecommended9"] = 0x2BBA6C, + ["gText_ApprenticeLevelModeThanks0"] = 0x2B7423, + ["gText_ApprenticeLevelModeThanks1"] = 0x2B763F, + ["gText_ApprenticeLevelModeThanks10"] = 0x2B9204, + ["gText_ApprenticeLevelModeThanks11"] = 0x2B9564, + ["gText_ApprenticeLevelModeThanks12"] = 0x2B989A, + ["gText_ApprenticeLevelModeThanks13"] = 0x2B9B76, + ["gText_ApprenticeLevelModeThanks14"] = 0x2B9EAA, + ["gText_ApprenticeLevelModeThanks15"] = 0x2BA1F3, + ["gText_ApprenticeLevelModeThanks2"] = 0x2B7871, + ["gText_ApprenticeLevelModeThanks3"] = 0x2B7D18, + ["gText_ApprenticeLevelModeThanks4"] = 0x2B7FE8, + ["gText_ApprenticeLevelModeThanks5"] = 0x2B8356, + ["gText_ApprenticeLevelModeThanks6"] = 0x2B8656, + ["gText_ApprenticeLevelModeThanks7"] = 0x2B8957, + ["gText_ApprenticeLevelModeThanks8"] = 0x2B8C20, + ["gText_ApprenticeLevelModeThanks9"] = 0x2B8ED5, + ["gText_ApprenticeMonFirstThanks0"] = 0x2BCE64, + ["gText_ApprenticeMonFirstThanks1"] = 0x2BCF61, + ["gText_ApprenticeMonFirstThanks10"] = 0x2BDC6B, + ["gText_ApprenticeMonFirstThanks11"] = 0x2BDDEC, + ["gText_ApprenticeMonFirstThanks12"] = 0x2BDF4D, + ["gText_ApprenticeMonFirstThanks13"] = 0x2BE0FD, + ["gText_ApprenticeMonFirstThanks14"] = 0x2BE2A5, + ["gText_ApprenticeMonFirstThanks15"] = 0x2BE46C, + ["gText_ApprenticeMonFirstThanks2"] = 0x2BD03C, + ["gText_ApprenticeMonFirstThanks3"] = 0x2BD18A, + ["gText_ApprenticeMonFirstThanks4"] = 0x2BD325, + ["gText_ApprenticeMonFirstThanks5"] = 0x2BD493, + ["gText_ApprenticeMonFirstThanks6"] = 0x2BD609, + ["gText_ApprenticeMonFirstThanks7"] = 0x2BD797, + ["gText_ApprenticeMonFirstThanks8"] = 0x2BD8F5, + ["gText_ApprenticeMonFirstThanks9"] = 0x2BDAE1, + ["gText_ApprenticeMonThanks0"] = 0x2BE5F5, + ["gText_ApprenticeMonThanks1"] = 0x2BE71E, + ["gText_ApprenticeMonThanks10"] = 0x2BF3CF, + ["gText_ApprenticeMonThanks11"] = 0x2BF551, + ["gText_ApprenticeMonThanks12"] = 0x2BF6E5, + ["gText_ApprenticeMonThanks13"] = 0x2BF869, + ["gText_ApprenticeMonThanks14"] = 0x2BF9BA, + ["gText_ApprenticeMonThanks15"] = 0x2BFB4E, + ["gText_ApprenticeMonThanks2"] = 0x2BE7F8, + ["gText_ApprenticeMonThanks3"] = 0x2BE99C, + ["gText_ApprenticeMonThanks4"] = 0x2BEAE9, + ["gText_ApprenticeMonThanks5"] = 0x2BEC8E, + ["gText_ApprenticeMonThanks6"] = 0x2BEE29, + ["gText_ApprenticeMonThanks7"] = 0x2BEFE2, + ["gText_ApprenticeMonThanks8"] = 0x2BF11D, + ["gText_ApprenticeMonThanks9"] = 0x2BF268, + ["gText_ApprenticeMoveThanks0"] = 0x2BFCAE, + ["gText_ApprenticeMoveThanks1"] = 0x2BFDB1, + ["gText_ApprenticeMoveThanks10"] = 0x2C0982, + ["gText_ApprenticeMoveThanks11"] = 0x2C0AFD, + ["gText_ApprenticeMoveThanks12"] = 0x2C0C7D, + ["gText_ApprenticeMoveThanks13"] = 0x2C0DFE, + ["gText_ApprenticeMoveThanks14"] = 0x2C0F6D, + ["gText_ApprenticeMoveThanks15"] = 0x2C1122, + ["gText_ApprenticeMoveThanks2"] = 0x2BFEAD, + ["gText_ApprenticeMoveThanks3"] = 0x2C0032, + ["gText_ApprenticeMoveThanks4"] = 0x2C016E, + ["gText_ApprenticeMoveThanks5"] = 0x2C034C, + ["gText_ApprenticeMoveThanks6"] = 0x2C046E, + ["gText_ApprenticeMoveThanks7"] = 0x2C0598, + ["gText_ApprenticeMoveThanks8"] = 0x2C06D8, + ["gText_ApprenticeMoveThanks9"] = 0x2C0809, + ["gText_ApprenticePhrase"] = 0x5EC391, + ["gText_ApprenticePickWinSpeech0"] = 0x2C11D1, + ["gText_ApprenticePickWinSpeech1"] = 0x2C13AB, + ["gText_ApprenticePickWinSpeech10"] = 0x2C24B5, + ["gText_ApprenticePickWinSpeech11"] = 0x2C2707, + ["gText_ApprenticePickWinSpeech12"] = 0x2C28D6, + ["gText_ApprenticePickWinSpeech13"] = 0x2C2B50, + ["gText_ApprenticePickWinSpeech14"] = 0x2C2D67, + ["gText_ApprenticePickWinSpeech15"] = 0x2C2EF5, + ["gText_ApprenticePickWinSpeech2"] = 0x2C1501, + ["gText_ApprenticePickWinSpeech3"] = 0x2C165E, + ["gText_ApprenticePickWinSpeech4"] = 0x2C1862, + ["gText_ApprenticePickWinSpeech5"] = 0x2C1A76, + ["gText_ApprenticePickWinSpeech6"] = 0x2C1CF5, + ["gText_ApprenticePickWinSpeech7"] = 0x2C1EDC, + ["gText_ApprenticePickWinSpeech8"] = 0x2C20D1, + ["gText_ApprenticePickWinSpeech9"] = 0x2C231C, + ["gText_ApprenticePleaseTeach0"] = 0x2B7229, + ["gText_ApprenticePleaseTeach1"] = 0x2B74C1, + ["gText_ApprenticePleaseTeach10"] = 0x2B8F45, + ["gText_ApprenticePleaseTeach11"] = 0x2B929C, + ["gText_ApprenticePleaseTeach12"] = 0x2B95D8, + ["gText_ApprenticePleaseTeach13"] = 0x2B992D, + ["gText_ApprenticePleaseTeach14"] = 0x2B9BF2, + ["gText_ApprenticePleaseTeach15"] = 0x2B9F55, + ["gText_ApprenticePleaseTeach2"] = 0x2B76AC, + ["gText_ApprenticePleaseTeach3"] = 0x2B78D4, + ["gText_ApprenticePleaseTeach4"] = 0x2B7DD4, + ["gText_ApprenticePleaseTeach5"] = 0x2B8087, + ["gText_ApprenticePleaseTeach6"] = 0x2B83CE, + ["gText_ApprenticePleaseTeach7"] = 0x2B86EA, + ["gText_ApprenticePleaseTeach8"] = 0x2B89C6, + ["gText_ApprenticePleaseTeach9"] = 0x2B8CAA, + ["gText_ApprenticeRejectTeaching0"] = 0x2B731C, + ["gText_ApprenticeRejectTeaching1"] = 0x2B756F, + ["gText_ApprenticeRejectTeaching10"] = 0x2B905F, + ["gText_ApprenticeRejectTeaching11"] = 0x2B9438, + ["gText_ApprenticeRejectTeaching12"] = 0x2B9763, + ["gText_ApprenticeRejectTeaching13"] = 0x2B9A84, + ["gText_ApprenticeRejectTeaching14"] = 0x2B9D83, + ["gText_ApprenticeRejectTeaching15"] = 0x2BA084, + ["gText_ApprenticeRejectTeaching2"] = 0x2B7772, + ["gText_ApprenticeRejectTeaching3"] = 0x2B7B1A, + ["gText_ApprenticeRejectTeaching4"] = 0x2B7EE5, + ["gText_ApprenticeRejectTeaching5"] = 0x2B822B, + ["gText_ApprenticeRejectTeaching6"] = 0x2B84FC, + ["gText_ApprenticeRejectTeaching7"] = 0x2B87DA, + ["gText_ApprenticeRejectTeaching8"] = 0x2B8ACF, + ["gText_ApprenticeRejectTeaching9"] = 0x2B8DD3, + ["gText_ApprenticeThanksHeldItem0"] = 0x2BA3D2, + ["gText_ApprenticeThanksHeldItem1"] = 0x2BA5F3, + ["gText_ApprenticeThanksHeldItem10"] = 0x2BBCF6, + ["gText_ApprenticeThanksHeldItem11"] = 0x2BBFA4, + ["gText_ApprenticeThanksHeldItem12"] = 0x2BC2DD, + ["gText_ApprenticeThanksHeldItem13"] = 0x2BC5CE, + ["gText_ApprenticeThanksHeldItem14"] = 0x2BC8EA, + ["gText_ApprenticeThanksHeldItem15"] = 0x2BCBFC, + ["gText_ApprenticeThanksHeldItem2"] = 0x2BA78F, + ["gText_ApprenticeThanksHeldItem3"] = 0x2BAA1B, + ["gText_ApprenticeThanksHeldItem4"] = 0x2BAD17, + ["gText_ApprenticeThanksHeldItem5"] = 0x2BAFDB, + ["gText_ApprenticeThanksHeldItem6"] = 0x2BB242, + ["gText_ApprenticeThanksHeldItem7"] = 0x2BB575, + ["gText_ApprenticeThanksHeldItem8"] = 0x2BB7A2, + ["gText_ApprenticeThanksHeldItem9"] = 0x2BBA05, + ["gText_ApprenticeThanksNoHeldItem0"] = 0x2BA380, + ["gText_ApprenticeThanksNoHeldItem1"] = 0x2BA5BF, + ["gText_ApprenticeThanksNoHeldItem10"] = 0x2BBC4B, + ["gText_ApprenticeThanksNoHeldItem11"] = 0x2BBF25, + ["gText_ApprenticeThanksNoHeldItem12"] = 0x2BC247, + ["gText_ApprenticeThanksNoHeldItem13"] = 0x2BC555, + ["gText_ApprenticeThanksNoHeldItem14"] = 0x2BC84D, + ["gText_ApprenticeThanksNoHeldItem15"] = 0x2BCBA6, + ["gText_ApprenticeThanksNoHeldItem2"] = 0x2BA770, + ["gText_ApprenticeThanksNoHeldItem3"] = 0x2BA9B7, + ["gText_ApprenticeThanksNoHeldItem4"] = 0x2BAC78, + ["gText_ApprenticeThanksNoHeldItem5"] = 0x2BAF8F, + ["gText_ApprenticeThanksNoHeldItem6"] = 0x2BB1CE, + ["gText_ApprenticeThanksNoHeldItem7"] = 0x2BB4FB, + ["gText_ApprenticeThanksNoHeldItem8"] = 0x2BB72C, + ["gText_ApprenticeThanksNoHeldItem9"] = 0x2BB9AE, + ["gText_ApprenticeWhatHeldItem0"] = 0x2BA2A3, + ["gText_ApprenticeWhatHeldItem1"] = 0x2BA4D3, + ["gText_ApprenticeWhatHeldItem10"] = 0x2BBB01, + ["gText_ApprenticeWhatHeldItem11"] = 0x2BBE0B, + ["gText_ApprenticeWhatHeldItem12"] = 0x2BC0C8, + ["gText_ApprenticeWhatHeldItem13"] = 0x2BC40E, + ["gText_ApprenticeWhatHeldItem14"] = 0x2BC714, + ["gText_ApprenticeWhatHeldItem15"] = 0x2BCA4D, + ["gText_ApprenticeWhatHeldItem2"] = 0x2BA6E6, + ["gText_ApprenticeWhatHeldItem3"] = 0x2BA867, + ["gText_ApprenticeWhatHeldItem4"] = 0x2BAB22, + ["gText_ApprenticeWhatHeldItem5"] = 0x2BAE36, + ["gText_ApprenticeWhatHeldItem6"] = 0x2BB0D4, + ["gText_ApprenticeWhatHeldItem7"] = 0x2BB370, + ["gText_ApprenticeWhatHeldItem8"] = 0x2BB656, + ["gText_ApprenticeWhatHeldItem9"] = 0x2BB8CD, + ["gText_ApprenticeWhichLevelMode0"] = 0x2B735B, + ["gText_ApprenticeWhichLevelMode1"] = 0x2B75B2, + ["gText_ApprenticeWhichLevelMode10"] = 0x2B910E, + ["gText_ApprenticeWhichLevelMode11"] = 0x2B9488, + ["gText_ApprenticeWhichLevelMode12"] = 0x2B97E5, + ["gText_ApprenticeWhichLevelMode13"] = 0x2B9AB9, + ["gText_ApprenticeWhichLevelMode14"] = 0x2B9DF9, + ["gText_ApprenticeWhichLevelMode15"] = 0x2BA11D, + ["gText_ApprenticeWhichLevelMode2"] = 0x2B77CE, + ["gText_ApprenticeWhichLevelMode3"] = 0x2B7C13, + ["gText_ApprenticeWhichLevelMode4"] = 0x2B7F35, + ["gText_ApprenticeWhichLevelMode5"] = 0x2B8286, + ["gText_ApprenticeWhichLevelMode6"] = 0x2B8559, + ["gText_ApprenticeWhichLevelMode7"] = 0x2B887C, + ["gText_ApprenticeWhichLevelMode8"] = 0x2B8B66, + ["gText_ApprenticeWhichLevelMode9"] = 0x2B8E24, + ["gText_ApprenticeWhichMon0"] = 0x2BE50D, + ["gText_ApprenticeWhichMon1"] = 0x2BE679, + ["gText_ApprenticeWhichMon10"] = 0x2BF2D1, + ["gText_ApprenticeWhichMon11"] = 0x2BF46A, + ["gText_ApprenticeWhichMon12"] = 0x2BF5C3, + ["gText_ApprenticeWhichMon13"] = 0x2BF773, + ["gText_ApprenticeWhichMon14"] = 0x2BF8DD, + ["gText_ApprenticeWhichMon15"] = 0x2BFA5A, + ["gText_ApprenticeWhichMon2"] = 0x2BE762, + ["gText_ApprenticeWhichMon3"] = 0x2BE850, + ["gText_ApprenticeWhichMon4"] = 0x2BEA1B, + ["gText_ApprenticeWhichMon5"] = 0x2BEB72, + ["gText_ApprenticeWhichMon6"] = 0x2BED16, + ["gText_ApprenticeWhichMon7"] = 0x2BEEB4, + ["gText_ApprenticeWhichMon8"] = 0x2BF04E, + ["gText_ApprenticeWhichMon9"] = 0x2BF1A8, + ["gText_ApprenticeWhichMonFirst0"] = 0x2BCD68, + ["gText_ApprenticeWhichMonFirst1"] = 0x2BCEF2, + ["gText_ApprenticeWhichMonFirst10"] = 0x2BDB4E, + ["gText_ApprenticeWhichMonFirst11"] = 0x2BDD0D, + ["gText_ApprenticeWhichMonFirst12"] = 0x2BDE68, + ["gText_ApprenticeWhichMonFirst13"] = 0x2BDFD8, + ["gText_ApprenticeWhichMonFirst14"] = 0x2BE189, + ["gText_ApprenticeWhichMonFirst15"] = 0x2BE33E, + ["gText_ApprenticeWhichMonFirst2"] = 0x2BCFA1, + ["gText_ApprenticeWhichMonFirst3"] = 0x2BD06D, + ["gText_ApprenticeWhichMonFirst4"] = 0x2BD222, + ["gText_ApprenticeWhichMonFirst5"] = 0x2BD3B1, + ["gText_ApprenticeWhichMonFirst6"] = 0x2BD51C, + ["gText_ApprenticeWhichMonFirst7"] = 0x2BD697, + ["gText_ApprenticeWhichMonFirst8"] = 0x2BD806, + ["gText_ApprenticeWhichMonFirst9"] = 0x2BD9BE, + ["gText_ApprenticeWhichMove0"] = 0x2BFBF2, + ["gText_ApprenticeWhichMove1"] = 0x2BFD26, + ["gText_ApprenticeWhichMove10"] = 0x2C086E, + ["gText_ApprenticeWhichMove11"] = 0x2C0A1D, + ["gText_ApprenticeWhichMove12"] = 0x2C0B6F, + ["gText_ApprenticeWhichMove13"] = 0x2C0D0B, + ["gText_ApprenticeWhichMove14"] = 0x2C0E71, + ["gText_ApprenticeWhichMove15"] = 0x2C1003, + ["gText_ApprenticeWhichMove2"] = 0x2BFE24, + ["gText_ApprenticeWhichMove3"] = 0x2BFF0A, + ["gText_ApprenticeWhichMove4"] = 0x2C0090, + ["gText_ApprenticeWhichMove5"] = 0x2C01F7, + ["gText_ApprenticeWhichMove6"] = 0x2C03CA, + ["gText_ApprenticeWhichMove7"] = 0x2C04F9, + ["gText_ApprenticeWhichMove8"] = 0x2C0602, + ["gText_ApprenticeWhichMove9"] = 0x2C074A, + ["gText_ApprenticeWinSpeechThanks0"] = 0x2C12D5, + ["gText_ApprenticeWinSpeechThanks1"] = 0x2C1444, + ["gText_ApprenticeWinSpeechThanks10"] = 0x2C25B1, + ["gText_ApprenticeWinSpeechThanks11"] = 0x2C27D4, + ["gText_ApprenticeWinSpeechThanks12"] = 0x2C2A0B, + ["gText_ApprenticeWinSpeechThanks13"] = 0x2C2C77, + ["gText_ApprenticeWinSpeechThanks14"] = 0x2C2E41, + ["gText_ApprenticeWinSpeechThanks15"] = 0x2C3023, + ["gText_ApprenticeWinSpeechThanks2"] = 0x2C15B6, + ["gText_ApprenticeWinSpeechThanks3"] = 0x2C174F, + ["gText_ApprenticeWinSpeechThanks4"] = 0x2C19A0, + ["gText_ApprenticeWinSpeechThanks5"] = 0x2C1C16, + ["gText_ApprenticeWinSpeechThanks6"] = 0x2C1DC1, + ["gText_ApprenticeWinSpeechThanks7"] = 0x2C1FEC, + ["gText_ApprenticeWinSpeechThanks8"] = 0x2C21FF, + ["gText_ApprenticeWinSpeechThanks9"] = 0x2C2407, + ["gText_ApprenticesPhrase"] = 0x5EC650, + ["gText_Are"] = 0x5CCB88, + ["gText_Are2"] = 0x5CCB8C, + ["gText_AtTheBattlesStart"] = 0x5EC5B2, + ["gText_Attack"] = 0x5EEA4E, + ["gText_Attack3"] = 0x5EA33C, + ["gText_AttractedCrowdsAttention"] = 0x27E7BA, + ["gText_AvoidStartledByOthers"] = 0x27CC5D, + ["gText_AvoidStartledByOthersLittle"] = 0x27CC81, + ["gText_AvoidStartledByOthersOnce"] = 0x27CC34, + ["gText_AwaitingLinkup"] = 0x278131, + ["gText_AwesomeWonF701F700"] = 0x5EDED3, + ["gText_B1F"] = 0x5EB68D, + ["gText_B2F"] = 0x5EB691, + ["gText_B3F"] = 0x5EB695, + ["gText_B4F"] = 0x5EB699, + ["gText_BButton"] = 0x5E84E3, + ["gText_BDot"] = 0x5ED1B5, + ["gText_BP"] = 0x5EB6AD, + ["gText_BackupMemoryDamaged"] = 0x5EC86F, + ["gText_Bad"] = 0x5ECB3A, + ["gText_BadConditionResultedInWeakAppeal"] = 0x27E68B, + ["gText_BadEgg"] = 0x5CCB90, + ["gText_Badges"] = 0x5E84D3, + ["gText_BadlyStartleAppealed"] = 0x27CDCB, + ["gText_BadlyStartleBeautyAppeals"] = 0x27CF4D, + ["gText_BadlyStartleCoolAppeals"] = 0x27CF1E, + ["gText_BadlyStartleCuteAppeals"] = 0x27CF7E, + ["gText_BadlyStartleFrontMon"] = 0x27CDA6, + ["gText_BadlyStartleMonsGoodCondition"] = 0x27D093, + ["gText_BadlyStartleSmartAppeals"] = 0x27CFAD, + ["gText_BadlyStartleToughAppeals"] = 0x27CFDD, + ["gText_BadlyStartlesMonsGoodAppeals"] = 0x27D3CE, + ["gText_BagFullCouldNotRemoveItem"] = 0x5E9A69, + ["gText_BagIsFull"] = 0x5EACB5, + ["gText_BagIsFull2"] = 0x5EB9EE, + ["gText_BasicRules"] = 0x5EB4AD, + ["gText_BatteryRunDry"] = 0x5E8453, + ["gText_Battle"] = 0x5ED423, + ["gText_BattleArena"] = 0x5ED13A, + ["gText_BattleArena2"] = 0x5EDAD8, + ["gText_BattleArenaDesc"] = 0x5EDBC2, + ["gText_BattleArenaGutsSymbol"] = 0x5EDA21, + ["gText_BattleBag"] = 0x5EB212, + ["gText_BattleBag2"] = 0x5EB563, + ["gText_BattleBasics"] = 0x5EB4EB, + ["gText_BattleChoiceResults"] = 0x5EE261, + ["gText_BattleDome"] = 0x5ED111, + ["gText_BattleDome2"] = 0x5EDABE, + ["gText_BattleDomeDesc"] = 0x5EDB4E, + ["gText_BattleDomeTacticsSymbol"] = 0x5ED9E5, + ["gText_BattleFactory"] = 0x5ED12B, + ["gText_BattleFactory2"] = 0x5EDAE5, + ["gText_BattleFactoryDesc"] = 0x5EDC00, + ["gText_BattleFactoryKnowledgeSymbol"] = 0x5EDA3C, + ["gText_BattleFrontier"] = 0x5EB06E, + ["gText_BattleMenu"] = 0x5CCA3A, + ["gText_BattleMon"] = 0x5EB43A, + ["gText_BattleMoves"] = 0x5EA3EB, + ["gText_BattlePalace"] = 0x5ED11D, + ["gText_BattlePalace2"] = 0x5EDACA, + ["gText_BattlePalaceDesc"] = 0x5EDB8B, + ["gText_BattlePalaceSpiritsSymbol"] = 0x5EDA02, + ["gText_BattlePike"] = 0x5ED147, + ["gText_BattlePike2"] = 0x5EDAF4, + ["gText_BattlePikeDesc"] = 0x5EDC45, + ["gText_BattlePikeLuckSymbol"] = 0x5EDA5E, + ["gText_BattlePoints"] = 0x5ED91C, + ["gText_BattlePokemon"] = 0x5EB597, + ["gText_BattlePts"] = 0x5EB424, + ["gText_BattlePtsWon"] = 0x5ED0B6, + ["gText_BattlePyramid"] = 0x5ED153, + ["gText_BattlePyramid2"] = 0x5EDB00, + ["gText_BattlePyramidBraveSymbol"] = 0x5EDA78, + ["gText_BattlePyramidDesc"] = 0x5EDC84, + ["gText_BattlePyramidFloor"] = 0x5EE716, + ["gText_BattleQuestResults"] = 0x5EE2D8, + ["gText_BattleRecord"] = 0x5ED90E, + ["gText_BattleRules"] = 0x5EB45C, + ["gText_BattleSalon"] = 0x5EB444, + ["gText_BattleScene"] = 0x5EE59B, + ["gText_BattleSceneOff"] = 0x5EE5FD, + ["gText_BattleSceneOn"] = 0x5EE5F4, + ["gText_BattleStyle"] = 0x5EE5A8, + ["gText_BattleStyleSet"] = 0x5EE613, + ["gText_BattleStyleShift"] = 0x5EE607, + ["gText_BattleSwapDoubleResults"] = 0x5EE2B8, + ["gText_BattleSwapSingleResults"] = 0x5EE298, + ["gText_BattleSwitchWhich"] = 0x5CCACA, + ["gText_BattleSwitchWhich2"] = 0x5CCAE0, + ["gText_BattleSwitchWhich3"] = 0x5CCAE9, + ["gText_BattleSwitchWhich4"] = 0x5CCAEB, + ["gText_BattleSwitchWhich5"] = 0x5CCAEF, + ["gText_BattleTourney"] = 0x5CCCEE, + ["gText_BattleTower"] = 0x5ED0D3, + ["gText_BattleTower2"] = 0x5ED104, + ["gText_BattleTower3"] = 0x5EDAB1, + ["gText_BattleTowerAbilitySymbol"] = 0x5ED9C7, + ["gText_BattleTowerDesc"] = 0x5EDB0F, + ["gText_BattleTrainers"] = 0x5EB5A6, + ["gText_BattleWallyName"] = 0x5CCB98, + ["gText_BattleWasRefused"] = 0x5EFC0C, + ["gText_BattleYesNoChoice"] = 0x5CCABB, + ["gText_BeDeletedThatOkay"] = 0x5EC42F, + ["gText_Beauty"] = 0x5EB0ED, + ["gText_Beauty2"] = 0x5EB124, + ["gText_Beauty3"] = 0x5EC7B9, + ["gText_BeautyContest"] = 0x5EADB5, + ["gText_BeautyMove"] = 0x27E89E, + ["gText_BecameLessAwareOfOtherMons"] = 0x27DC45, + ["gText_BecameMoreConsciousOfOtherMons"] = 0x27DB75, + ["gText_BecameObliviousToOtherMons"] = 0x27DC0F, + ["gText_BecomeLeader"] = 0x5EB3C6, + ["gText_Berries"] = 0x5EFCE5, + ["gText_Berries2"] = 0x5EB161, + ["gText_BerriesInRowFivePlayers"] = 0x5EDD1E, + ["gText_BerriesPicked"] = 0x5EDD02, + ["gText_BerriesPocket"] = 0x5E91E9, + ["gText_Berry"] = 0x5EFCDF, + ["gText_Berry2"] = 0x5E8DA4, + ["gText_BerryCrush"] = 0x5ED036, + ["gText_BerryCrush2"] = 0x5ED8D0, + ["gText_BerryCrush3"] = 0x5EB397, + ["gText_BerryPickingRecords"] = 0x5EDCE5, + ["gText_BerryPickingResults"] = 0x5EDD49, + ["gText_BerryTag"] = 0x5E926B, + ["gText_BestScore"] = 0x5EDD12, + ["gText_BestScore2"] = 0x5EDEB2, + ["gText_BigGirl"] = 0x5EB601, + ["gText_BigGuy"] = 0x5EB5F9, + ["gText_BigHoleInTheWall"] = 0x27304E, + ["gText_BirchBoy"] = 0x5E858F, + ["gText_BirchGirl"] = 0x5E8593, + ["gText_BirchInTrouble"] = 0x5E8C53, + ["gText_Birch_AndYouAre"] = 0x2C8BD0, + ["gText_Birch_AreYouReady"] = 0x2C8C7A, + ["gText_Birch_BoyOrGirl"] = 0x2C8BDD, + ["gText_Birch_MainSpeech"] = 0x2C8A1F, + ["gText_Birch_Pokemon"] = 0x2C89FB, + ["gText_Birch_SoItsPlayer"] = 0x2C8C1C, + ["gText_Birch_Welcome"] = 0x2C897B, + ["gText_Birch_WhatsYourName"] = 0x2C8BFF, + ["gText_Birch_YourePlayer"] = 0x2C8C2A, + ["gText_BirthIsland"] = 0x5EB2BD, + ["gText_Bitter"] = 0x5E932D, + ["gText_Bitter2"] = 0x5ECB58, + ["gText_BlackFlute"] = 0x5EB645, + ["gText_BlackFlute2"] = 0x5EAF93, + ["gText_BlackPokeblock"] = 0x5E92FA, + ["gText_Blank"] = 0x5EB278, + ["gText_BlastoiseDoll256BP"] = 0x5ECCFC, + ["gText_BlenderMaxSpeedRecord"] = 0x5EE44F, + ["gText_Blue"] = 0x5EE14F, + ["gText_BlueFlute"] = 0x5EB617, + ["gText_BlueFlute2"] = 0x5EAF65, + ["gText_BluePokeblock"] = 0x5E927F, + ["gText_BlueShard"] = 0x5EB057, + ["gText_BlueTent"] = 0x5EB2A3, + ["gText_Body"] = 0x5CCDC6, + ["gText_BodySlam48BP"] = 0x5ECE60, + ["gText_BoostPP"] = 0x5E9F2A, + ["gText_BootedUpHM"] = 0x5E9047, + ["gText_BootedUpTM"] = 0x5E9037, + ["gText_Box"] = 0x5EBCC1, + ["gText_BoxFull"] = 0x5E9125, + ["gText_BoxIsFull2"] = 0x5EB88E, + ["gText_BoxName"] = 0x5EEAC9, + ["gText_BrandonDefeatGold"] = 0x2C877B, + ["gText_BrandonDefeatSilver"] = 0x2C86FE, + ["gText_BrandonWonGold"] = 0x2C8739, + ["gText_BrandonWonSilver"] = 0x2C86C3, + ["gText_Brawly"] = 0x5EAD1B, + ["gText_BrawlyMatchCallDesc"] = 0x5EFB6F, + ["gText_BrightPowder64BP"] = 0x5ECDA6, + ["gText_Brn"] = 0x5EAE3D, + ["gText_BronzeCard"] = 0x5EE670, + ["gText_BrownPokeblock"] = 0x5E92C8, + ["gText_BunnyHops"] = 0x5EAF24, + ["gText_Burn"] = 0x5CCB57, + ["gText_ButAppealWasJammed"] = 0x27D5C1, + ["gText_ButItFailedToMakeAnyoneNervous"] = 0x27E606, + ["gText_ButItFailedToMakeTargetNervous"] = 0x27E5D0, + ["gText_ButItMessedUp"] = 0x27D600, + ["gText_ButItMessedUp2"] = 0x27E5B2, + ["gText_ButItWasIgnored"] = 0x27E638, + ["gText_ButtonMode"] = 0x5EE5C8, + ["gText_ButtonTypeLEqualsA"] = 0x5EE65D, + ["gText_ButtonTypeLR"] = 0x5EE654, + ["gText_ButtonTypeNormal"] = 0x5EE647, + ["gText_ByeBye"] = 0x5ED44A, + ["gText_ByeByePkmn"] = 0x5EB8C6, + ["gText_ByeByeVar1"] = 0x5EE959, + ["gText_CDot"] = 0x5ED1B2, + ["gText_Calcium1000"] = 0x5EB738, + ["gText_Calcium1BP"] = 0x5ECD21, + ["gText_Call"] = 0x5EC017, + ["gText_Call2"] = 0x5EBE84, + ["gText_CallCantBeMadeHere"] = 0x5EEB0B, + ["gText_CallRegisteredTrainer"] = 0x5EBD01, + ["gText_CanIHelpWithAnythingElse"] = 0x5E95C7, + ["gText_Cancel"] = 0x5E8CF0, + ["gText_Cancel2"] = 0x5E8CF7, + ["gText_Cancel3"] = 0x5EECDD, + ["gText_Cancel4"] = 0x5EFAE8, + ["gText_Cancel5"] = 0x5EFCBE, + ["gText_Cancel6"] = 0x5EC022, + ["gText_Cancel7"] = 0x5E8D04, + ["gText_CancelBattle"] = 0x5EA126, + ["gText_CancelChallenge"] = 0x5EA155, + ["gText_CancelDecorating"] = 0x5EA8BF, + ["gText_CancelParticipation"] = 0x5EA110, + ["gText_CancelSelection"] = 0x5EC594, + ["gText_CantAcceptCardFromTrainer"] = 0x5EF280, + ["gText_CantAcceptNewsFromTrainer"] = 0x5EF2B4, + ["gText_CantBePlacedHere"] = 0x5EA8A6, + ["gText_CantBeUsedOnPkmn"] = 0x5E9712, + ["gText_CantBuyKeyItem"] = 0x5E960F, + ["gText_CantCallOpponentHere"] = 0x5EBE8E, + ["gText_CantDismountBike"] = 0x5E8F6E, + ["gText_CantHoldAnyMore"] = 0x5EDDE2, + ["gText_CantHoldMore"] = 0x5EDF11, + ["gText_CantMakeAnyMoreMoves"] = 0x27D67E, + ["gText_CantParticipateInAppealsAnyMore"] = 0x27DA5B, + ["gText_CantPlaceInRoom"] = 0x5EA9AE, + ["gText_CantSelectSamePkmn"] = 0x5EEC50, + ["gText_CantSendGiftToTrainer"] = 0x5EF4F0, + ["gText_CantStoreImportantItems"] = 0x5E8E8E, + ["gText_CantSurfHere"] = 0x5E9ED4, + ["gText_CantSwitchWithAlly"] = 0x5E97B2, + ["gText_CantThrowAwayInUse"] = 0x5EA9E0, + ["gText_CantTradeWithTrainer"] = 0x5EA2B8, + ["gText_CantUseHere"] = 0x5E9F01, + ["gText_CantUseUntilNewBadge"] = 0x5E97FC, + ["gText_CantWriteMail"] = 0x5E8DCE, + ["gText_Carbos1000"] = 0x5EB726, + ["gText_Carbos1BP"] = 0x5ECD45, + ["gText_CaveOfOrigin"] = 0x5EB7B8, + ["gText_Chair"] = 0x5EA85E, + ["gText_Challenge"] = 0x5EAEA2, + ["gText_ChallengeQuestionMark"] = 0x5EC727, + ["gText_ChampionMatchCallDesc"] = 0x5EFBC0, + ["gText_Championships"] = 0x5EE349, + ["gText_ChangeJustOneWordOrPhrase"] = 0x5EC149, + ["gText_ChangedToNewItem"] = 0x5EBA29, + ["gText_CharizardDoll256BP"] = 0x5ECCE5, + ["gText_CheapenedAppealOfThoseAhead"] = 0x27D8FE, + ["gText_CheapenedAppealOfThoseAhead2"] = 0x27D9B1, + ["gText_CheapenedJudgesFavoriteAppeal"] = 0x27D9D9, + ["gText_CheapenedMonsAppeal"] = 0x27D8E4, + ["gText_CheapenedMonsAppeal2"] = 0x27D947, + ["gText_Check"] = 0x5EC01C, + ["gText_CheckAllPokemonInDetail"] = 0x5EBDA2, + ["gText_CheckCompleted"] = 0x5EC917, + ["gText_CheckFrontierMap"] = 0x5ED932, + ["gText_CheckMapOfHoenn"] = 0x5EBCC5, + ["gText_CheckObtainedRibbons"] = 0x5EBD1C, + ["gText_CheckPartyPokemonInDetail"] = 0x5EBD83, + ["gText_CheckPokemonInDetail"] = 0x5EBCE8, + ["gText_CheckTrainerCard"] = 0x5ED94D, + ["gText_ChikoritaDoll80BP"] = 0x5ECC7B, + ["gText_ChoiceBand64BP"] = 0x5ECDBA, + ["gText_ChoosePokemon"] = 0x5E9E43, + ["gText_ChoosePokemon2"] = 0x5E9F6F, + ["gText_ChoosePokemonCancel"] = 0x5EA010, + ["gText_ChoosePokemonConfirm"] = 0x5EA02A, + ["gText_ClawFossil"] = 0x5EB2E4, + ["gText_ClearAllSaveData"] = 0x5EFA8F, + ["gText_ClearStreak"] = 0x5EE338, + ["gText_ClearingData"] = 0x5EFAAA, + ["gText_ClefairyDoll48BP"] = 0x5ECC3E, + ["gText_ClockHasBeenReset"] = 0x5ECA4F, + ["gText_CloseBag"] = 0x5E8DB4, + ["gText_Cloudy"] = 0x5EFC92, + ["gText_CoinCase"] = 0x5E9026, + ["gText_Coins"] = 0x5E8DAB, + ["gText_CollectedSymbols"] = 0x5ED9AF, + ["gText_Colon2"] = 0x5ECF8F, + ["gText_Colon3"] = 0x5EE68E, + ["gText_Color161Shadow161"] = 0x5EA7C8, + ["gText_ColorBlue"] = 0x5ED1A7, + ["gText_ColorDarkGray"] = 0x5ED21E, + ["gText_ColorDynamic6WhiteDynamic5"] = 0x5ED222, + ["gText_ColorLightShadowDarkGray"] = 0x5ED1A0, + ["gText_ColorTransparent"] = 0x5ED1AB, + ["gText_Colosseum"] = 0x5EB37F, + ["gText_CombineFourWordsOrPhrases"] = 0x5EC06C, + ["gText_CombineNineWordsOrPhrases"] = 0x5EC117, + ["gText_CombineSixWordsOrPhrases"] = 0x5EC0A1, + ["gText_CombineTwoWordsOrPhrases"] = 0x5EC235, + ["gText_CombineTwoWordsOrPhrases2"] = 0x5EC291, + ["gText_CombineTwoWordsOrPhrases3"] = 0x5EC520, + ["gText_ComeBackWithSecretPower"] = 0x272E30, + ["gText_CommErrorCheckConnections"] = 0x5EE4A4, + ["gText_CommErrorEllipsis"] = 0x5EE4F7, + ["gText_CommStandbyAwaitingOtherPlayer"] = 0x5EFBD2, + ["gText_CommaSpace"] = 0x5CCB7F, + ["gText_Communicating"] = 0x5EEFE8, + ["gText_CommunicationCanceled"] = 0x5EF02C, + ["gText_CommunicationCompleted"] = 0x5EEFF8, + ["gText_CommunicationError"] = 0x5EF014, + ["gText_CommunicationStandby"] = 0x5ED207, + ["gText_CommunicationStandby2"] = 0x5ED7BC, + ["gText_CommunicationStandby3"] = 0x5EDE65, + ["gText_CommunicationStandby4"] = 0x5EDF6F, + ["gText_CommunicationStandby5"] = 0x5EF6D2, + ["gText_CommunicationStandbyBButtonCancel"] = 0x5EEFC0, + ["gText_Confirm2"] = 0x5EE690, + ["gText_Confirm3"] = 0x5EFAE0, + ["gText_ConfirmLinkWhenPlayersReady"] = 0x2780B3, + ["gText_ConfirmSave"] = 0x2C87B4, + ["gText_ConfirmStartLinkWithXPlayers"] = 0x2780F2, + ["gText_ConfirmStarterChoice"] = 0x5E8C90, + ["gText_ConfirmTossItems"] = 0x5E8F0E, + ["gText_Confusion"] = 0x5CCB6A, + ["gText_CongratsPkmnEvolved"] = 0x5CC9A6, + ["gText_ContestLady_Beauty"] = 0x5EEB5A, + ["gText_ContestLady_Coolness"] = 0x5EEB51, + ["gText_ContestLady_Cuteness"] = 0x5EEB61, + ["gText_ContestLady_Handsome"] = 0x5EEB2B, + ["gText_ContestLady_Ironhard"] = 0x5EEB41, + ["gText_ContestLady_Moreme"] = 0x5EEB3A, + ["gText_ContestLady_Muscle"] = 0x5EEB4A, + ["gText_ContestLady_Smartness"] = 0x5EEB6A, + ["gText_ContestLady_Toughness"] = 0x5EEB74, + ["gText_ContestLady_Vinny"] = 0x5EEB34, + ["gText_ContestMoves"] = 0x5EA3F8, + ["gText_Contest_Anxiety"] = 0x27E83F, + ["gText_Contest_Beauty"] = 0x27E868, + ["gText_Contest_Coolness"] = 0x27E85F, + ["gText_Contest_Cuteness"] = 0x27E86F, + ["gText_Contest_Fear"] = 0x27E85A, + ["gText_Contest_Hesitancy"] = 0x27E850, + ["gText_Contest_Laziness"] = 0x27E847, + ["gText_Contest_Shyness"] = 0x27E837, + ["gText_Contest_Smartness"] = 0x27E878, + ["gText_Contest_Toughness"] = 0x27E882, + ["gText_ContestantsMonWon"] = 0x5ED1FA, + ["gText_ContinueBoxOperations"] = 0x5EB960, + ["gText_ContinueMenuBadges"] = 0x5EDCD7, + ["gText_ContinueMenuPlayer"] = 0x5EDCC3, + ["gText_ContinueMenuPokedex"] = 0x5EDCCF, + ["gText_ContinueMenuTime"] = 0x5EDCCA, + ["gText_ControlPad"] = 0x5E850B, + ["gText_Controls"] = 0x5E852C, + ["gText_Conversion"] = 0x27D4B2, + ["gText_Cool"] = 0x5EB0E8, + ["gText_Cool2"] = 0x5EB11F, + ["gText_CoolMove"] = 0x27E894, + ["gText_Coolness"] = 0x5EC7AF, + ["gText_CoolnessContest"] = 0x5EADA4, + ["gText_CoopRankings"] = 0x5ED8A0, + ["gText_CopperCard"] = 0x5EE677, + ["gText_CouldntImproveItsCondition"] = 0x27E658, + ["gText_Count"] = 0x5EAE0C, + ["gText_Counter48BP"] = 0x5ECE83, + ["gText_CreateAQuiz"] = 0x5EC743, + ["gText_CreateAQuiz2"] = 0x5EC575, + ["gText_CrowdContinuesToWatchMon"] = 0x27E7EA, + ["gText_CrushingResults"] = 0x5ED878, + ["gText_CryOf"] = 0x5E871B, + ["gText_Current"] = 0x5EE311, + ["gText_CurrentBattlePoints"] = 0x5ED993, + ["gText_CurrentIsTooFast"] = 0x5E9FDB, + ["gText_Cushion"] = 0x5EA883, + ["gText_Cute"] = 0x5EB0F4, + ["gText_Cute2"] = 0x5EB12B, + ["gText_CuteMove"] = 0x27E8AA, + ["gText_Cuteness"] = 0x5EC7C1, + ["gText_CutenessContest"] = 0x5EADC4, + ["gText_CyndaquilDoll80BP"] = 0x5ECC65, + ["gText_Dad"] = 0x5EAD04, + ["gText_DadsAdvice"] = 0x5E8F31, + ["gText_Dash"] = 0x5EEA76, + ["gText_DataWillBeSaved"] = 0x5EF5BC, + ["gText_Daughter"] = 0x5EB60E, + ["gText_Day"] = 0x5EE68A, + ["gText_Days"] = 0x5EE698, + ["gText_DecimalPoint"] = 0x5EED24, + ["gText_Decorate"] = 0x5EA7B1, + ["gText_Decoration"] = 0x5EAB36, + ["gText_Decoration2"] = 0x5EADF9, + ["gText_DecorationReturnedToPC"] = 0x5EAAC5, + ["gText_DecorationThrownAway"] = 0x5EAA3D, + ["gText_DecorationWillBeDiscarded"] = 0x5EAA14, + ["gText_DeepSeaScale"] = 0x5EAF58, + ["gText_DeepSeaTooth"] = 0x5EAF4B, + ["gText_DefaultNameAllie"] = 0x5E862B, + ["gText_DefaultNameAubree"] = 0x5E864B, + ["gText_DefaultNameBella"] = 0x5E861F, + ["gText_DefaultNameBrett"] = 0x5E85D8, + ["gText_DefaultNameCamila"] = 0x5E8644, + ["gText_DefaultNameCasey"] = 0x5E85E9, + ["gText_DefaultNameCollin"] = 0x5E85FD, + ["gText_DefaultNameDarren"] = 0x5E85EF, + ["gText_DefaultNameEaston"] = 0x5E85BE, + ["gText_DefaultNameHalie"] = 0x5E868C, + ["gText_DefaultNameHazel"] = 0x5E8659, + ["gText_DefaultNameJaxson"] = 0x5E85B7, + ["gText_DefaultNameJayla"] = 0x5E8625, + ["gText_DefaultNameJohnny"] = 0x5E85D1, + ["gText_DefaultNameJude"] = 0x5E85B2, + ["gText_DefaultNameKenny"] = 0x5E85A7, + ["gText_DefaultNameKimmy"] = 0x5E8613, + ["gText_DefaultNameLandon"] = 0x5E85F6, + ["gText_DefaultNameLianna"] = 0x5E8631, + ["gText_DefaultNameLillie"] = 0x5E867A, + ["gText_DefaultNameLucy"] = 0x5E8687, + ["gText_DefaultNameMilton"] = 0x5E859C, + ["gText_DefaultNameMonica"] = 0x5E863D, + ["gText_DefaultNameNadine"] = 0x5E865F, + ["gText_DefaultNameNicola"] = 0x5E8673, + ["gText_DefaultNameQuincy"] = 0x5E860C, + ["gText_DefaultNameReid"] = 0x5E85AD, + ["gText_DefaultNameRuthie"] = 0x5E8652, + ["gText_DefaultNameSara"] = 0x5E8638, + ["gText_DefaultNameSeth"] = 0x5E85DE, + ["gText_DefaultNameStanley"] = 0x5E8604, + ["gText_DefaultNameStu"] = 0x5E8598, + ["gText_DefaultNameTanja"] = 0x5E8666, + ["gText_DefaultNameTerra"] = 0x5E8681, + ["gText_DefaultNameTerry"] = 0x5E85E3, + ["gText_DefaultNameTeru"] = 0x5E85CC, + ["gText_DefaultNameTiara"] = 0x5E8619, + ["gText_DefaultNameTom"] = 0x5E85A3, + ["gText_DefaultNameWalker"] = 0x5E85C5, + ["gText_DefaultNameYasmin"] = 0x5E866C, + ["gText_Defense"] = 0x5EEA55, + ["gText_Defense3"] = 0x5EA343, + ["gText_DefenseCurl"] = 0x27D4D5, + ["gText_DefenseCurl16BP"] = 0x5ECEBA, + ["gText_DelAll"] = 0x5EFCB5, + ["gText_DelRegist"] = 0x5EA79D, + ["gText_Dennis"] = 0x5EAB1E, + ["gText_DepositHowManyVar1"] = 0x5E8E42, + ["gText_DepositInWhichBox"] = 0x5EB866, + ["gText_DepositItem"] = 0x5EAB56, + ["gText_DepositMonDescription"] = 0x5EBBDE, + ["gText_DepositPokemon"] = 0x5EBB82, + ["gText_DepositedVar2Var1s"] = 0x5E8E5A, + ["gText_Deselect"] = 0x5EEC24, + ["gText_Desk"] = 0x5EA859, + ["gText_Detail"] = 0x5EBE7D, + ["gText_Dewford"] = 0x5EAD5F, + ["gText_DexEmptyString"] = 0x5E8B25, + ["gText_DexHoenn"] = 0x5EE84F, + ["gText_DexHoennDescription"] = 0x5E89A4, + ["gText_DexHoennTitle"] = 0x5E88DF, + ["gText_DexNatDescription"] = 0x5E89BB, + ["gText_DexNatTitle"] = 0x5E88E9, + ["gText_DexNational"] = 0x5EE855, + ["gText_DexSearchAlphaABC"] = 0x5E8948, + ["gText_DexSearchAlphaDEF"] = 0x5E894C, + ["gText_DexSearchAlphaGHI"] = 0x5E8950, + ["gText_DexSearchAlphaJKL"] = 0x5E8954, + ["gText_DexSearchAlphaMNO"] = 0x5E8958, + ["gText_DexSearchAlphaPQR"] = 0x5E895C, + ["gText_DexSearchAlphaSTU"] = 0x5E8960, + ["gText_DexSearchAlphaVWX"] = 0x5E8964, + ["gText_DexSearchAlphaYZ"] = 0x5E8968, + ["gText_DexSearchColorBlack"] = 0x5E8981, + ["gText_DexSearchColorBlue"] = 0x5E896F, + ["gText_DexSearchColorBrown"] = 0x5E8987, + ["gText_DexSearchColorGray"] = 0x5E8994, + ["gText_DexSearchColorGreen"] = 0x5E897B, + ["gText_DexSearchColorPink"] = 0x5E899F, + ["gText_DexSearchColorPurple"] = 0x5E898D, + ["gText_DexSearchColorRed"] = 0x5E896B, + ["gText_DexSearchColorWhite"] = 0x5E8999, + ["gText_DexSearchColorYellow"] = 0x5E8974, + ["gText_DexSearchDontSpecify"] = 0x5E8B26, + ["gText_DexSearchTypeNone"] = 0x5E8B35, + ["gText_DexSortAtoZDescription"] = 0x5E8A02, + ["gText_DexSortAtoZTitle"] = 0x5E8905, + ["gText_DexSortHeaviestDescription"] = 0x5E8A37, + ["gText_DexSortHeaviestTitle"] = 0x5E8911, + ["gText_DexSortLightestDescription"] = 0x5E8A73, + ["gText_DexSortLightestTitle"] = 0x5E891F, + ["gText_DexSortNumericalDescription"] = 0x5E89D4, + ["gText_DexSortNumericalTitle"] = 0x5E88F6, + ["gText_DexSortSmallestDescription"] = 0x5E8AEA, + ["gText_DexSortSmallestTitle"] = 0x5E893A, + ["gText_DexSortTallestDescription"] = 0x5E8AAF, + ["gText_DexSortTallestTitle"] = 0x5E892D, + ["gText_DidntDoAsWellAsMonBefore"] = 0x27E1BB, + ["gText_DifferentSaveFile"] = 0x2C8845, + ["gText_DiplomaEmpty"] = 0x5EE8EA, + ["gText_Dissatisfied"] = 0x5EAF3E, + ["gText_DittoDoll48BP"] = 0x5ECC53, + ["gText_DoWhatWithItem"] = 0x5E9F42, + ["gText_DoWhatWithMail"] = 0x5E9FF9, + ["gText_DoWhatWithPokemon"] = 0x5E9EA6, + ["gText_DodrioBerryPicking"] = 0x5EB3B1, + ["gText_Doll"] = 0x5EA87E, + ["gText_DontHave"] = 0x5EA0DC, + ["gText_DontHaveCardNewOneInput"] = 0x5EEEF0, + ["gText_DontHaveNewsNewOneInput"] = 0x5EEF2C, + ["gText_DontRemember"] = 0x5EB7DB, + ["gText_DontRemoveCableTurnOff"] = 0x5EE7CE, + ["gText_DoorOpenedFarAway"] = 0x27301B, + ["gText_Dot"] = 0x5EB5F7, + ["gText_Double"] = 0x5ECB6B, + ["gText_Double2"] = 0x5EB1FA, + ["gText_DoubleBattle"] = 0x5EAE7C, + ["gText_DoubleBattleHallResults"] = 0x5EE241, + ["gText_DoubleBattleRoomResults"] = 0x5EE178, + ["gText_DoubleBattleTourneyResults"] = 0x5EE1FE, + ["gText_DoubleKO"] = 0x5EB4A3, + ["gText_Draw"] = 0x5CCBAD, + ["gText_DreamEater24BP"] = 0x5ECE2A, + ["gText_Dry"] = 0x5E9323, + ["gText_Dry2"] = 0x5ECB4E, + ["gText_E_Mode"] = 0x5EB24E, + ["gText_EasyChatKeyboard_ABCDEFothers"] = 0x62B810, + ["gText_EasyChatKeyboard_GHIJKL"] = 0x62B832, + ["gText_EasyChatKeyboard_MNOPQRS"] = 0x62B84B, + ["gText_EasyChatKeyboard_TUVWXYZ"] = 0x62B86C, + ["gText_EditedTextWillNotBeSaved"] = 0x5EC457, + ["gText_EggAboutToHatch"] = 0x5EA4A2, + ["gText_EggCantBattle"] = 0x5E97E5, + ["gText_EggCantBeTradedNow"] = 0x5EA234, + ["gText_EggFromHotSprings"] = 0x5EA6D7, + ["gText_EggFromTraveler"] = 0x5EA702, + ["gText_EggNickname"] = 0x5E8264, + ["gText_EggWillHatchSoon"] = 0x5EA475, + ["gText_EggWillTakeALongTime"] = 0x5EA40B, + ["gText_EggWillTakeSomeTime"] = 0x5EA442, + ["gText_ElevatorNowOn"] = 0x5EB6A5, + ["gText_EliteFourMatchCallDesc"] = 0x5EFBB5, + ["gText_EllipsisQuestionMark"] = 0x5CC9EE, + ["gText_EmptyContestString"] = 0x27D830, + ["gText_EmptyHeight"] = 0x5E86FB, + ["gText_EmptyLinkService"] = 0x5EB3A3, + ["gText_EmptyPkmnCategory"] = 0x5E86DC, + ["gText_EmptyPokedexInfo1"] = 0x5E871A, + ["gText_EmptyPokedexInfo2"] = 0x5E8722, + ["gText_EmptySpace"] = 0x5E8B3C, + ["gText_EmptySpace2"] = 0x5ED22C, + ["gText_EmptyString2"] = 0x5E8D03, + ["gText_EmptyString3"] = 0x5CC23E, + ["gText_EmptyString5"] = 0x5EA3C0, + ["gText_EmptyString6"] = 0x5ECF8E, + ["gText_EmptyString7"] = 0x5ED931, + ["gText_EmptyString8"] = 0x5EA364, + ["gText_EmptyTextInput1"] = 0x62BA71, + ["gText_EmptyTextInput2"] = 0x62BA72, + ["gText_EmptyTextInput3"] = 0x62BA73, + ["gText_EmptyTextInput4"] = 0x62BA74, + ["gText_EmptyTextInput5"] = 0x62BA75, + ["gText_EmptyTextInput6"] = 0x62BA76, + ["gText_EmptyTextInput7"] = 0x62BA77, + ["gText_EmptyTextInput8"] = 0x62BA78, + ["gText_EmptyWeight"] = 0x5E8709, + ["gText_EndedWithXUnitsPowder"] = 0x5ED65C, + ["gText_Endure48BP"] = 0x5ECF08, + ["gText_EnergyPowder50"] = 0x5EB6B0, + ["gText_EnergyRoot80"] = 0x5EB6C5, + ["gText_EnjoyCycling"] = 0x5EA046, + ["gText_Enter"] = 0x5E96C4, + ["gText_Enter2"] = 0x5EAD67, + ["gText_EntireTextCantBeDeleted"] = 0x5EC4A7, + ["gText_EscapeFromHere"] = 0x5EA16B, + ["gText_EventSafelyLoaded"] = 0x5EE80A, + ["gText_Events"] = 0x5EA3C1, + ["gText_EveryonesAppealsMadeSame"] = 0x27DB4E, + ["gText_EveryonesAppealsMadeToLookSame"] = 0x27D8B5, + ["gText_Excellent"] = 0x5ECB2A, + ["gText_Excellent2"] = 0x5EB02A, + ["gText_ExcellentsInARow"] = 0x5EDEBE, + ["gText_ExchangeService"] = 0x5EB78A, + ["gText_ExecuteSearchSwitch"] = 0x5E88C8, + ["gText_Exit"] = 0x5EB7EA, + ["gText_Exit2"] = 0x5ED260, + ["gText_Exit3"] = 0x5EEE28, + ["gText_Exit4"] = 0x5EF883, + ["gText_ExitFromBox"] = 0x5EB7EF, + ["gText_ExitingChat"] = 0x5ED2E8, + ["gText_ExpPoints"] = 0x5EA39F, + ["gText_ExpandedPlaceholder_Aqua"] = 0x5E8231, + ["gText_ExpandedPlaceholder_Archie"] = 0x5E823C, + ["gText_ExpandedPlaceholder_Brendan"] = 0x5E8258, + ["gText_ExpandedPlaceholder_Chan"] = 0x5E821A, + ["gText_ExpandedPlaceholder_Emerald"] = 0x5E8229, + ["gText_ExpandedPlaceholder_Empty"] = 0x5E8218, + ["gText_ExpandedPlaceholder_Groudon"] = 0x5E8250, + ["gText_ExpandedPlaceholder_Kun"] = 0x5E8219, + ["gText_ExpandedPlaceholder_Kyogre"] = 0x5E8249, + ["gText_ExpandedPlaceholder_Magma"] = 0x5E8236, + ["gText_ExpandedPlaceholder_Maxie"] = 0x5E8243, + ["gText_ExpandedPlaceholder_May"] = 0x5E8260, + ["gText_ExpandedPlaceholder_Ruby"] = 0x5E8224, + ["gText_ExpandedPlaceholder_Sapphire"] = 0x5E821B, + ["gText_ExpertTagMatch"] = 0x5EB361, + ["gText_F700JoinedChat"] = 0x5ED2A9, + ["gText_F700LeftChat"] = 0x5ED2BD, + ["gText_F700Players"] = 0x5EEDB0, + ["gText_F700sQuiz"] = 0x5EC6A8, + ["gText_F701Players"] = 0x5EEDBB, + ["gText_F702Players"] = 0x5EEDC6, + ["gText_F703Players"] = 0x5EEDD1, + ["gText_Facility"] = 0x5ED190, + ["gText_FacilityDouble"] = 0x5ED170, + ["gText_FacilityLink"] = 0x5ED188, + ["gText_FacilityMulti"] = 0x5ED17C, + ["gText_FacilitySingle"] = 0x5ED164, + ["gText_FansQuestion"] = 0x5EC636, + ["gText_FarawayIsland"] = 0x5EB2CA, + ["gText_FavorLady_Pointy"] = 0x5EEBAB, + ["gText_FavorLady_Roundish"] = 0x5EEB8C, + ["gText_FavorLady_Shiny"] = 0x5EEB9E, + ["gText_FavorLady_Slippery"] = 0x5EEB83, + ["gText_FavorLady_Sticky"] = 0x5EEBA4, + ["gText_FavorLady_Whamish"] = 0x5EEB95, + ["gText_Feel"] = 0x5E933F, + ["gText_FeelingsRightNow"] = 0x5EC0F0, + ["gText_FemaleSymbol"] = 0x5E8D4B, + ["gText_FemaleSymbol4"] = 0x32DAC9, + ["gText_Ferry"] = 0x5EC9D2, + ["gText_FilledStorageSpace"] = 0x5EDDFB, + ["gText_FilledStorageSpace2"] = 0x5EDEF4, + ["gText_FindBeautifulPokemon"] = 0x5EBDEE, + ["gText_FindCoolPokemon"] = 0x5EBDDB, + ["gText_FindCutePokemon"] = 0x5EBE06, + ["gText_FindSmartPokemon"] = 0x5EBE19, + ["gText_FindToughPokemon"] = 0x5EBE2D, + ["gText_FindWordsThatDescribeYour"] = 0x5EC0D2, + ["gText_FindWordsWhichFit"] = 0x5EC2CA, + ["gText_FirePunch48BP"] = 0x5ECF4B, + ["gText_FirmSlash"] = 0x5E922C, + ["gText_First"] = 0x5ECB7A, + ["gText_FirstPlacePrize"] = 0x5EDDBD, + ["gText_First_PM"] = 0x5EA09E, + ["gText_FiveMarks"] = 0x5E8D38, + ["gText_FlanneryMatchCallDesc"] = 0x5EFB87, + ["gText_Floor1"] = 0x5EE728, + ["gText_Floor2"] = 0x5EE730, + ["gText_Floor3"] = 0x5EE738, + ["gText_Floor4"] = 0x5EE740, + ["gText_Floor5"] = 0x5EE748, + ["gText_Floor6"] = 0x5EE750, + ["gText_Floor7"] = 0x5EE758, + ["gText_FloorsCleared"] = 0x5EE39C, + ["gText_FlyToWhere"] = 0x5E8CB4, + ["gText_FocusBand64BP"] = 0x5ECDE0, + ["gText_FocusEnergy"] = 0x27D478, + ["gText_Fog"] = 0x5EFC6A, + ["gText_Fog2"] = 0x5EFC84, + ["gText_FollowedAnotherMonsLead"] = 0x27D5DC, + ["gText_FourEllipsesExclamation"] = 0x5EB9A1, + ["gText_Fourth"] = 0x5EA0E7, + ["gText_Frame"] = 0x5EE5BB, + ["gText_FrameType"] = 0x5EE635, + ["gText_FrameTypeNumber"] = 0x5EE640, + ["gText_FreshWaterAndPrice"] = 0x5EAEC3, + ["gText_Friend"] = 0x5EDFB0, + ["gText_Friend2"] = 0x5EEE20, + ["gText_FromSpace"] = 0x5EEA78, + ["gText_FrontierFacilityClearStreak"] = 0x5EE3D4, + ["gText_FrontierFacilityFloorsCleared"] = 0x5EE410, + ["gText_FrontierFacilityKOsStreak"] = 0x5EE3FC, + ["gText_FrontierFacilityRoomsCleared"] = 0x5EE3E8, + ["gText_FrontierFacilityWinStreak"] = 0x5EE3C4, + ["gText_Frz"] = 0x5EAE41, + ["gText_G_Mode"] = 0x5EB255, + ["gText_Gabby"] = 0x5EFA84, + ["gText_GamePlayCannotBeContinued"] = 0x5EC8D4, + ["gText_GameTime"] = 0x5EE6A3, + ["gText_GenderlessSymbol"] = 0x32DACB, + ["gText_GetsAPokeBlockQuestion"] = 0x5EC7A0, + ["gText_GiftSentTo"] = 0x5EF410, + ["gText_Give"] = 0x5ED193, + ["gText_Give2"] = 0x5EAC46, + ["gText_GiveToAPkmn"] = 0x5EB9C3, + ["gText_GiveToWhichPokemon"] = 0x5E9E8F, + ["gText_Glacia"] = 0x5EAD30, + ["gText_GlassChair"] = 0x5EAF9F, + ["gText_GlassDesk"] = 0x5EAFAB, + ["gText_GoBackPrevMenu"] = 0x5E8D62, + ["gText_GoOn"] = 0x5EB5B6, + ["gText_Gold"] = 0x5EA88B, + ["gText_GoldCard"] = 0x5EE685, + ["gText_GoldPokeblock"] = 0x5E9312, + ["gText_Good"] = 0x5ECB1B, + ["gText_GoodSaying"] = 0x5EC62A, + ["gText_GrayPokeblock"] = 0x5E92EF, + ["gText_GreatAppealButNoMoreToEnd"] = 0x27CBCE, + ["gText_GreenPokeblock"] = 0x5E9295, + ["gText_GreenShard"] = 0x5EB062, + ["gText_GretaDefeatGold"] = 0x2C8611, + ["gText_GretaDefeatSilver"] = 0x2C85E3, + ["gText_GretaWonGold"] = 0x2C85F5, + ["gText_GretaWonSilver"] = 0x2C85B4, + ["gText_HMMovesCantBeForgotten2"] = 0x5EA4CB, + ["gText_HOFCorrupted"] = 0x5E8BDC, + ["gText_HOFDexRating"] = 0x5E8B5B, + ["gText_HOFDexSaving"] = 0x5E8BBA, + ["gText_HOFNumber"] = 0x5E8C00, + ["gText_HP3"] = 0x5E96E3, + ["gText_HP4"] = 0x5EA361, + ["gText_HPUp1000"] = 0x5EB75B, + ["gText_HTHeight"] = 0x5E8755, + ["gText_HallOfFame"] = 0x5EB18B, + ["gText_HallOfFameDebut"] = 0x5ECFB8, + ["gText_HatchedFromEgg"] = 0x5ED5A5, + ["gText_Have"] = 0x5EA0D7, + ["gText_HaventReceivedCardsGift"] = 0x5EF084, + ["gText_HaventReceivedGiftOkayToDiscard"] = 0x5EF584, + ["gText_HealPowder50"] = 0x5EB6D9, + ["gText_HealthboxGender_Female"] = 0x5ED233, + ["gText_HealthboxGender_Male"] = 0x5ED22E, + ["gText_HealthboxGender_None"] = 0x5ED238, + ["gText_HealthboxNickname"] = 0x5ED228, + ["gText_HeavyRain"] = 0x5EFCA0, + ["gText_HeldItem"] = 0x5EB21D, + ["gText_HeldItems"] = 0x5EB57E, + ["gText_Hello"] = 0x5ED40F, + ["gText_HereYouGoThankYou"] = 0x5E94C5, + ["gText_Hideout"] = 0x5EC9E4, + ["gText_HighlyAppealingMove"] = 0x27CB82, + ["gText_Hoenn"] = 0x5EE8F1, + ["gText_HoldItems"] = 0x5EB40A, + ["gText_HornAttack"] = 0x27D499, + ["gText_HowManyToSell"] = 0x5E962F, + ["gText_HowMayIServeYou"] = 0x272A21, + ["gText_HowToRide"] = 0x5EAEF6, + ["gText_HowToTurn"] = 0x5EAF02, + ["gText_HpUp1BP"] = 0x5ECD52, + ["gText_Hyper"] = 0x5EB112, + ["gText_HyperRank"] = 0x5EB1DC, + ["gText_Hypnosis"] = 0x27D485, + ["gText_ICanPayVar1"] = 0x5E9654, + ["gText_IDNumber"] = 0x5E8C4D, + ["gText_IDNumber2"] = 0x5E8D59, + ["gText_ILost"] = 0x5EB317, + ["gText_IWon"] = 0x5EB310, + ["gText_IWontTell"] = 0x5EB31F, + ["gText_Ice"] = 0x5CCB66, + ["gText_IcePunch48BP"] = 0x5ECF26, + ["gText_IcyWind24BP"] = 0x5ECEF8, + ["gText_IdNumberSlash"] = 0x5E8C40, + ["gText_IfLeaderLeavesChatEnds"] = 0x5ED3D8, + ["gText_IfThrowAwayCardEventWontHappen"] = 0x5EF520, + ["gText_IllBattleNow"] = 0x5EB2FF, + ["gText_IllTakeYourPkmn"] = 0x272768, + ["gText_IllTakeYourPkmn2"] = 0x2729C0, + ["gText_InBagVar1"] = 0x5E93D5, + ["gText_InGameClockUsable"] = 0x5ECAD8, + ["gText_InParty"] = 0x5EBFCE, + ["gText_InUseAlready"] = 0x5EA8D2, + ["gText_InUseAlready_PM"] = 0x5EA05B, + ["gText_IndigoPokeblock"] = 0x5E92BB, + ["gText_Info"] = 0x5EA406, + ["gText_Info2"] = 0x5EAD6D, + ["gText_Info3"] = 0x5EAEAC, + ["gText_Info4"] = 0x5EAE69, + ["gText_Information"] = 0x5EAE1B, + ["gText_InputText"] = 0x5ED29D, + ["gText_Interview"] = 0x5EC620, + ["gText_Iron1000"] = 0x5EB716, + ["gText_Iron1BP"] = 0x5ECD2F, + ["gText_IsAsShownOkay"] = 0x5EC27E, + ["gText_IsThatOkay"] = 0x5EC47A, + ["gText_IsThisQuizOK"] = 0x5EC732, + ["gText_IsThisTheCorrectTime"] = 0x5EFAC6, + ["gText_ItGotAway"] = 0x5EE933, + ["gText_Item"] = 0x5E8D0B, + ["gText_ItemFinderNearby"] = 0x5E8F93, + ["gText_ItemFinderNothing"] = 0x5E9002, + ["gText_ItemFinderOnTop"] = 0x5E8FDB, + ["gText_ItemIsNowHeld"] = 0x5EBA19, + ["gText_ItemStorage"] = 0x5EAB41, + ["gText_ItemThrownAway"] = 0x5E9E14, + ["gText_Items"] = 0x5EB13C, + ["gText_ItemsPocket"] = 0x5E91CE, + ["gText_Jackpot"] = 0x5ECB72, + ["gText_Jam"] = 0x5EA394, + ["gText_JamOthersMissesTurn"] = 0x27CEBE, + ["gText_JammedAppealsMadeEvenLessNoticeable"] = 0x27DB1F, + ["gText_John"] = 0x5EFA7B, + ["gText_JoinGroup"] = 0x5EB3D4, + ["gText_JuanMatchCallDesc"] = 0x5EFBA9, + ["gText_JudgeBody"] = 0x5EB482, + ["gText_JudgeLookedAtMonExpectantly"] = 0x27E2FE, + ["gText_JudgeLookedAwayForSomeReason"] = 0x27D62D, + ["gText_JudgeLookedAwayFromMon"] = 0x27DD12, + ["gText_JudgeMind"] = 0x5EB469, + ["gText_JudgeSkill"] = 0x5EB475, + ["gText_JudgedLookedOnExpectantly"] = 0x27D6E5, + ["gText_JudgesViewsOnMonHeldFirm"] = 0x27D831, + ["gText_Judging"] = 0x5EAD9C, + ["gText_Jump"] = 0x5EAF2F, + ["gText_JumpToWhichBox"] = 0x5EB853, + ["gText_JumpsInARow"] = 0x5EDEA2, + ["gText_JustOnePkmn"] = 0x5EBC89, + ["gText_KOsInARow"] = 0x5EE381, + ["gText_KeyItemsPocket"] = 0x5E91F1, + ["gText_Key_Items"] = 0x5EB142, + ["gText_KingsRock64BP"] = 0x5ECDCD, + ["gText_Kira"] = 0x5EFA72, + ["gText_KissCushion32BP"] = 0x5ECBEF, + ["gText_KissPoster16BP"] = 0x5ECBDC, + ["gText_LButton"] = 0x5E84F5, + ["gText_LButtonRButton"] = 0x5E8519, + ["gText_Lady"] = 0x5EC6B2, + ["gText_LanettesPC"] = 0x5EB176, + ["gText_LaprasDoll128BP"] = 0x5ECCA6, + ["gText_LargelyUnnoticedMonWorkedHard"] = 0x27DE73, + ["gText_LeaderLeftEndingChat"] = 0x5ED2FA, + ["gText_LeagueChamp"] = 0x5E8C14, + ["gText_Learned"] = 0x5EA0CF, + ["gText_Left"] = 0x5EB084, + ["gText_Leftovers48BP"] = 0x5ECD5E, + ["gText_LegendaryFlewAway"] = 0x273204, + ["gText_LemonadeAndPrice"] = 0x5EAEE6, + ["gText_Lets"] = 0x5ED42A, + ["gText_LetsReplyToTheInterview"] = 0x5EC3F2, + ["gText_Level"] = 0x5E8C3B, + ["gText_LevelSymbol"] = 0x5E8D4D, + ["gText_Lightning"] = 0x5EFC60, + ["gText_LikeToQuitQuiz"] = 0x5EC706, + ["gText_Lilycove"] = 0x5EAD56, + ["gText_LilycoveCity"] = 0x5EB79B, + ["gText_LineBreak"] = 0x5CCB84, + ["gText_LinkBattles"] = 0x5ECFCD, + ["gText_LinkCableBattles"] = 0x5ECFDA, + ["gText_LinkContest"] = 0x5EB227, + ["gText_LinkContestResults"] = 0x5EDF86, + ["gText_LinkMultiBattleRoomResults"] = 0x5EE1B7, + ["gText_LinkStandby"] = 0x5CCA29, + ["gText_LinkStandby2"] = 0x5EE765, + ["gText_LinkStandby4"] = 0x27E8DA, + ["gText_LinkTVProgramWillNotBeMadeTrainerLost"] = 0x27E9CD, + ["gText_ListByBodyColor"] = 0x5E887C, + ["gText_ListByFirstLetter"] = 0x5E8840, + ["gText_ListByType"] = 0x5E88A6, + ["gText_LiteBluePokeblock"] = 0x5E92D4, + ["gText_Littleroot"] = 0x5EAD4B, + ["gText_LoadErrorEndingSession"] = 0x5EE827, + ["gText_LoadingEvent"] = 0x5EE7BF, + ["gText_LockOn"] = 0x27D4E2, + ["gText_LogOff"] = 0x5EB198, + ["gText_Loss"] = 0x5CCBA5, + ["gText_Love"] = 0x5CCB74, + ["gText_Lower"] = 0x5ED242, + ["gText_LucyDefeatGold"] = 0x2C85AE, + ["gText_LucyDefeatSilver"] = 0x2C85A4, + ["gText_LucyWonGold"] = 0x2C85A9, + ["gText_LucyWonSilver"] = 0x2C859D, + ["gText_Lv"] = 0x5EF888, + ["gText_Lv50"] = 0x5EAEB1, + ["gText_Lv502"] = 0x5EE2F2, + ["gText_Lv50_3"] = 0x5EB3EA, + ["gText_LvVar1"] = 0x5E934F, + ["gText_LyricsCantBeDeleted"] = 0x5EC765, + ["gText_Mach"] = 0x5EAE27, + ["gText_Mail"] = 0x5E8D10, + ["gText_MailCantBeStored"] = 0x5EBA38, + ["gText_MailMessageWillBeLost"] = 0x5E98B4, + ["gText_MailSentToPC"] = 0x5E9877, + ["gText_MailTakenFromPkmn"] = 0x5E99B8, + ["gText_MailToBagMessageErased"] = 0x5EACC8, + ["gText_MailTransferredFromMailbox"] = 0x5E9A40, + ["gText_Mailbox"] = 0x5EAB4E, + ["gText_MainMenuContinue"] = 0x5E8285, + ["gText_MainMenuMysteryEvents"] = 0x5E82AF, + ["gText_MainMenuMysteryGift"] = 0x5E8295, + ["gText_MainMenuMysteryGift2"] = 0x5E82A2, + ["gText_MainMenuNewGame"] = 0x5E827C, + ["gText_MainMenuOption"] = 0x5E828E, + ["gText_MakeAllMonsAfterUserNervous"] = 0x27D037, + ["gText_MakeMonAfterUserNervous"] = 0x27D00D, + ["gText_MaleSymbol"] = 0x5E8D49, + ["gText_MaleSymbol4"] = 0x32DAC7, + ["gText_Marco"] = 0x5ECF6B, + ["gText_MarkYourPkmn"] = 0x5EB8D3, + ["gText_Master"] = 0x5EB118, + ["gText_MasterRank"] = 0x5EB1E7, + ["gText_Mat"] = 0x5EA873, + ["gText_MatchCallAromaLady_Rose_Intro1"] = 0x6203C2, + ["gText_MatchCallAromaLady_Rose_Intro2"] = 0x6203DB, + ["gText_MatchCallAromaLady_Rose_Pokemon"] = 0x6203AA, + ["gText_MatchCallAromaLady_Rose_Strategy"] = 0x620390, + ["gText_MatchCallBattleGirl_Cyndy_Intro1"] = 0x621499, + ["gText_MatchCallBattleGirl_Cyndy_Intro2"] = 0x6214AD, + ["gText_MatchCallBattleGirl_Cyndy_Pokemon"] = 0x621483, + ["gText_MatchCallBattleGirl_Cyndy_Strategy"] = 0x62146C, + ["gText_MatchCallBeauty_Jessica_Intro1"] = 0x62081B, + ["gText_MatchCallBeauty_Jessica_Intro2"] = 0x620833, + ["gText_MatchCallBeauty_Jessica_Pokemon"] = 0x62080A, + ["gText_MatchCallBeauty_Jessica_Strategy"] = 0x6207F4, + ["gText_MatchCallBeauty_Thalia_Intro1"] = 0x6207BC, + ["gText_MatchCallBeauty_Thalia_Intro2"] = 0x6207D7, + ["gText_MatchCallBeauty_Thalia_Pokemon"] = 0x6207A9, + ["gText_MatchCallBeauty_Thalia_Strategy"] = 0x62078D, + ["gText_MatchCallBirdKeeper_Robert_Intro1"] = 0x6213DC, + ["gText_MatchCallBirdKeeper_Robert_Intro2"] = 0x6213F8, + ["gText_MatchCallBirdKeeper_Robert_Pokemon"] = 0x6213C2, + ["gText_MatchCallBirdKeeper_Robert_Strategy"] = 0x6213A6, + ["gText_MatchCallBlackBelt_Koji_Intro1"] = 0x6209E3, + ["gText_MatchCallBlackBelt_Koji_Intro2"] = 0x6209FD, + ["gText_MatchCallBlackBelt_Koji_Pokemon"] = 0x6209CA, + ["gText_MatchCallBlackBelt_Koji_Strategy"] = 0x6209B4, + ["gText_MatchCallBlackBelt_Nob_Intro1"] = 0x620988, + ["gText_MatchCallBlackBelt_Nob_Intro2"] = 0x6209A4, + ["gText_MatchCallBlackBelt_Nob_Pokemon"] = 0x620979, + ["gText_MatchCallBlackBelt_Nob_Strategy"] = 0x620963, + ["gText_MatchCallBrendan_Intro1"] = 0x5ED516, + ["gText_MatchCallBrendan_Intro2"] = 0x5ED52F, + ["gText_MatchCallBrendan_Pokemon"] = 0x5ED4FA, + ["gText_MatchCallBrendan_Strategy"] = 0x5ED4E3, + ["gText_MatchCallBugCatcher_James_Intro1"] = 0x62199D, + ["gText_MatchCallBugCatcher_James_Intro2"] = 0x6219B6, + ["gText_MatchCallBugCatcher_James_Pokemon"] = 0x621983, + ["gText_MatchCallBugCatcher_James_Strategy"] = 0x62196C, + ["gText_MatchCallBugManiac_Jeffrey_Intro1"] = 0x620C19, + ["gText_MatchCallBugManiac_Jeffrey_Intro2"] = 0x620C34, + ["gText_MatchCallBugManiac_Jeffrey_Pokemon"] = 0x620C03, + ["gText_MatchCallBugManiac_Jeffrey_Strategy"] = 0x620BF2, + ["gText_MatchCallCamper_Ethan_Intro1"] = 0x620B64, + ["gText_MatchCallCamper_Ethan_Intro2"] = 0x620B7F, + ["gText_MatchCallCamper_Ethan_Pokemon"] = 0x620B4C, + ["gText_MatchCallCamper_Ethan_Strategy"] = 0x620B32, + ["gText_MatchCallChampion_Wallace_Intro1"] = 0x621FF9, + ["gText_MatchCallChampion_Wallace_Intro2"] = 0x62200F, + ["gText_MatchCallChampion_Wallace_Pokemon"] = 0x621FDE, + ["gText_MatchCallChampion_Wallace_Strategy"] = 0x621FC9, + ["gText_MatchCallCollector_Edwin_Intro1"] = 0x621710, + ["gText_MatchCallCollector_Edwin_Intro2"] = 0x62172A, + ["gText_MatchCallCollector_Edwin_Pokemon"] = 0x6216FB, + ["gText_MatchCallCollector_Edwin_Strategy"] = 0x6216E0, + ["gText_MatchCallCooltrainer_Brooke_Intro1"] = 0x620668, + ["gText_MatchCallCooltrainer_Brooke_Intro2"] = 0x620681, + ["gText_MatchCallCooltrainer_Brooke_Pokemon"] = 0x620654, + ["gText_MatchCallCooltrainer_Brooke_Strategy"] = 0x620638, + ["gText_MatchCallCooltrainer_Cristin_Intro1"] = 0x620612, + ["gText_MatchCallCooltrainer_Cristin_Intro2"] = 0x62062F, + ["gText_MatchCallCooltrainer_Cristin_Pokemon"] = 0x6205F8, + ["gText_MatchCallCooltrainer_Cristin_Strategy"] = 0x6205DD, + ["gText_MatchCallCooltrainer_Wilton_Intro1"] = 0x6206BC, + ["gText_MatchCallCooltrainer_Wilton_Intro2"] = 0x6206D5, + ["gText_MatchCallCooltrainer_Wilton_Pokemon"] = 0x6206A7, + ["gText_MatchCallCooltrainer_Wilton_Strategy"] = 0x620693, + ["gText_MatchCallDandyCharm_Juan_Intro1"] = 0x621E00, + ["gText_MatchCallDandyCharm_Juan_Intro2"] = 0x621E1B, + ["gText_MatchCallDandyCharm_Juan_Pokemon"] = 0x621DEB, + ["gText_MatchCallDandyCharm_Juan_Strategy"] = 0x621DD0, + ["gText_MatchCallDragonTamer_Nicolas_Intro1"] = 0x621376, + ["gText_MatchCallDragonTamer_Nicolas_Intro2"] = 0x621393, + ["gText_MatchCallDragonTamer_Nicolas_Pokemon"] = 0x62135C, + ["gText_MatchCallDragonTamer_Nicolas_Strategy"] = 0x621342, + ["gText_MatchCallEliteFour_Drake_Intro1"] = 0x621F99, + ["gText_MatchCallEliteFour_Drake_Intro2"] = 0x621FB2, + ["gText_MatchCallEliteFour_Drake_Pokemon"] = 0x621F7F, + ["gText_MatchCallEliteFour_Drake_Strategy"] = 0x621F65, + ["gText_MatchCallEliteFour_Glacia_Intro1"] = 0x621F30, + ["gText_MatchCallEliteFour_Glacia_Intro2"] = 0x621F4B, + ["gText_MatchCallEliteFour_Glacia_Pokemon"] = 0x621F13, + ["gText_MatchCallEliteFour_Glacia_Strategy"] = 0x621EFD, + ["gText_MatchCallEliteFour_Phoebe_Intro1"] = 0x621ECF, + ["gText_MatchCallEliteFour_Phoebe_Intro2"] = 0x621EEA, + ["gText_MatchCallEliteFour_Phoebe_Pokemon"] = 0x621EB5, + ["gText_MatchCallEliteFour_Phoebe_Strategy"] = 0x621E9F, + ["gText_MatchCallEliteFour_Sidney_Intro1"] = 0x621E68, + ["gText_MatchCallEliteFour_Sidney_Intro2"] = 0x621E84, + ["gText_MatchCallEliteFour_Sidney_Pokemon"] = 0x621E4E, + ["gText_MatchCallEliteFour_Sidney_Strategy"] = 0x621E38, + ["gText_MatchCallExpert_Shelby_Intro1"] = 0x620FCD, + ["gText_MatchCallExpert_Shelby_Intro2"] = 0x620FE6, + ["gText_MatchCallExpert_Shelby_Pokemon"] = 0x620FBA, + ["gText_MatchCallExpert_Shelby_Strategy"] = 0x620FA2, + ["gText_MatchCallExpert_Timothy_Intro1"] = 0x620F76, + ["gText_MatchCallExpert_Timothy_Intro2"] = 0x620F90, + ["gText_MatchCallExpert_Timothy_Pokemon"] = 0x620F5D, + ["gText_MatchCallExpert_Timothy_Strategy"] = 0x620F43, + ["gText_MatchCallFisherman_Elliot_Intro1"] = 0x621083, + ["gText_MatchCallFisherman_Elliot_Intro2"] = 0x62109F, + ["gText_MatchCallFisherman_Elliot_Pokemon"] = 0x62106A, + ["gText_MatchCallFisherman_Elliot_Strategy"] = 0x621056, + ["gText_MatchCallGentleman_Walter_Intro1"] = 0x620D32, + ["gText_MatchCallGentleman_Walter_Intro2"] = 0x620D49, + ["gText_MatchCallGentleman_Walter_Pokemon"] = 0x620D1A, + ["gText_MatchCallGentleman_Walter_Strategy"] = 0x620D06, + ["gText_MatchCallGuitarist_Dalton_Intro1"] = 0x620A9E, + ["gText_MatchCallGuitarist_Dalton_Intro2"] = 0x620AB8, + ["gText_MatchCallGuitarist_Dalton_Pokemon"] = 0x620A8C, + ["gText_MatchCallGuitarist_Dalton_Strategy"] = 0x620A78, + ["gText_MatchCallGuitarist_Fernando_Intro1"] = 0x620A4B, + ["gText_MatchCallGuitarist_Fernando_Intro2"] = 0x620A66, + ["gText_MatchCallGuitarist_Fernando_Pokemon"] = 0x620A31, + ["gText_MatchCallGuitarist_Fernando_Strategy"] = 0x620A18, + ["gText_MatchCallHexManiac_Valerie_Intro1"] = 0x620717, + ["gText_MatchCallHexManiac_Valerie_Intro2"] = 0x620730, + ["gText_MatchCallHexManiac_Valerie_Pokemon"] = 0x6206FF, + ["gText_MatchCallHexManiac_Valerie_Strategy"] = 0x6206E7, + ["gText_MatchCallHiker_Sawyer_Intro1"] = 0x621A41, + ["gText_MatchCallHiker_Sawyer_Intro2"] = 0x621A5C, + ["gText_MatchCallHiker_Sawyer_Pokemon"] = 0x621A34, + ["gText_MatchCallHiker_Sawyer_Strategy"] = 0x621A25, + ["gText_MatchCallHiker_Trent_Intro1"] = 0x6219F8, + ["gText_MatchCallHiker_Trent_Intro2"] = 0x621A13, + ["gText_MatchCallHiker_Trent_Pokemon"] = 0x6219E3, + ["gText_MatchCallHiker_Trent_Strategy"] = 0x6219CE, + ["gText_MatchCallKindler_Bernie_Intro1"] = 0x620AFB, + ["gText_MatchCallKindler_Bernie_Intro2"] = 0x620B16, + ["gText_MatchCallKindler_Bernie_Pokemon"] = 0x620AE4, + ["gText_MatchCallKindler_Bernie_Strategy"] = 0x620AD2, + ["gText_MatchCallLady_Cindy_Intro1"] = 0x62075D, + ["gText_MatchCallLady_Cindy_Intro2"] = 0x620779, + ["gText_MatchCallLady_Cindy_Pokemon"] = 0x62074E, + ["gText_MatchCallLady_Cindy_Strategy"] = 0x62073D, + ["gText_MatchCallLass_Haley_Intro1"] = 0x62193C, + ["gText_MatchCallLass_Haley_Intro2"] = 0x621955, + ["gText_MatchCallLass_Haley_Pokemon"] = 0x621921, + ["gText_MatchCallLass_Haley_Strategy"] = 0x621908, + ["gText_MatchCallMay_Intro1"] = 0x5ED579, + ["gText_MatchCallMay_Intro2"] = 0x5ED58F, + ["gText_MatchCallMay_Pokemon"] = 0x5ED563, + ["gText_MatchCallMay_Strategy"] = 0x5ED547, + ["gText_MatchCallMysticDuo_TateAndLiza_Intro1"] = 0x621DA2, + ["gText_MatchCallMysticDuo_TateAndLiza_Intro2"] = 0x621DBB, + ["gText_MatchCallMysticDuo_TateAndLiza_Pokemon"] = 0x621D89, + ["gText_MatchCallMysticDuo_TateAndLiza_Strategy"] = 0x621D6F, + ["gText_MatchCallNinjaBoy_Lao_Intro1"] = 0x621439, + ["gText_MatchCallNinjaBoy_Lao_Intro2"] = 0x621451, + ["gText_MatchCallNinjaBoy_Lao_Pokemon"] = 0x621426, + ["gText_MatchCallNinjaBoy_Lao_Strategy"] = 0x62140B, + ["gText_MatchCallOldCouple_JohnAndJay_Intro1"] = 0x620BC0, + ["gText_MatchCallOldCouple_JohnAndJay_Intro2"] = 0x620BD8, + ["gText_MatchCallOldCouple_JohnAndJay_Pokemon"] = 0x620BA6, + ["gText_MatchCallOldCouple_JohnAndJay_Strategy"] = 0x620B8C, + ["gText_MatchCallParasolLady_Madeline_Intro1"] = 0x6214EF, + ["gText_MatchCallParasolLady_Madeline_Intro2"] = 0x621507, + ["gText_MatchCallParasolLady_Madeline_Pokemon"] = 0x6214DA, + ["gText_MatchCallParasolLady_Madeline_Strategy"] = 0x6214C6, + ["gText_MatchCallPassionBurn_Flannery_Intro1"] = 0x621C6F, + ["gText_MatchCallPassionBurn_Flannery_Intro2"] = 0x621C8A, + ["gText_MatchCallPassionBurn_Flannery_Pokemon"] = 0x621C5C, + ["gText_MatchCallPassionBurn_Flannery_Strategy"] = 0x621C47, + ["gText_MatchCallPicnicker_Diana_Intro1"] = 0x621599, + ["gText_MatchCallPicnicker_Diana_Intro2"] = 0x6215AF, + ["gText_MatchCallPicnicker_Diana_Pokemon"] = 0x621585, + ["gText_MatchCallPicnicker_Diana_Strategy"] = 0x62156A, + ["gText_MatchCallPkmnBreeder_Gabrielle_Intro1"] = 0x621826, + ["gText_MatchCallPkmnBreeder_Gabrielle_Intro2"] = 0x62183E, + ["gText_MatchCallPkmnBreeder_Gabrielle_Pokemon"] = 0x621810, + ["gText_MatchCallPkmnBreeder_Gabrielle_Strategy"] = 0x6217F5, + ["gText_MatchCallPkmnBreeder_Isaac_Intro1"] = 0x6217C2, + ["gText_MatchCallPkmnBreeder_Isaac_Intro2"] = 0x6217D9, + ["gText_MatchCallPkmnBreeder_Isaac_Pokemon"] = 0x6217A9, + ["gText_MatchCallPkmnBreeder_Isaac_Strategy"] = 0x621799, + ["gText_MatchCallPkmnBreeder_Lydia_Intro1"] = 0x62176B, + ["gText_MatchCallPkmnBreeder_Lydia_Intro2"] = 0x621782, + ["gText_MatchCallPkmnBreeder_Lydia_Pokemon"] = 0x621752, + ["gText_MatchCallPkmnBreeder_Lydia_Strategy"] = 0x621740, + ["gText_MatchCallPkmnRanger_Catherine_Intro1"] = 0x621881, + ["gText_MatchCallPkmnRanger_Catherine_Intro2"] = 0x621899, + ["gText_MatchCallPkmnRanger_Catherine_Pokemon"] = 0x62186A, + ["gText_MatchCallPkmnRanger_Catherine_Strategy"] = 0x621851, + ["gText_MatchCallPkmnRanger_Jackson_Intro1"] = 0x6218D7, + ["gText_MatchCallPkmnRanger_Jackson_Intro2"] = 0x6218F0, + ["gText_MatchCallPkmnRanger_Jackson_Pokemon"] = 0x6218C0, + ["gText_MatchCallPkmnRanger_Jackson_Strategy"] = 0x6218AF, + ["gText_MatchCallPkmnTrainer_Wally_Intro1"] = 0x621AF7, + ["gText_MatchCallPkmnTrainer_Wally_Intro2"] = 0x621B10, + ["gText_MatchCallPkmnTrainer_Wally_Pokemon"] = 0x621ADD, + ["gText_MatchCallPkmnTrainer_Wally_Strategy"] = 0x621AC5, + ["gText_MatchCallPokeManiac_Steve_Intro1"] = 0x6208DC, + ["gText_MatchCallPokeManiac_Steve_Intro2"] = 0x6208F1, + ["gText_MatchCallPokeManiac_Steve_Pokemon"] = 0x6208C3, + ["gText_MatchCallPokeManiac_Steve_Strategy"] = 0x6208AA, + ["gText_MatchCallPokefan_Isabel_Intro1"] = 0x620EB5, + ["gText_MatchCallPokefan_Isabel_Intro2"] = 0x620ECC, + ["gText_MatchCallPokefan_Isabel_Pokemon"] = 0x620E98, + ["gText_MatchCallPokefan_Isabel_Strategy"] = 0x620E83, + ["gText_MatchCallPokefan_Miguel_Intro1"] = 0x620F12, + ["gText_MatchCallPokefan_Miguel_Intro2"] = 0x620F2A, + ["gText_MatchCallPokefan_Miguel_Pokemon"] = 0x620EF6, + ["gText_MatchCallPokefan_Miguel_Strategy"] = 0x620EE2, + ["gText_MatchCallPsychic_Cameron_Intro1"] = 0x620C78, + ["gText_MatchCallPsychic_Cameron_Intro2"] = 0x620C92, + ["gText_MatchCallPsychic_Cameron_Pokemon"] = 0x620C60, + ["gText_MatchCallPsychic_Cameron_Strategy"] = 0x620C4E, + ["gText_MatchCallPsychic_Jacki_Intro1"] = 0x620CD9, + ["gText_MatchCallPsychic_Jacki_Intro2"] = 0x620CF5, + ["gText_MatchCallPsychic_Jacki_Pokemon"] = 0x620CBE, + ["gText_MatchCallPsychic_Jacki_Strategy"] = 0x620CA8, + ["gText_MatchCallReliableOne_Dad_Intro1"] = 0x621CD9, + ["gText_MatchCallReliableOne_Dad_Intro2"] = 0x621CF4, + ["gText_MatchCallReliableOne_Dad_Pokemon"] = 0x621CBD, + ["gText_MatchCallReliableOne_Dad_Strategy"] = 0x621CA2, + ["gText_MatchCallRichBoy_Winston_Intro1"] = 0x62087C, + ["gText_MatchCallRichBoy_Winston_Intro2"] = 0x620896, + ["gText_MatchCallRichBoy_Winston_Pokemon"] = 0x620863, + ["gText_MatchCallRichBoy_Winston_Strategy"] = 0x62084B, + ["gText_MatchCallRockinWhiz_Roxanne_Intro1"] = 0x621B57, + ["gText_MatchCallRockinWhiz_Roxanne_Intro2"] = 0x621B73, + ["gText_MatchCallRockinWhiz_Roxanne_Pokemon"] = 0x621B3B, + ["gText_MatchCallRockinWhiz_Roxanne_Strategy"] = 0x621B23, + ["gText_MatchCallRuinManiac_Andres_Intro1"] = 0x620428, + ["gText_MatchCallRuinManiac_Andres_Intro2"] = 0x620444, + ["gText_MatchCallRuinManiac_Andres_Pokemon"] = 0x62040D, + ["gText_MatchCallRuinManiac_Andres_Strategy"] = 0x6203F2, + ["gText_MatchCallRuinManiac_Dusty_Intro1"] = 0x620481, + ["gText_MatchCallRuinManiac_Dusty_Intro2"] = 0x62049C, + ["gText_MatchCallRuinManiac_Dusty_Pokemon"] = 0x62046C, + ["gText_MatchCallRuinManiac_Dusty_Strategy"] = 0x620456, + ["gText_MatchCallSailor_Cory_Intro1"] = 0x6216A8, + ["gText_MatchCallSailor_Cory_Intro2"] = 0x6216C3, + ["gText_MatchCallSailor_Cory_Pokemon"] = 0x62168C, + ["gText_MatchCallSailor_Cory_Strategy"] = 0x621674, + ["gText_MatchCallSailor_Ernest_Intro1"] = 0x62164A, + ["gText_MatchCallSailor_Ernest_Intro2"] = 0x621664, + ["gText_MatchCallSailor_Ernest_Pokemon"] = 0x621630, + ["gText_MatchCallSailor_Ernest_Strategy"] = 0x621615, + ["gText_MatchCallSchoolKid_Jerry_Intro1"] = 0x620DF1, + ["gText_MatchCallSchoolKid_Jerry_Intro2"] = 0x620E08, + ["gText_MatchCallSchoolKid_Jerry_Pokemon"] = 0x620DDE, + ["gText_MatchCallSchoolKid_Jerry_Strategy"] = 0x620DCA, + ["gText_MatchCallSchoolKid_Karen_Intro1"] = 0x620D98, + ["gText_MatchCallSchoolKid_Karen_Intro2"] = 0x620DB3, + ["gText_MatchCallSchoolKid_Karen_Pokemon"] = 0x620D7C, + ["gText_MatchCallSchoolKid_Karen_Strategy"] = 0x620D63, + ["gText_MatchCallSisAndBro_LilaAndRoy_Intro1"] = 0x6205AC, + ["gText_MatchCallSisAndBro_LilaAndRoy_Intro2"] = 0x6205C6, + ["gText_MatchCallSisAndBro_LilaAndRoy_Pokemon"] = 0x620592, + ["gText_MatchCallSisAndBro_LilaAndRoy_Strategy"] = 0x62057D, + ["gText_MatchCallSkyTamer_Winona_Intro1"] = 0x621D3D, + ["gText_MatchCallSkyTamer_Winona_Intro2"] = 0x621D56, + ["gText_MatchCallSkyTamer_Winona_Pokemon"] = 0x621D27, + ["gText_MatchCallSkyTamer_Winona_Strategy"] = 0x621D0C, + ["gText_MatchCallSrAndJr_AnnaAndMeg_Intro1"] = 0x620E54, + ["gText_MatchCallSrAndJr_AnnaAndMeg_Intro2"] = 0x620E6C, + ["gText_MatchCallSrAndJr_AnnaAndMeg_Pokemon"] = 0x620E39, + ["gText_MatchCallSrAndJr_AnnaAndMeg_Strategy"] = 0x620E22, + ["gText_MatchCallSteven_Intro1_AfterMeteorFallsBattle"] = 0x5ED4B3, + ["gText_MatchCallSteven_Intro1_BeforeMeteorFallsBattle"] = 0x5ED483, + ["gText_MatchCallSteven_Intro2_AfterMeteorFallsBattle"] = 0x5ED4CE, + ["gText_MatchCallSteven_Intro2_BeforeMeteorFallsBattle"] = 0x5ED49D, + ["gText_MatchCallSteven_Pokemon"] = 0x5ED46B, + ["gText_MatchCallSteven_Strategy"] = 0x5ED453, + ["gText_MatchCallSwellShock_Wattson_Intro1"] = 0x621C18, + ["gText_MatchCallSwellShock_Wattson_Intro2"] = 0x621C2F, + ["gText_MatchCallSwellShock_Wattson_Pokemon"] = 0x621BFC, + ["gText_MatchCallSwellShock_Wattson_Strategy"] = 0x621BE5, + ["gText_MatchCallSwimmer_Jenny_Intro1"] = 0x62153B, + ["gText_MatchCallSwimmer_Jenny_Intro2"] = 0x621551, + ["gText_MatchCallSwimmer_Jenny_Pokemon"] = 0x621527, + ["gText_MatchCallSwimmer_Jenny_Strategy"] = 0x62151D, + ["gText_MatchCallSwimmer_Tony_Intro1"] = 0x62092E, + ["gText_MatchCallSwimmer_Tony_Intro2"] = 0x62094A, + ["gText_MatchCallSwimmer_Tony_Pokemon"] = 0x62091C, + ["gText_MatchCallSwimmer_Tony_Strategy"] = 0x620909, + ["gText_MatchCallTheBigHit_Brawly_Intro1"] = 0x621BBB, + ["gText_MatchCallTheBigHit_Brawly_Intro2"] = 0x621BD6, + ["gText_MatchCallTheBigHit_Brawly_Pokemon"] = 0x621BA4, + ["gText_MatchCallTheBigHit_Brawly_Strategy"] = 0x621B8C, + ["gText_MatchCallTriathlete_Abigail_Intro1"] = 0x621197, + ["gText_MatchCallTriathlete_Abigail_Intro2"] = 0x6211B3, + ["gText_MatchCallTriathlete_Abigail_Pokemon"] = 0x621182, + ["gText_MatchCallTriathlete_Abigail_Strategy"] = 0x62116E, + ["gText_MatchCallTriathlete_Benjamin_Intro1"] = 0x6212A7, + ["gText_MatchCallTriathlete_Benjamin_Intro2"] = 0x6212BF, + ["gText_MatchCallTriathlete_Benjamin_Pokemon"] = 0x621290, + ["gText_MatchCallTriathlete_Benjamin_Strategy"] = 0x62127B, + ["gText_MatchCallTriathlete_Dylan_Intro1"] = 0x6211FC, + ["gText_MatchCallTriathlete_Dylan_Intro2"] = 0x621216, + ["gText_MatchCallTriathlete_Dylan_Pokemon"] = 0x6211E4, + ["gText_MatchCallTriathlete_Dylan_Strategy"] = 0x6211CC, + ["gText_MatchCallTriathlete_Isaiah_Intro1"] = 0x6210E7, + ["gText_MatchCallTriathlete_Isaiah_Intro2"] = 0x621101, + ["gText_MatchCallTriathlete_Isaiah_Pokemon"] = 0x6210CE, + ["gText_MatchCallTriathlete_Isaiah_Strategy"] = 0x6210B5, + ["gText_MatchCallTriathlete_Katelyn_Intro1"] = 0x621257, + ["gText_MatchCallTriathlete_Katelyn_Intro2"] = 0x621270, + ["gText_MatchCallTriathlete_Katelyn_Pokemon"] = 0x621243, + ["gText_MatchCallTriathlete_Katelyn_Strategy"] = 0x621230, + ["gText_MatchCallTriathlete_Maria_Intro1"] = 0x62113D, + ["gText_MatchCallTriathlete_Maria_Intro2"] = 0x621157, + ["gText_MatchCallTriathlete_Maria_Pokemon"] = 0x621125, + ["gText_MatchCallTriathlete_Maria_Strategy"] = 0x621114, + ["gText_MatchCallTriathlete_Pablo_Intro1"] = 0x62130C, + ["gText_MatchCallTriathlete_Pablo_Intro2"] = 0x621326, + ["gText_MatchCallTriathlete_Pablo_Pokemon"] = 0x6212F3, + ["gText_MatchCallTriathlete_Pablo_Strategy"] = 0x6212DA, + ["gText_MatchCallTuber_Lola_Intro1"] = 0x6204DD, + ["gText_MatchCallTuber_Lola_Intro2"] = 0x6204F9, + ["gText_MatchCallTuber_Lola_Pokemon"] = 0x6204C7, + ["gText_MatchCallTuber_Lola_Strategy"] = 0x6204B0, + ["gText_MatchCallTuber_Ricky_Intro1"] = 0x620546, + ["gText_MatchCallTuber_Ricky_Intro2"] = 0x620562, + ["gText_MatchCallTuber_Ricky_Pokemon"] = 0x62052B, + ["gText_MatchCallTuber_Ricky_Strategy"] = 0x62050E, + ["gText_MatchCallTwins_AmyAndLiv_Intro1"] = 0x6215E3, + ["gText_MatchCallTwins_AmyAndLiv_Intro2"] = 0x6215FD, + ["gText_MatchCallTwins_AmyAndLiv_Pokemon"] = 0x6215D0, + ["gText_MatchCallTwins_AmyAndLiv_Strategy"] = 0x6215BC, + ["gText_MatchCallYoungCouple_LoisAndHal_Intro1"] = 0x621A9D, + ["gText_MatchCallYoungCouple_LoisAndHal_Intro2"] = 0x621AB0, + ["gText_MatchCallYoungCouple_LoisAndHal_Pokemon"] = 0x621A88, + ["gText_MatchCallYoungCouple_LoisAndHal_Strategy"] = 0x621A72, + ["gText_MatchCallYoungster_Calvin_Intro1"] = 0x621021, + ["gText_MatchCallYoungster_Calvin_Intro2"] = 0x62103B, + ["gText_MatchCallYoungster_Calvin_Pokemon"] = 0x62100A, + ["gText_MatchCallYoungster_Calvin_Strategy"] = 0x620FF9, + ["gText_Matchup"] = 0x5EB48E, + ["gText_MaxHP"] = 0x5EEA46, + ["gText_MayBrendanMatchCallDesc"] = 0x5EFB18, + ["gText_MegaKick48BP"] = 0x5ECE4F, + ["gText_MegaPunch24BP"] = 0x5ECE3D, + ["gText_MemberDroppedOut"] = 0x5ED754, + ["gText_MentalHerb48BP"] = 0x5ECD93, + ["gText_MenuBag"] = 0x5EE6D3, + ["gText_MenuExit"] = 0x5EE6EE, + ["gText_MenuOption"] = 0x5EE6E7, + ["gText_MenuOptionBag"] = 0x5EB26C, + ["gText_MenuOptionExit"] = 0x5EB285, + ["gText_MenuOptionOption"] = 0x5EB27E, + ["gText_MenuOptionPokedex"] = 0x5EB25C, + ["gText_MenuOptionPokemon"] = 0x5EB264, + ["gText_MenuOptionPokenav"] = 0x5EB270, + ["gText_MenuOptionSave"] = 0x5EB279, + ["gText_MenuPlayer"] = 0x5EE6DF, + ["gText_MenuPokedex"] = 0x5EE6C3, + ["gText_MenuPokemon"] = 0x5EE6CB, + ["gText_MenuPokenav"] = 0x5EE6D7, + ["gText_MenuRest"] = 0x5EE6FA, + ["gText_MenuRetire"] = 0x5EE6F3, + ["gText_MenuSave"] = 0x5EE6E2, + ["gText_MeowthDoll48BP"] = 0x5ECC2B, + ["gText_MessageWillBeLost"] = 0x5EAC8D, + ["gText_Mind"] = 0x5CCDBB, + ["gText_MixingRecords"] = 0x5EEA7E, + ["gText_Mom"] = 0x5EAD08, + ["gText_MomMatchCallDesc"] = 0x5EFB32, + ["gText_MomMatchCallName"] = 0x5EFB4B, + ["gText_MomOrDadMightLikeThisProgram"] = 0x272BCF, + ["gText_MonAppealedWithMove"] = 0x27D55A, + ["gText_MonAttractedALotOfAttention"] = 0x27D7A5, + ["gText_MonBecameALittleDistracted"] = 0x27E448, + ["gText_MonBecameNervous"] = 0x27DD6F, + ["gText_MonCantAppealNextTurn"] = 0x27E793, + ["gText_MonCantMakeAnAppealAfterThis"] = 0x27DBB0, + ["gText_MonCaughtALittleAttention"] = 0x27D785, + ["gText_MonCommandedTotalAttention"] = 0x27D7C8, + ["gText_MonCouldntHelpLeapingUp"] = 0x27E531, + ["gText_MonCouldntHelpUtteringCry"] = 0x27E504, + ["gText_MonDidntStandOutVeryMuch"] = 0x27D764, + ["gText_MonFailedToStandOutAtAll"] = 0x27D743, + ["gText_MonHasntMadeItsAppeal"] = 0x27D7E8, + ["gText_MonIsntFazedByThatSortOfThing"] = 0x27E416, + ["gText_MonLookedDownOutOfDistraction"] = 0x27E4A6, + ["gText_MonManagedToAvertGaze"] = 0x27E3C1, + ["gText_MonManagedToAvoidSeeingIt"] = 0x27E3EB, + ["gText_MonRegainedItsForm"] = 0x27DDF2, + ["gText_MonTrippedOutOfDistraction"] = 0x27E55A, + ["gText_MonTurnedBackOutOfDistraction"] = 0x27E4D5, + ["gText_MonTypeAndNo"] = 0x5EB3FC, + ["gText_MonWasTooNervousToMove"] = 0x27E58A, + ["gText_MonWasUnaffected"] = 0x27E6C4, + ["gText_MonWasWatchingOthers"] = 0x27D56F, + ["gText_MonsAppealDidNotGoWell"] = 0x27DED9, + ["gText_MonsAppealDidNotGoWell2"] = 0x27DF3A, + ["gText_MonsAppealDidNotGoWell3"] = 0x27DF63, + ["gText_MonsAppealDidNotWorkVeryWell"] = 0x27E02F, + ["gText_MonsAppealEffectWoreOff"] = 0x27D872, + ["gText_MonsAppealWasDud"] = 0x27E00C, + ["gText_MonsAppealWentExcellently"] = 0x27DFE2, + ["gText_MonsAppealWentExcellently2"] = 0x27E0DD, + ["gText_MonsAppealWentPrettyWell"] = 0x27DFB8, + ["gText_MonsAppealWentPrettyWell2"] = 0x27E08B, + ["gText_MonsAppealWentSlightlyWell"] = 0x27DF8C, + ["gText_MonsAppealWentSlightlyWell2"] = 0x27E05F, + ["gText_MonsAppealWentVeryWell"] = 0x27E0B5, + ["gText_MonsConditionRoseAboveUsual"] = 0x27E1F3, + ["gText_MonsHotStatusMadeGreatAppeal"] = 0x27E220, + ["gText_MonsMoveIsIgnored"] = 0x27E817, + ["gText_MonsXChangedPerceptions"] = 0x27D855, + ["gText_MonsXDidntGoOverWell"] = 0x27E73C, + ["gText_MonsXGotTheCrowdGoing"] = 0x27E76A, + ["gText_MonsXWentOverGreat"] = 0x27E717, + ["gText_MostImpressiveMon"] = 0x27E992, + ["gText_MoveCloserToLinkPartner"] = 0x5EE50C, + ["gText_MoveInterfaceDynamicColors"] = 0x5CCA8A, + ["gText_MoveInterfacePP"] = 0x5CCA6F, + ["gText_MoveInterfacePPType"] = 0x5CCA79, + ["gText_MoveInterfaceType"] = 0x5CCA73, + ["gText_MoveItems"] = 0x5EBB9F, + ["gText_MoveItemsDescription"] = 0x5EBC35, + ["gText_MoveMonDescription"] = 0x5EBC04, + ["gText_MoveNotLearned"] = 0x5E9B76, + ["gText_MoveOkBack"] = 0x5EEAF6, + ["gText_MovePokemon"] = 0x5EBB92, + ["gText_MoveRelearnedPkmnDidNotLearnMove"] = 0x5EF9C8, + ["gText_MoveRelearnerAccuracy"] = 0x5EFA5D, + ["gText_MoveRelearnerAndPoof"] = 0x5EF979, + ["gText_MoveRelearnerAppeal"] = 0x5EFA67, + ["gText_MoveRelearnerBattleMoves"] = 0x5EFA31, + ["gText_MoveRelearnerContestMovesTitle"] = 0x5EFA3E, + ["gText_MoveRelearnerGiveUp"] = 0x5EF9E6, + ["gText_MoveRelearnerJam"] = 0x5EFA6E, + ["gText_MoveRelearnerPP"] = 0x5EFA52, + ["gText_MoveRelearnerPkmnForgotMoveAndLearnedNew"] = 0x5EF9A6, + ["gText_MoveRelearnerPkmnLearnedMove"] = 0x5EF8E4, + ["gText_MoveRelearnerPkmnTryingToLearnMove"] = 0x5EF8F3, + ["gText_MoveRelearnerPower"] = 0x5EFA56, + ["gText_MoveRelearnerStopTryingToTeachMove"] = 0x5EF960, + ["gText_MoveRelearnerTeachMoveConfirm"] = 0x5EF8DA, + ["gText_MoveRelearnerType"] = 0x5EFA4C, + ["gText_MoveRelearnerWhichMoveToForget"] = 0x5EFA10, + ["gText_MoveToBag"] = 0x5EAC3A, + ["gText_MoveToWhere"] = 0x5E9E55, + ["gText_MoveUsedMostOften"] = 0x27E973, + ["gText_MoveVar1Where"] = 0x5E8DFE, + ["gText_MovedBackInLineForNextAppeal"] = 0x27E289, + ["gText_MovedUpInLineForNextAppeal"] = 0x27E254, + ["gText_Moves"] = 0x5E96BE, + ["gText_MovesPPIncreased"] = 0x5E9CF5, + ["gText_MrBriney"] = 0x5EAE97, + ["gText_MrStoneMatchCallDesc"] = 0x5EFAEF, + ["gText_MrStoneMatchCallName"] = 0x5EFAFA, + ["gText_MtPyre"] = 0x5EB7C7, + ["gText_MudSlap24BP"] = 0x5ECEDB, + ["gText_MudkipDollAndPrice"] = 0x5EAFE8, + ["gText_Multi"] = 0x5EB201, + ["gText_MultiBattle"] = 0x5EAE8A, + ["gText_MultiBattleRoomResults"] = 0x5EE198, + ["gText_MultiLink"] = 0x5EB207, + ["gText_MultiLink2"] = 0x5EB451, + ["gText_MustBePlacedOnDesk"] = 0x5EA978, + ["gText_MysteryEventBerry"] = 0x674AE4, + ["gText_MysteryEventBerryObtained"] = 0x674B42, + ["gText_MysteryEventBerryTransform"] = 0x674B16, + ["gText_MysteryEventCantBeUsed"] = 0x674C86, + ["gText_MysteryEventFullParty"] = 0x674C02, + ["gText_MysteryEventNationalDex"] = 0x674B9E, + ["gText_MysteryEventNewAdversaryInBattleTower"] = 0x674C55, + ["gText_MysteryEventNewTrainer"] = 0x674C31, + ["gText_MysteryEventRareWord"] = 0x674BD4, + ["gText_MysteryEventSentOver"] = 0x674BF0, + ["gText_MysteryEventSpecialRibbon"] = 0x674B6A, + ["gText_MysteryEventsCantUse"] = 0x5E8328, + ["gText_MysteryGift"] = 0x5EF654, + ["gText_MysteryGiftCantUse"] = 0x5E82E5, + ["gText_Name"] = 0x5E8C48, + ["gText_NamingScreenKeyboard_01234"] = 0x62B995, + ["gText_NamingScreenKeyboard_56789"] = 0x62B9AE, + ["gText_NamingScreenKeyboard_ABCDEF"] = 0x62B911, + ["gText_NamingScreenKeyboard_GHIJKL"] = 0x62B932, + ["gText_NamingScreenKeyboard_MNOPQRS"] = 0x62B953, + ["gText_NamingScreenKeyboard_Symbols1"] = 0x62B9C7, + ["gText_NamingScreenKeyboard_Symbols2"] = 0x62B9E0, + ["gText_NamingScreenKeyboard_TUVWXYZ"] = 0x62B974, + ["gText_NamingScreenKeyboard_abcdef"] = 0x62B88D, + ["gText_NamingScreenKeyboard_ghijkl"] = 0x62B8AE, + ["gText_NamingScreenKeyboard_mnopqrs"] = 0x62B8CF, + ["gText_NamingScreenKeyboard_tuvwxyz"] = 0x62B8F0, + ["gText_NatureSlash"] = 0x5EBF8D, + ["gText_NavelRock"] = 0x5EB2D9, + ["gText_NeatnessRankings"] = 0x5ED88C, + ["gText_NewLine"] = 0x5CCB86, + ["gText_NewLine2"] = 0x5EF881, + ["gText_NewStampReceived"] = 0x5EF178, + ["gText_NewTrainerReceived"] = 0x5EF198, + ["gText_Next"] = 0x5E8544, + ["gText_NextAppealMadeEarlier"] = 0x27D2DA, + ["gText_NextAppealMadeLater"] = 0x27D309, + ["gText_NextBack"] = 0x5E854C, + ["gText_NextLv"] = 0x5EA3AB, + ["gText_NicknameHatchPrompt"] = 0x5ED5BE, + ["gText_NicknameThisPokemon"] = 0x273374, + ["gText_No"] = 0x5EAE66, + ["gText_No2"] = 0x5EEC4D, + ["gText_No3"] = 0x5EECCE, + ["gText_No4"] = 0x5EB2FC, + ["gText_NoDecorationHere"] = 0x5EAA81, + ["gText_NoDecorations"] = 0x5EA83D, + ["gText_NoDecorationsInUse"] = 0x5EAAEC, + ["gText_NoEntry"] = 0x5E96CA, + ["gText_NoIdenticalHoldItems"] = 0x5E9FC2, + ["gText_NoItems"] = 0x5EABD1, + ["gText_NoMailHere"] = 0x5EAC4B, + ["gText_NoMatchingPkmnWereFound"] = 0x5E8785, + ["gText_NoMoreDecorations"] = 0x5EA8EA, + ["gText_NoMoreDecorations2"] = 0x5EA931, + ["gText_NoMoreRoomForPokemon"] = 0x27331C, + ["gText_NoMoreRoomForStamps"] = 0x5EF21C, + ["gText_NoMoreRoomForThis"] = 0x5E9558, + ["gText_NoMoreThanVar1Pkmn"] = 0x5E9830, + ["gText_NoNeed"] = 0x5ED198, + ["gText_NoPokemon"] = 0x5E8DE9, + ["gText_NoPokemonForBattle"] = 0x5E9F58, + ["gText_NoRegistry"] = 0x5EA785, + ["gText_NoRibbonWinners"] = 0x5EBD4A, + ["gText_NoRoomForItems"] = 0x5E8E6E, + ["gText_NoRoomInBag"] = 0x5EABE7, + ["gText_NoRoomLeftForAnother"] = 0x272B1A, + ["gText_NoSaveFileCantSetTime"] = 0x5ECAA7, + ["gText_NoTrainersRegistered"] = 0x5EBD67, + ["gText_NoUse"] = 0x5EA091, + ["gText_NoWeather"] = 0x5EFC3E, + ["gText_NolandDefeatGold"] = 0x2C8589, + ["gText_NolandDefeatSilver"] = 0x2C853B, + ["gText_NolandWonGold"] = 0x2C8561, + ["gText_NolandWonSilver"] = 0x2C8512, + ["gText_None"] = 0x5E8D26, + ["gText_Normal"] = 0x5EB105, + ["gText_NormalRank"] = 0x5EB1C5, + ["gText_NormalTagMatch"] = 0x5EB32D, + ["gText_NormanMatchCallDesc"] = 0x5EFB25, + ["gText_NormanMatchCallName"] = 0x5EFB47, + ["gText_NotAble"] = 0x5EA0B6, + ["gText_NotAble2"] = 0x5EA0C5, + ["gText_NotEnoughHp"] = 0x5E9F81, + ["gText_NotEvenANibble"] = 0x5EE91E, + ["gText_NotPkmnOtherTrainerWants"] = 0x5EA2E0, + ["gText_NotSameTypeAsOneBeforeGood"] = 0x27E143, + ["gText_NotSoGood"] = 0x5EB034, + ["gText_NotYet"] = 0x5EAE5A, + ["gText_NothingChanged"] = 0x5EC7EF, + ["gText_NothingSentOver"] = 0x5EF2E4, + ["gText_NothingToCut"] = 0x5E9EBC, + ["gText_NoticesGoldCard"] = 0x272860, + ["gText_NumBP"] = 0x5ED0C8, + ["gText_NumPlayerLink"] = 0x5EE667, + ["gText_NumPokeblocks"] = 0x5ED096, + ["gText_Number"] = 0x5E8C36, + ["gText_Number2"] = 0x5EBFD7, + ["gText_NumberClear01"] = 0x5E8D4F, + ["gText_NumberIndex"] = 0x5EC029, + ["gText_NumberItem_HM"] = 0x5E921A, + ["gText_NumberItem_TMBerry"] = 0x5E9210, + ["gText_NumberOfBattles"] = 0x5EBE6E, + ["gText_NumberRegistered"] = 0x5EBE5F, + ["gText_NumberVar1Var2"] = 0x5E9263, + ["gText_OTSlash"] = 0x5EA365, + ["gText_ObtainedTheDecor"] = 0x272B09, + ["gText_ObtainedTheItem"] = 0x272A78, + ["gText_OddEggFoundByCouple"] = 0x5EA647, + ["gText_OhABite"] = 0x5EE8F7, + ["gText_Ok"] = 0x5ED430, + ["gText_Ok2"] = 0x5EFCC5, + ["gText_Ok3"] = 0x5EAE4B, + ["gText_OkayToDeleteFromRegistry"] = 0x5EA737, + ["gText_OkayToDiscardNews"] = 0x5EF55C, + ["gText_OlivePokeblock"] = 0x5E92E3, + ["gText_OneDash"] = 0x5E8D40, + ["gText_OnlyOnePhrase"] = 0x5EC4C9, + ["gText_OnlyPkmnForBattle"] = 0x5EA1E8, + ["gText_OpenLevel"] = 0x5EAEB8, + ["gText_OpenLevel2"] = 0x5EB3F1, + ["gText_OpenLevel3"] = 0x5EB4E0, + ["gText_OpenLv"] = 0x5EE2F9, + ["gText_Opponent"] = 0x5EB1A0, + ["gText_Option"] = 0x5EE589, + ["gText_OptionMenuCancel"] = 0x5EE5C1, + ["gText_OriginalSongWillBeUsed"] = 0x5EC4E9, + ["gText_Ornament"] = 0x5EA86A, + ["gText_OtherTrainerCanceled"] = 0x5EF4C4, + ["gText_OtherTrainerCantAcceptPkmn"] = 0x5EA284, + ["gText_OtherTrainerHasCard"] = 0x5EF42C, + ["gText_OtherTrainerHasNews"] = 0x5EF460, + ["gText_OtherTrainerHasStamp"] = 0x5EF494, + ["gText_OtherTrainersPkmnCantBeTraded"] = 0x5EA250, + ["gText_Others"] = 0x5ED248, + ["gText_Others2"] = 0x5EEC1D, + ["gText_OutOfTheListedChoices"] = 0x5EC2FF, + ["gText_PCMailboxFull"] = 0x5E9897, + ["gText_PPUp3000"] = 0x5EB76C, + ["gText_PPWasRestored"] = 0x5E9CB7, + ["gText_PackUp"] = 0x5EAE04, + ["gText_Par"] = 0x5EAE35, + ["gText_Paralysis"] = 0x5CCB5C, + ["gText_PartyFull"] = 0x5EBCAD, + ["gText_PauseUntilPress"] = 0x5EA1CA, + ["gText_Peak"] = 0x5EE760, + ["gText_PeculiarEggNicePlace"] = 0x5EA678, + ["gText_PeculiarEggTrade"] = 0x5EA6AB, + ["gText_Peekaboo"] = 0x5EE49A, + ["gText_PeopleBattling"] = 0x5EED6F, + ["gText_PeopleCommunicating"] = 0x5EED9A, + ["gText_PeopleInUnionRoom"] = 0x5EED80, + ["gText_PeopleTrading"] = 0x5EED5F, + ["gText_Petalburg"] = 0x5EAD37, + ["gText_Philip"] = 0x5EAB17, + ["gText_Phoebe"] = 0x5EAD29, + ["gText_PickAWordOrPhraseAnd"] = 0x5EC34C, + ["gText_PickCancel"] = 0x5E8574, + ["gText_PickNextCancel"] = 0x5E855C, + ["gText_PickOKCancel"] = 0x5EF678, + ["gText_PickOKExit"] = 0x5EF664, + ["gText_PickOk"] = 0x5E8538, + ["gText_PickTheWallpaper"] = 0x5EB82F, + ["gText_PinkPokeblock"] = 0x5E928A, + ["gText_PkmnAdoresBaseVar2Fell"] = 0x5E9D6D, + ["gText_PkmnAlreadyHoldingItemSwitch"] = 0x5E993A, + ["gText_PkmnAlreadyInBattle"] = 0x5E9754, + ["gText_PkmnAlreadyKnows"] = 0x5E9C00, + ["gText_PkmnAlreadySelected"] = 0x5E976F, + ["gText_PkmnBaseVar2StatIncreased"] = 0x5E9D25, + ["gText_PkmnBecameHealthy"] = 0x5E9CE0, + ["gText_PkmnBurnHealed"] = 0x5E9C8A, + ["gText_PkmnCameBack"] = 0x5EB979, + ["gText_PkmnCantBeTradedNow"] = 0x5EA210, + ["gText_PkmnCantLearnMove"] = 0x5E9AB4, + ["gText_PkmnCantParticipate"] = 0x5EA0EE, + ["gText_PkmnCantSwitchOut"] = 0x5E9738, + ["gText_PkmnCuredOfParalysis"] = 0x5E9C5F, + ["gText_PkmnCuredOfPoison"] = 0x5E9C3E, + ["gText_PkmnElevatedToLvVar2"] = 0x5E9D0A, + ["gText_PkmnFainted_FldPsn"] = 0x5ECF5D, + ["gText_PkmnForSwap"] = 0x5EECD1, + ["gText_PkmnFriendlyBaseVar2CantFall"] = 0x5E9D90, + ["gText_PkmnFriendlyBaseVar2Fell"] = 0x5E9D45, + ["gText_PkmnGotOverInfatuation"] = 0x5E9DE1, + ["gText_PkmnHPRestoredByVar2"] = 0x5E9C17, + ["gText_PkmnHasNoEnergy"] = 0x5E978F, + ["gText_PkmnHoldingItemCantHoldMail"] = 0x5E9A08, + ["gText_PkmnInfo"] = 0x5EA3CF, + ["gText_PkmnIsEvolving"] = 0x5CC990, + ["gText_PkmnIsSelected"] = 0x5EB843, + ["gText_PkmnJumpRecords"] = 0x5EDE8D, + ["gText_PkmnLearnedMove3"] = 0x5E9AA5, + ["gText_PkmnNeedsToReplaceMove"] = 0x5E9AE9, + ["gText_PkmnNotHolding"] = 0x5E9980, + ["gText_PkmnRegainhedHealth"] = 0x5E9CCA, + ["gText_PkmnSkills"] = 0x5EA3DC, + ["gText_PkmnSnappedOutOfConfusion"] = 0x5E9DBE, + ["gText_PkmnStoppedEvolving"] = 0x5CC9D3, + ["gText_PkmnSwap"] = 0x5EEC66, + ["gText_PkmnThawedOut"] = 0x5E9CA2, + ["gText_PkmnTransferredLanettesPC"] = 0x273256, + ["gText_PkmnTransferredLanettesPCBoxFull"] = 0x2732D9, + ["gText_PkmnTransferredSomeonesPC"] = 0x273216, + ["gText_PkmnTransferredSomeonesPCBoxFull"] = 0x273296, + ["gText_PkmnWasDeposited"] = 0x5EB87C, + ["gText_PkmnWasGivenItem"] = 0x5E991B, + ["gText_PkmnWasReleased"] = 0x5EB8B5, + ["gText_PkmnWokeUp2"] = 0x5E9C7C, + ["gText_PkmnsNickname"] = 0x5EEAD3, + ["gText_PlaceItHere"] = 0x5EA897, + ["gText_PlacedItemInBag"] = 0x5EB9D6, + ["gText_Plant"] = 0x5EA864, + ["gText_PlayBerryCrushAgain"] = 0x5ED704, + ["gText_Player"] = 0x5E84BF, + ["gText_PlayerFoundOneItem"] = 0x272ABF, + ["gText_PlayerFoundOneTMHM"] = 0x2731A9, + ["gText_PlayerHandedOverTheItem"] = 0x273161, + ["gText_PlayerHouseBootPC"] = 0x272D87, + ["gText_PlayerPutItemInBag"] = 0x272AEA, + ["gText_PlayerSavedGame"] = 0x2C8832, + ["gText_PlayerSavedGame_Chat"] = 0x5ED3C5, + ["gText_PlayerUsedSurf"] = 0x27300D, + ["gText_PlayerUsedVar2"] = 0x5E9080, + ["gText_PlayerWhatCanIDoForYou"] = 0x272A52, + ["gText_PlayerWhitedOut"] = 0x272DE3, + ["gText_PlayersBattleResults"] = 0x5EF68D, + ["gText_PlayersPC"] = 0x5EB183, + ["gText_PleaseComeAgain"] = 0x272A3F, + ["gText_PleaseEnterPhraseOrWord"] = 0x5EC488, + ["gText_PleasePickATheme"] = 0x5EB81A, + ["gText_PleaseRemoveTheMail"] = 0x5EB9AB, + ["gText_PleaseResetTime"] = 0x5ECA38, + ["gText_PleaseWaitForLink"] = 0x278091, + ["gText_Plus"] = 0x5EEA73, + ["gText_PlusSymbol"] = 0x5E8D55, + ["gText_Points"] = 0x5ECF91, + ["gText_Poison"] = 0x5CCB50, + ["gText_PokeBalls"] = 0x5EFCD4, + ["gText_PokeBallsPocket"] = 0x5E91D4, + ["gText_PokeDollar"] = 0x5ECF84, + ["gText_Poke_Balls"] = 0x5EB14C, + ["gText_PokeblockLinkCanceled"] = 0x272D9C, + ["gText_PokeblocksWithFriends"] = 0x5ED085, + ["gText_Pokedex"] = 0x5E84C6, + ["gText_PokedexDiploma"] = 0x5EE85E, + ["gText_PokedexRegistration"] = 0x5E8735, + ["gText_PokedollarVar1"] = 0x5E969C, + ["gText_PokemartSign"] = 0x272B6A, + ["gText_Pokemon"] = 0x5E8268, + ["gText_Pokemon2"] = 0x5ED415, + ["gText_Pokemon3"] = 0x5EDFB7, + ["gText_Pokemon4"] = 0x5E8CAC, + ["gText_PokemonAreNeeded"] = 0x5E9F90, + ["gText_PokemonCantBeSame"] = 0x5E9FA7, + ["gText_PokemonCenter"] = 0x5EC791, + ["gText_PokemonCenterSign"] = 0x272B9E, + ["gText_PokemonFemaleLv"] = 0x5EBFF6, + ["gText_PokemonFemaleLv2"] = 0x5EC04F, + ["gText_PokemonJump"] = 0x5EB3A4, + ["gText_PokemonLeague"] = 0x5EC782, + ["gText_PokemonMaleLv"] = 0x5EBFE4, + ["gText_PokemonMaleLv2"] = 0x5EC03B, + ["gText_PokemonMoves"] = 0x5EB508, + ["gText_PokemonNature"] = 0x5EB4F9, + ["gText_PokemonNoGenderLv"] = 0x5EC008, + ["gText_PokemonNoGenderLv2"] = 0x5EC063, + ["gText_PokemonOnHook"] = 0x5EE903, + ["gText_PokemonOrder"] = 0x5EB589, + ["gText_PokemonTrades"] = 0x5ED010, + ["gText_PokemonTrainerSchoolEmail"] = 0x272CD5, + ["gText_PokenavAndBag"] = 0x5EB56E, + ["gText_PokenavCondition_MarkingButtons"] = 0x5EBF22, + ["gText_PokenavCondition_MonListButtons"] = 0x5EBEF9, + ["gText_PokenavCondition_MonStatusButtons"] = 0x5EBF0E, + ["gText_PokenavMap_ZoomedInButtons"] = 0x5EBEE9, + ["gText_PokenavMap_ZoomedOutButtons"] = 0x5EBED9, + ["gText_PokenavMatchCall_CallMenuButtons"] = 0x5EBF49, + ["gText_PokenavMatchCall_CheckTrainerButtons"] = 0x5EBF57, + ["gText_PokenavMatchCall_SelfIntroduction"] = 0x5EBEC3, + ["gText_PokenavMatchCall_Strategy"] = 0x5EBEA8, + ["gText_PokenavMatchCall_TrainerListButtons"] = 0x5EBF39, + ["gText_PokenavMatchCall_TrainerPokemon"] = 0x5EBEB1, + ["gText_PokenavRibbons_MonListButtons"] = 0x5EBF60, + ["gText_PokenavRibbons_RibbonCheckButtons"] = 0x5EBF84, + ["gText_PokenavRibbons_RibbonListButtons"] = 0x5EBF73, + ["gText_Pokenav_ClearButtonList"] = 0x5EBED5, + ["gText_PokerusExplanation"] = 0x272F07, + ["gText_Poster"] = 0x5EA877, + ["gText_Powder"] = 0x5EDCDE, + ["gText_PowderQty"] = 0x5E9138, + ["gText_Power"] = 0x5EA37E, + ["gText_PreliminaryResults"] = 0x5ED1D0, + ["gText_PresentTime"] = 0x5ECA0D, + ["gText_PressAToLoadEvent"] = 0x5EE788, + ["gText_PressesRankings"] = 0x5ED860, + ["gText_PressingPowerRankings"] = 0x5ED8B8, + ["gText_PressingSpeed"] = 0x5ED810, + ["gText_PressingSpeedRankings"] = 0x5ED8DC, + ["gText_PrettyChair"] = 0x5EB651, + ["gText_PrettyDesk"] = 0x5EB65E, + ["gText_Prev"] = 0x5EE320, + ["gText_PreviousTime"] = 0x5ECA22, + ["gText_PrimaryJudgingNumX"] = 0x27E929, + ["gText_ProfBirchMatchCallDesc"] = 0x5EFBC9, + ["gText_ProfBirchMatchCallName"] = 0x5E8270, + ["gText_Profile"] = 0x5EC5AA, + ["gText_Protein1000"] = 0x5EB703, + ["gText_Protein1BP"] = 0x5ECD13, + ["gText_Psn"] = 0x5EAE31, + ["gText_PsychUp48BP"] = 0x5ECF16, + ["gText_PurplePokeblock"] = 0x5E92AE, + ["gText_PutAway"] = 0x5EA7BA, + ["gText_PutAwayFrontierPass"] = 0x5ED977, + ["gText_PutAwayPokenav"] = 0x5EBD34, + ["gText_PutItemInBag"] = 0x5EB9FF, + ["gText_PutItemInPocket"] = 0x272A9A, + ["gText_PutOutSelectedDecorItem"] = 0x5EA7CF, + ["gText_PyramidMaze"] = 0x5EB555, + ["gText_PyramidPokemon"] = 0x5EB532, + ["gText_PyramidTrainers"] = 0x5EB543, + ["gText_QuestionMark"] = 0x5ECBDA, + ["gText_QuestionPressTheAButton"] = 0x5EC6D4, + ["gText_Questionnaire"] = 0x5EC664, + ["gText_QuickClaw48BP"] = 0x5ECD81, + ["gText_Quit"] = 0x5EAE4E, + ["gText_QuitChatting"] = 0x5ED265, + ["gText_QuitEditing"] = 0x5EC3A6, + ["gText_QuitEditing2"] = 0x5EC449, + ["gText_QuitGivingInfo"] = 0x5EC53E, + ["gText_QuitShopping"] = 0x5E93E0, + ["gText_QuitSwapping"] = 0x5EECBB, + ["gText_QuitTheGame"] = 0x5EF6E9, + ["gText_Quiz"] = 0x5EFCC8, + ["gText_QuizLady_Lady"] = 0x5EEB7E, + ["gText_RButton"] = 0x5E84EC, + ["gText_RTCTime"] = 0x5EE6AD, + ["gText_Rage"] = 0x27D473, + ["gText_Rain"] = 0x5EFC56, + ["gText_RainDance"] = 0x27D468, + ["gText_RankingHall"] = 0x5EB77D, + ["gText_Ranks"] = 0x5EAD96, + ["gText_Read"] = 0x5EAC35, + ["gText_Read2"] = 0x5E96D8, + ["gText_ReadNewsThatArrived"] = 0x5EEEB8, + ["gText_ReadyPickBerry"] = 0x5ED5F0, + ["gText_ReadyToStart"] = 0x5EB1B6, + ["gText_Receive"] = 0x5EEE30, + ["gText_ReceivedItemFromPkmn"] = 0x5E999D, + ["gText_Rechoose"] = 0x5EECB2, + ["gText_Record"] = 0x5EE319, + ["gText_Record2"] = 0x5EB5BC, + ["gText_Record3"] = 0x5EB41D, + ["gText_RecordBattleToPass"] = 0x5CD28F, + ["gText_RecordCorner"] = 0x5EB389, + ["gText_RecordMixingComplete"] = 0x5EEA8E, + ["gText_RecordUploadedViaWireless"] = 0x5EF244, + ["gText_RecordingGameResults"] = 0x5ED6C8, + ["gText_RecordsLv50"] = 0x5EE3B0, + ["gText_RecordsOpenLevel"] = 0x5EE3B8, + ["gText_Red"] = 0x5EE14B, + ["gText_RedFlute"] = 0x5EB62F, + ["gText_RedFlute2"] = 0x5EAF7D, + ["gText_RedPokeblock"] = 0x5E9275, + ["gText_RedShard"] = 0x5EB040, + ["gText_RedTent"] = 0x5EB29A, + ["gText_ReelTimeHelp"] = 0x5EF750, + ["gText_RefusedBattle"] = 0x5EFC27, + ["gText_Register"] = 0x5EA333, + ["gText_Register2"] = 0x5ED257, + ["gText_RegisterTextHere"] = 0x5ED289, + ["gText_RegisterTextWhere"] = 0x5ED274, + ["gText_RegisteredDataDeleted"] = 0x5EA762, + ["gText_RegisteredTextChangedOKToSave"] = 0x5ED325, + ["gText_RegisteredTrainerinPokeNav"] = 0x272E0F, + ["gText_Registry"] = 0x5EAE12, + ["gText_ReleaseThisPokemon"] = 0x5EB89F, + ["gText_RemoveMailBeforeItem"] = 0x5E98EC, + ["gText_Rent"] = 0x5EEC10, + ["gText_RentalPkmn"] = 0x5EA369, + ["gText_RentalPkmn2"] = 0x5EEBB2, + ["gText_RentalSwap"] = 0x5EE326, + ["gText_RepeatedAppeal"] = 0x27E6E3, + ["gText_RepelEffectsLingered"] = 0x5E9092, + ["gText_ResetRTCConfirmCancel"] = 0x5EC9EC, + ["gText_Rest"] = 0x5EB5C3, + ["gText_Rest2"] = 0x27D4C7, + ["gText_RestoreWhichMove"] = 0x5E9F16, + ["gText_RestoredPkmnToFullHealth"] = 0x272798, + ["gText_Retire"] = 0x5EB5C8, + ["gText_ReturnDecorationToPC"] = 0x5EAAA3, + ["gText_ReturnToConditionMenu"] = 0x5EBE41, + ["gText_ReturnToHealingSpot"] = 0x5EA196, + ["gText_ReturnToPokedex"] = 0x5E87EF, + ["gText_ReturnToPokenavMenu"] = 0x5EBDBF, + ["gText_ReturnToTitle"] = 0x5EEED4, + ["gText_ReturnToVar1"] = 0x5E91C0, + ["gText_ReturnToWaitingRoom"] = 0x5EA139, + ["gText_RevivalHerb300"] = 0x5EB6ED, + ["gText_Ribbons"] = 0x5EBFDC, + ["gText_RibbonsF700"] = 0x5EC030, + ["gText_RibbonsVar1"] = 0x5EA3B4, + ["gText_Right"] = 0x5EB07E, + ["gText_RightArrow"] = 0x5E8D57, + ["gText_Roberto"] = 0x5EAB25, + ["gText_RockSlide48BP"] = 0x5ECE71, + ["gText_Rooftop"] = 0x5EB69D, + ["gText_RoomsCleared"] = 0x5EE35B, + ["gText_RootFossil"] = 0x5EB2F0, + ["gText_Roulette"] = 0x5ECB12, + ["gText_Round2Results"] = 0x5ED1E9, + ["gText_RoxanneMatchCallDesc"] = 0x5EFB62, + ["gText_Roy"] = 0x5EFA80, + ["gText_SafariBallLeft"] = 0x5CCB3C, + ["gText_SafariBallStock"] = 0x5EE6FF, + ["gText_SafariBalls"] = 0x5CCB2C, + ["gText_SafariZoneMenu"] = 0x5CCA54, + ["gText_SamePkmnInPartyAlready"] = 0x5EED0A, + ["gText_SameTypeAsOneBeforeGood"] = 0x27E107, + ["gText_Sandstorm"] = 0x5EFC7A, + ["gText_SandstormIsVicious"] = 0x272C5F, + ["gText_SandySlopes"] = 0x5EAF0E, + ["gText_Satisfied"] = 0x5EAF34, + ["gText_SaveCompleteGameCannotContinue"] = 0x5EC94F, + ["gText_SaveCompletePressA"] = 0x5EC9A2, + ["gText_SaveCompleted"] = 0x5ECA8A, + ["gText_SaveCompletedPressA"] = 0x5EF5E0, + ["gText_SaveError"] = 0x2C892A, + ["gText_SaveFailed"] = 0x5ECA9A, + ["gText_SaveFailedCheckingBackup"] = 0x5EC816, + ["gText_SaveFileCorrupted"] = 0x5E83C2, + ["gText_SaveFileErased"] = 0x5E8405, + ["gText_SaveFileUpdated"] = 0x5E83A2, + ["gText_SavingBadges"] = 0x5EED2D, + ["gText_SavingDontTurnOff"] = 0x2C8810, + ["gText_SavingDontTurnOff2"] = 0x5EE42D, + ["gText_SavingDontTurnOffPower"] = 0x2C8959, + ["gText_SavingDontTurnOff_Chat"] = 0x5ED3A3, + ["gText_SavingPlayer"] = 0x5EED26, + ["gText_SavingPokedex"] = 0x5EED34, + ["gText_SavingTime"] = 0x5EED3C, + ["gText_SawIt"] = 0x5EAE53, + ["gText_ScopeLens64BP"] = 0x5ECDF2, + ["gText_ScottMatchCallDesc"] = 0x5EFB4F, + ["gText_ScottMatchCallName"] = 0x5EFB5C, + ["gText_ScrambleOrderOfNextAppeals"] = 0x27D369, + ["gText_ScrambledUpOrderForNextTurn"] = 0x27E2C5, + ["gText_Seafloor"] = 0x5EFC89, + ["gText_Seafloor2"] = 0x5EFCAB, + ["gText_SearchCompleted"] = 0x5E8773, + ["gText_SearchForPkmnBasedOnParameters"] = 0x5E87A5, + ["gText_SearchingPleaseWait"] = 0x5E875B, + ["gText_Second"] = 0x5ECB80, + ["gText_Second_PM"] = 0x5EA0A4, + ["gText_SecondaryJudgingNumX"] = 0x27E943, + ["gText_SecretBase"] = 0x5EC9D8, + ["gText_SectionMustBeCompleted"] = 0x5EC688, + ["gText_SeeYa"] = 0x5EBBAA, + ["gText_SeeYaDescription"] = 0x5EBC6C, + ["gText_SeismicToss24BP"] = 0x5ECE16, + ["gText_Select"] = 0x5E8504, + ["gText_SelectFirstPkmn"] = 0x5EEBC1, + ["gText_SelectPkmnToAccept"] = 0x5EEC8B, + ["gText_SelectPkmnToSwap"] = 0x5EEC73, + ["gText_SelectPokedexListingMode"] = 0x5E881F, + ["gText_SelectPokedexMode"] = 0x5E8806, + ["gText_SelectSecondPkmn"] = 0x5EEBDB, + ["gText_SelectTheAnswer"] = 0x5EC752, + ["gText_SelectTheAnswerToTheQuiz"] = 0x5EC31A, + ["gText_SelectThirdPkmn"] = 0x5EEBF6, + ["gText_SelectWithoutRegisteredItem"] = 0x272C98, + ["gText_SelectorArrow"] = 0x5E8B3A, + ["gText_SelectorArrow2"] = 0x5E8D60, + ["gText_SelectorArrow3"] = 0x5EE498, + ["gText_Send"] = 0x5EEE38, + ["gText_SendMailToPC"] = 0x5E9855, + ["gText_SendOut"] = 0x5E96A6, + ["gText_SendingWonderCard"] = 0x5EF360, + ["gText_SendingWonderNews"] = 0x5EF37C, + ["gText_SetEventNumX"] = 0x27E95F, + ["gText_SetEventNumX2"] = 0x27E9B9, + ["gText_SetKOTourneyResults"] = 0x5EE27C, + ["gText_SetTheAnswer"] = 0x5EC584, + ["gText_SetTheQuizAnswer"] = 0x5EC366, + ["gText_SettledDownJustLittleBit"] = 0x27DBE0, + ["gText_SeverelyCheapenedOtherAppeals"] = 0x27D961, + ["gText_Shift"] = 0x5E96A0, + ["gText_ShiftJudgesAttentionFromOthers"] = 0x27CE5F, + ["gText_ShopBuy"] = 0x5E93C7, + ["gText_ShopQuit"] = 0x5E93D0, + ["gText_ShopSell"] = 0x5E93CB, + ["gText_Silkiness"] = 0x5ED820, + ["gText_Silver"] = 0x5EA890, + ["gText_SilverCard"] = 0x5EE67E, + ["gText_Single"] = 0x5ECB64, + ["gText_Single2"] = 0x5EB1F3, + ["gText_SingleBattle"] = 0x5EAE6E, + ["gText_SingleBattleHallResults"] = 0x5EE221, + ["gText_SingleBattleRoomResults"] = 0x5EE158, + ["gText_SingleBattleTourneyResults"] = 0x5EE1DB, + ["gText_SizeComparedTo"] = 0x5E8723, + ["gText_SizeSlash"] = 0x5E9225, + ["gText_Skill"] = 0x5CCDC0, + ["gText_SkyPillar"] = 0x5EB7D0, + ["gText_Slash"] = 0x5E8D3E, + ["gText_Slateport"] = 0x5EAD41, + ["gText_SlateportCity"] = 0x5EB7A9, + ["gText_Sleep"] = 0x5CCB4A, + ["gText_SlightlyStartleAppealed"] = 0x27CD0F, + ["gText_SlightlyStartleFrontMon"] = 0x27CCE7, + ["gText_Slots"] = 0x5ECB0C, + ["gText_Slp"] = 0x5EAE39, + ["gText_Smart"] = 0x5EB0F9, + ["gText_Smart2"] = 0x5EB130, + ["gText_SmartMove"] = 0x27E8B4, + ["gText_Smartness"] = 0x5EC7CB, + ["gText_SmartnessContest"] = 0x5EADD5, + ["gText_SmoochumDoll32BP"] = 0x5ECC03, + ["gText_Snore24BP"] = 0x5ECECE, + ["gText_SnorlaxDoll128BP"] = 0x5ECCBA, + ["gText_Snow"] = 0x5EFC5B, + ["gText_SoSo"] = 0x5ECB34, + ["gText_SodaPopAndPrice"] = 0x5EAED6, + ["gText_Softboiled"] = 0x27D48E, + ["gText_Softboiled16BP"] = 0x5ECE04, + ["gText_SomeoneDroppedOut"] = 0x5EDE2C, + ["gText_SomeoneDroppedOut2"] = 0x5EDF3E, + ["gText_SomeonesPC"] = 0x5EB169, + ["gText_Son"] = 0x5EB60A, + ["gText_Sorry"] = 0x5ED434, + ["gText_SorryRecordCornerPreparation"] = 0x273125, + ["gText_SorryTradeCenterInspections"] = 0x2730E5, + ["gText_SorryWirelessClubAdjustments"] = 0x27306F, + ["gText_Sound"] = 0x5EE5B5, + ["gText_SoundMono"] = 0x5EE61D, + ["gText_SoundStereo"] = 0x5EE628, + ["gText_Sour"] = 0x5E9334, + ["gText_Sour2"] = 0x5ECB5F, + ["gText_SouthernIsland"] = 0x5EB2AD, + ["gText_SpAtk"] = 0x5EEA63, + ["gText_SpAtk3"] = 0x5E96E6, + ["gText_SpAtk4"] = 0x5EA34B, + ["gText_SpDef"] = 0x5EEA6B, + ["gText_SpDef3"] = 0x5E96EE, + ["gText_SpDef4"] = 0x5EA353, + ["gText_Space"] = 0x5E8D5E, + ["gText_Space2"] = 0x5CCB82, + ["gText_SpaceAndSpace"] = 0x5CCB79, + ["gText_SpaceForVar1Full"] = 0x5E957F, + ["gText_SpaceMin"] = 0x5ED830, + ["gText_SpacePoints"] = 0x5EDE5D, + ["gText_SpacePoints2"] = 0x5EDE7C, + ["gText_SpaceSec"] = 0x5ED840, + ["gText_SpaceSeconds"] = 0x5EB5E5, + ["gText_SpaceTimes"] = 0x5EB5EE, + ["gText_SpaceTimes2"] = 0x5ED7E8, + ["gText_SpaceTimes3"] = 0x5EDE84, + ["gText_SpecialAppealsEffectWoreOff"] = 0x27D88F, + ["gText_Speed"] = 0x5EEA5D, + ["gText_Speed2"] = 0x5EA35B, + ["gText_SpenserDefeatGold"] = 0x2C86B3, + ["gText_SpenserDefeatSilver"] = 0x2C8662, + ["gText_SpenserWonGold"] = 0x2C8682, + ["gText_SpenserWonSilver"] = 0x2C8628, + ["gText_Spicy"] = 0x5E931D, + ["gText_Spicy2"] = 0x5ECB48, + ["gText_StampSentTo"] = 0x5EF3F0, + ["gText_StandoutMonHustledEvenMore"] = 0x27DE44, + ["gText_Start"] = 0x5E84FE, + ["gText_StartleAllAppealed"] = 0x27CD73, + ["gText_StartleAllAppealed2"] = 0x27CE2C, + ["gText_StartleAppealedBeforeUser"] = 0x27CD3F, + ["gText_StartleAppealedBeforeUser2"] = 0x27CDF8, + ["gText_StartleMonHasJudgesAttention"] = 0x27CE89, + ["gText_StartleMonsMadeSameTypeAppeal"] = 0x27CEEF, + ["gText_StatRose"] = 0x5CB38A, + ["gText_Status"] = 0x5EA398, + ["gText_Steven"] = 0x5EAD14, + ["gText_StevenMatchCallDesc"] = 0x5EFB04, + ["gText_StevenMatchCallName"] = 0x5EFB11, + ["gText_StoleAttentionAwayFromMon"] = 0x27D926, + ["gText_StoodOutAsMuchAsMon"] = 0x27DB03, + ["gText_StoodOutAsMuchAsSpecialAppeals"] = 0x27DADA, + ["gText_StoodOutMuchMoreThanMonBefore"] = 0x27E17F, + ["gText_StoodOutToMakeUpForBeingJammed"] = 0x27DA31, + ["gText_StopGivingPkmnMail"] = 0x5EC3B4, + ["gText_StopGivingPkmnMail2"] = 0x5EC557, + ["gText_StopLearningMove2"] = 0x5E9B5D, + ["gText_StopPuttingAwayDecorations"] = 0x5EAA62, + ["gText_StoppedCaringAboutOtherMons"] = 0x27DC7C, + ["gText_StorageSystemOpened"] = 0x2726A3, + ["gText_Store"] = 0x5E8D1A, + ["gText_StoreChosenDecorInPC"] = 0x5EA7F5, + ["gText_StoreItemsInPC"] = 0x5EAB7B, + ["gText_StowCase"] = 0x5E9344, + ["gText_StrVar1"] = 0x5ED82C, + ["gText_Sudowoodo_Attacked"] = 0x2731BD, + ["gText_Summary"] = 0x5EEC15, + ["gText_Summary2"] = 0x5EECAA, + ["gText_Summary5"] = 0x5E96B6, + ["gText_Sunny"] = 0x5EFC49, + ["gText_Sunny2"] = 0x5EFC4F, + ["gText_Sunny3"] = 0x5EFC99, + ["gText_SunnyDay"] = 0x27D4BD, + ["gText_Super"] = 0x5EB10C, + ["gText_SuperRank"] = 0x5EB1D1, + ["gText_Swap"] = 0x5EECA5, + ["gText_Swap2"] = 0x5EECE4, + ["gText_SwapNotes"] = 0x5EB4D4, + ["gText_SwapNumber"] = 0x5EB4C7, + ["gText_SwapPartners"] = 0x5EB4B9, + ["gText_Sweet"] = 0x5E9327, + ["gText_Sweet2"] = 0x5ECB52, + ["gText_Swift24BP"] = 0x5ECEEB, + ["gText_Switch"] = 0x5EA3C8, + ["gText_Switch2"] = 0x5E96AF, + ["gText_SwitchPokedexListings"] = 0x5E87D6, + ["gText_SwitchedPkmnItem"] = 0x5E99DB, + ["gText_SwordsDance"] = 0x27D4A5, + ["gText_SwordsDance48BP"] = 0x5ECEA6, + ["gText_Symbols"] = 0x5ED24F, + ["gText_Symbols2"] = 0x5EB415, + ["gText_SymbolsEarned"] = 0x5ED8FF, + ["gText_TM13AndPrice"] = 0x5EB0D5, + ["gText_TM24AndPrice"] = 0x5EB0C2, + ["gText_TM29AndPrice"] = 0x5EB09C, + ["gText_TM32AndPrice"] = 0x5EB089, + ["gText_TM35AndPrice"] = 0x5EB0AF, + ["gText_TMHMContainedVar1"] = 0x5E9058, + ["gText_TMHMPocket"] = 0x5E91DF, + ["gText_TMs_Hms"] = 0x5EB157, + ["gText_Take"] = 0x5E8D15, + ["gText_Take2"] = 0x5E96D3, + ["gText_TakeGoodCareOfX"] = 0x5EE977, + ["gText_TakeOutItemsFromPC"] = 0x5EAB92, + ["gText_Tasty"] = 0x5E9339, + ["gText_TateLizaMatchCallDesc"] = 0x5EFB9E, + ["gText_TauntedMonsDoingWell"] = 0x27DDC7, + ["gText_TeachWhichMoveToPkmn"] = 0x5EF8C2, + ["gText_TeachWhichPokemon"] = 0x5E9E64, + ["gText_TeachWhichPokemon2"] = 0x5E9E2E, + ["gText_TellHimTheWords"] = 0x5EEAE2, + ["gText_TemporarilyStopCrowdExcited"] = 0x27D436, + ["gText_Tension"] = 0x27E88C, + ["gText_TextSpeed"] = 0x5EE590, + ["gText_TextSpeedFast"] = 0x5EE5E9, + ["gText_TextSpeedMid"] = 0x5EE5DF, + ["gText_TextSpeedSlow"] = 0x5EE5D4, + ["gText_ThankYou"] = 0x5ED440, + ["gText_ThankYouForAccessingMysteryGift"] = 0x273178, + ["gText_ThankYouForWaiting"] = 0x2729F0, + ["gText_ThankYouIllSendItHome"] = 0x5E94E7, + ["gText_ThanksIllSendItHome"] = 0x5E9510, + ["gText_ThatIsntAnEgg"] = 0x5EA320, + ["gText_ThatsTrendyAlready"] = 0x5EC509, + ["gText_ThatsYourLastPkmn"] = 0x5EB8E6, + ["gText_TheAnswer"] = 0x5EC1F8, + ["gText_TheAnswerColon"] = 0x5EC37B, + ["gText_TheBagIsFull"] = 0x272A89, + ["gText_TheBardsSong"] = 0x5EC5F6, + ["gText_TheBardsSong2"] = 0x5EC228, + ["gText_TheBattle"] = 0x5E9153, + ["gText_TheDecorWasTransferredToThePC"] = 0x272B48, + ["gText_TheField"] = 0x5E9149, + ["gText_TheGreatNewHope"] = 0x5CCD30, + ["gText_TheImage"] = 0x5EC2F4, + ["gText_TheMailMessage"] = 0x5EC203, + ["gText_TheMailSalutation"] = 0x5EC214, + ["gText_ThePC"] = 0x5E9178, + ["gText_ThePokemonList"] = 0x5E915E, + ["gText_ThePreviousChampion"] = 0x5CCD82, + ["gText_TheQuizAnswerIs"] = 0x5EC6F2, + ["gText_TheQuizColon"] = 0x5EC387, + ["gText_TheShop"] = 0x5E916F, + ["gText_TheTrainersImage"] = 0x5EC2DF, + ["gText_TheTrendySaying"] = 0x5EC26C, + ["gText_TheUnbeatenChampion"] = 0x5CCD9A, + ["gText_TheWorst"] = 0x5ECB3E, + ["gText_ThereIsNoBattleRecord"] = 0x5EDA96, + ["gText_TheseThreePkmnOkay"] = 0x5EEC2D, + ["gText_Third"] = 0x5ECB87, + ["gText_Third_PM"] = 0x5EA0AB, + ["gText_ThisIsAPokemon"] = 0x5E8692, + ["gText_ThreeDashes"] = 0x5E8D45, + ["gText_ThreeMarks"] = 0x5E8D34, + ["gText_ThreeQuestionMarks"] = 0x5EEA42, + ["gText_ThrewAwayVar2Var1s"] = 0x5E8EF9, + ["gText_ThrowAwayItem"] = 0x5E9E00, + ["gText_ThrowAwayItemsInPC"] = 0x5EABAE, + ["gText_ThrowAwayUnwantedDecors"] = 0x5EA81C, + ["gText_ThrowAwayVar1"] = 0x5E9353, + ["gText_ThrowAwayWonderCard"] = 0x5EF050, + ["gText_ThrowInPremierBall"] = 0x5E95EA, + ["gText_ThunderPunch48BP"] = 0x5ECF37, + ["gText_ThunderWave48BP"] = 0x5ECE92, + ["gText_Time"] = 0x5E84CE, + ["gText_TimeBoard"] = 0x5EF88A, + ["gText_TimeCleared"] = 0x5EF895, + ["gText_TimeColon"] = 0x5ED808, + ["gText_TimeColon2"] = 0x5EE69D, + ["gText_TimesCleared"] = 0x5EE36D, + ["gText_TimesPerSec"] = 0x5ED850, + ["gText_TimesUpNoGoodPowder"] = 0x5ED788, + ["gText_TimesVar1"] = 0x5EE392, + ["gText_ToTeachHerAGoodSaying"] = 0x5EC2AE, + ["gText_TogepiDoll48BP"] = 0x5ECC18, + ["gText_TooBadBagIsFull"] = 0x272AD0, + ["gText_TooImportantToToss"] = 0x5E8EB9, + ["gText_TorchicDollAndPrice"] = 0x5EAFCF, + ["gText_Toss"] = 0x5EEE40, + ["gText_Toss2"] = 0x5EA7C3, + ["gText_TossHowManyVar1s"] = 0x5E8EE0, + ["gText_TossItem"] = 0x5EAB71, + ["gText_Total"] = 0x5EE332, + ["gText_TotalRecordWLD"] = 0x5EF6A1, + ["gText_TotodileDoll80BP"] = 0x5ECC91, + ["gText_TouchedJudgeForFantasticAppeal"] = 0x27DA85, + ["gText_Tough"] = 0x5EB0FF, + ["gText_Tough2"] = 0x5EB136, + ["gText_ToughMove"] = 0x27E8BF, + ["gText_Toughness"] = 0x5EC7D6, + ["gText_ToughnessContest"] = 0x5EADE7, + ["gText_TourneyTree"] = 0x5EB496, + ["gText_Tourney_Tree"] = 0x5EB1A9, + ["gText_TowerInfo"] = 0x5EB42F, + ["gText_Toxic"] = 0x5EAE45, + ["gText_Trade"] = 0x5ED41D, + ["gText_Trade4"] = 0x5E96DD, + ["gText_TradeCenter"] = 0x5EB372, + ["gText_TrainerCardIDNo"] = 0x5ECF78, + ["gText_TrainerCardMoney"] = 0x5ECF7E, + ["gText_TrainerCardName"] = 0x5ECF71, + ["gText_TrainerCardPokedex"] = 0x5ECF86, + ["gText_TrainerCardTime"] = 0x5ECF99, + ["gText_TrainerCloseBy"] = 0x5EBF95, + ["gText_TrainerHill1F"] = 0x5EF8B6, + ["gText_TrainerHill2F"] = 0x5EF8B9, + ["gText_TrainerHill3F"] = 0x5EF8BC, + ["gText_TrainerHill4F"] = 0x5EF8BF, + ["gText_TreeckoDollAndPrice"] = 0x5EAFB6, + ["gText_TriedToDazzleOthers"] = 0x27DCE7, + ["gText_TriedToJamMonDoingWell"] = 0x27DE14, + ["gText_TriedToStartleOtherMons"] = 0x27DCB4, + ["gText_TriedToStartleOtherPokemon"] = 0x27E473, + ["gText_TriedToUnnerveNextMon"] = 0x27DD3D, + ["gText_Tristan"] = 0x5EAB0F, + ["gText_TuckerDefeatGold"] = 0x2C84F7, + ["gText_TuckerDefeatSilver"] = 0x2C84C1, + ["gText_TuckerWonGold"] = 0x2C84D0, + ["gText_TuckerWonSilver"] = 0x2C848B, + ["gText_TurnOff"] = 0x5EAB2D, + ["gText_TurnOrderMoreEasilyScrambled"] = 0x27D336, + ["gText_TurnedOverVar1ForVar2"] = 0x5E9677, + ["gText_TwoDashes"] = 0x5E8D42, + ["gText_TwoStyles"] = 0x5EB3DF, + ["gText_TypeSlash"] = 0x5EA378, + ["gText_TypesOfContests"] = 0x5EAD84, + ["gText_UndergoingAdjustments"] = 0x2730BC, + ["gText_Underpowered"] = 0x5EB516, + ["gText_UnionRoomChatKeyboard_01234Lower"] = 0x62BA59, + ["gText_UnionRoomChatKeyboard_01234Upper"] = 0x62BA1D, + ["gText_UnionRoomChatKeyboard_56789Lower"] = 0x62BA5F, + ["gText_UnionRoomChatKeyboard_56789Upper"] = 0x62BA23, + ["gText_UnionRoomChatKeyboard_ABCDE"] = 0x62B9F9, + ["gText_UnionRoomChatKeyboard_Emoji1"] = 0x62BA79, + ["gText_UnionRoomChatKeyboard_Emoji10"] = 0x62BADA, + ["gText_UnionRoomChatKeyboard_Emoji2"] = 0x62BA84, + ["gText_UnionRoomChatKeyboard_Emoji3"] = 0x62BA8F, + ["gText_UnionRoomChatKeyboard_Emoji4"] = 0x62BA9A, + ["gText_UnionRoomChatKeyboard_Emoji5"] = 0x62BAA3, + ["gText_UnionRoomChatKeyboard_Emoji6"] = 0x62BAAE, + ["gText_UnionRoomChatKeyboard_Emoji7"] = 0x62BAB9, + ["gText_UnionRoomChatKeyboard_Emoji8"] = 0x62BAC4, + ["gText_UnionRoomChatKeyboard_Emoji9"] = 0x62BACF, + ["gText_UnionRoomChatKeyboard_FGHIJ"] = 0x62B9FF, + ["gText_UnionRoomChatKeyboard_KLMNO"] = 0x62BA05, + ["gText_UnionRoomChatKeyboard_PQRST"] = 0x62BA0B, + ["gText_UnionRoomChatKeyboard_PunctuationLower"] = 0x62BA65, + ["gText_UnionRoomChatKeyboard_PunctuationUpper"] = 0x62BA29, + ["gText_UnionRoomChatKeyboard_SymbolsLower"] = 0x62BA6B, + ["gText_UnionRoomChatKeyboard_SymbolsUpper"] = 0x62BA2F, + ["gText_UnionRoomChatKeyboard_UVWXY"] = 0x62BA11, + ["gText_UnionRoomChatKeyboard_Z"] = 0x62BA17, + ["gText_UnionRoomChatKeyboard_abcde"] = 0x62BA35, + ["gText_UnionRoomChatKeyboard_fghij"] = 0x62BA3B, + ["gText_UnionRoomChatKeyboard_klmno"] = 0x62BA41, + ["gText_UnionRoomChatKeyboard_pqrst"] = 0x62BA47, + ["gText_UnionRoomChatKeyboard_uvwxy"] = 0x62BA4D, + ["gText_UnionRoomChatKeyboard_z"] = 0x62BA53, + ["gText_UnionTradesAndBattles"] = 0x5ED01F, + ["gText_UniqueTagMatch"] = 0x5EB350, + ["gText_UnkHeight"] = 0x5E86C6, + ["gText_UnkWeight"] = 0x5E86D0, + ["gText_Unknown"] = 0x5EC00F, + ["gText_UnusedCancel"] = 0x5ED92A, + ["gText_UnusedExit"] = 0x5EBE89, + ["gText_UnusedNicknameReceivedPokemon"] = 0x272DB3, + ["gText_UpdatedTime"] = 0x5EE6B6, + ["gText_UpdatingSaveExternalData"] = 0x5E836D, + ["gText_UponLosingABattle"] = 0x5EC5E0, + ["gText_UponWinningABattle"] = 0x5EC5C9, + ["gText_Upper"] = 0x5ED23C, + ["gText_UpsConditionHelpsPreventNervousness"] = 0x27D26E, + ["gText_UseOnWhichPokemon"] = 0x5E9E79, + ["gText_UsedRepeatedlyWithoutBoringJudge"] = 0x27CC03, + ["gText_UsedVar2WildLured"] = 0x5E90C6, + ["gText_UsedVar2WildRepelled"] = 0x5E90F4, + ["gText_UserLessLikelyStartled"] = 0x27CCAE, + ["gText_UserMoreEasilyStartled"] = 0x27CB9B, + ["gText_Var1AndYouWantedVar2"] = 0x5E943F, + ["gText_Var1AteTheVar2"] = 0x5E937F, + ["gText_Var1Berry"] = 0x5ED7FC, + ["gText_Var1CantBeHeld"] = 0x5E8E11, + ["gText_Var1CantBeHeldHere"] = 0x5E8E27, + ["gText_Var1CertainlyHowMany"] = 0x5E93EF, + ["gText_Var1CertainlyHowMany2"] = 0x5E9417, + ["gText_Var1DisdainfullyAteVar2"] = 0x5E93A9, + ["gText_Var1DotVar2"] = 0x5E9233, + ["gText_Var1HappilyAteVar2"] = 0x5E9390, + ["gText_Var1IsItThatllBeVar2"] = 0x5E9468, + ["gText_Var1IsSelected"] = 0x5E8DBE, + ["gText_Var1Percent"] = 0x5ED85C, + ["gText_Var1Players"] = 0x5ED8F4, + ["gText_Var1ThrownAway"] = 0x5E9367, + ["gText_Var1sTrainerCard"] = 0x5ECFA6, + ["gText_Var3Var1SlashVar2"] = 0x5EA7A9, + ["gText_VarietyOfEventsImportedWireless"] = 0x5EEE48, + ["gText_VarietyTagMatch"] = 0x5EB33E, + ["gText_VenusaurDoll256BP"] = 0x5ECCCF, + ["gText_VeryGood"] = 0x5ECB20, + ["gText_Vicegrip"] = 0x27D4CC, + ["gText_ViewRecordedBattle"] = 0x5ED961, + ["gText_VolcanoAsh"] = 0x5EFC6E, + ["gText_Vs"] = 0x5CCDB5, + ["gText_WTWeight"] = 0x5E8758, + ["gText_WaitForAllChooseBerry"] = 0x5ED62C, + ["gText_WaitingTrainerFinishReading"] = 0x5ED042, + ["gText_Wallace"] = 0x5EAD0C, + ["gText_WallyMatchCallDesc"] = 0x5EFB3E, + ["gText_WantToPlayAgain"] = 0x5EDE18, + ["gText_WantToPlayAgain2"] = 0x5EDF2A, + ["gText_WantToUseSurf"] = 0x272FD6, + ["gText_WasEnhanced"] = 0x5EC7E1, + ["gText_WasItWorriedAboutYou"] = 0x5EB987, + ["gText_WattsonMatchCallDesc"] = 0x5EFB7B, + ["gText_WeHopeToSeeYouAgain"] = 0x2727DB, + ["gText_WeHopeToSeeYouAgain2"] = 0x272A07, + ["gText_WelcomeCutShort"] = 0x2727F5, + ["gText_WelcomeToHOF"] = 0x5E8B3E, + ["gText_WentBetterThanUsual"] = 0x27D612, + ["gText_WhatDoYouWantToDo"] = 0x5EB802, + ["gText_WhatToDoWithCards"] = 0x5EF2FC, + ["gText_WhatToDoWithNews"] = 0x5EF330, + ["gText_WhatToDoWithVar1sMail"] = 0x5EAC63, + ["gText_WhatWillWallyDo"] = 0x5CCA15, + ["gText_WhatWouldYouLike"] = 0x5E8D80, + ["gText_WhatWouldYouLikeToDo"] = 0x5EBB56, + ["gText_WhatYouSayIfYouLose"] = 0x5EC1D6, + ["gText_WhatYouSayIfYouWin"] = 0x5EC1B5, + ["gText_WhatsAContest"] = 0x5EAD72, + ["gText_WhatsHipAndHappening"] = 0x5EC606, + ["gText_Wheelies"] = 0x5EAF1B, + ["gText_WhenInDanger"] = 0x5EB523, + ["gText_WhereShouldCardBeAccessed"] = 0x5EEF68, + ["gText_WhereShouldNewsBeAccessed"] = 0x5EEF94, + ["gText_WhichFloorWouldYouLike"] = 0x272C1D, + ["gText_WhichMoveToForget"] = 0x5E9B96, + ["gText_WhichMoveToForget4"] = 0x5CCA93, + ["gText_WhichOneWillYouTake"] = 0x5EB92D, + ["gText_WhichPCShouldBeAccessed"] = 0x27266F, + ["gText_WhiteFlute"] = 0x5EB639, + ["gText_WhiteFlute2"] = 0x5EAF87, + ["gText_WhiteHerb48BP"] = 0x5ECD6F, + ["gText_WhitePokeblock"] = 0x5E9306, + ["gText_WillChampionshipDreamComeTrue"] = 0x5CCD45, + ["gText_Win"] = 0x5CCB9E, + ["gText_WinLoseDraw"] = 0x5EF6BD, + ["gText_WinStreak"] = 0x5EE302, + ["gText_WinnerIsPlayersMon"] = 0x27E910, + ["gText_WinnerIsPlayersMonCongrats"] = 0x27E8E8, + ["gText_Winona"] = 0x5EAD22, + ["gText_WinonaMatchCallDesc"] = 0x5EFB94, + ["gText_WinsLosses"] = 0x5ECFED, + ["gText_WinsStraight"] = 0x5ED0E0, + ["gText_WirelessCommStatus"] = 0x5EED41, + ["gText_WirelessCommunication"] = 0x5EEE08, + ["gText_WirelessNotConnected"] = 0x5E82BE, + ["gText_WithFourPhrases"] = 0x5EC104, + ["gText_WithdrawHowManyItems"] = 0x5EAC09, + ["gText_WithdrawItem"] = 0x5EAB63, + ["gText_WithdrawMonDescription"] = 0x5EBBB2, + ["gText_WithdrawPokemon"] = 0x5EBB71, + ["gText_WithdrawXItems"] = 0x5EAC22, + ["gText_WonContestsWFriends"] = 0x5ED09F, + ["gText_WonderCardReceived"] = 0x5EF124, + ["gText_WonderCardReceivedFrom"] = 0x5EF0C8, + ["gText_WonderCardSentTo"] = 0x5EF39C, + ["gText_WonderCardThrownAway"] = 0x5EF60C, + ["gText_WonderCards"] = 0x5EEDEC, + ["gText_WonderCardsInPossession"] = 0x5EEE8C, + ["gText_WonderNews"] = 0x5EEDFC, + ["gText_WonderNewsReceived"] = 0x5EF14C, + ["gText_WonderNewsReceivedFrom"] = 0x5EF0F4, + ["gText_WonderNewsSentTo"] = 0x5EF3C4, + ["gText_WonderNewsThrownAway"] = 0x5EF630, + ["gText_WontEatAnymore"] = 0x5EC800, + ["gText_WontHaveEffect"] = 0x5E96F6, + ["gText_WorkedAsMuchAsMonBefore"] = 0x27DEA5, + ["gText_WorkedAsMuchAsPrecedingMon"] = 0x27DF02, + ["gText_WorkedFrighteninglyWell"] = 0x27D69C, + ["gText_WorkedHardAsStandoutMon"] = 0x27D6BA, + ["gText_WorkedHardToBuildOnPastMistakes"] = 0x27D654, + ["gText_WorkedLittleBetterThanUsual"] = 0x27D71D, + ["gText_WorkedRatherWell"] = 0x27D706, + ["gText_WorksWellIfDifferentTypeAsBefore"] = 0x27D20A, + ["gText_WorksWellIfSameTypeAsBefore"] = 0x27D1D6, + ["gText_WorsenConditionOfThoseMadeAppeals"] = 0x27D061, + ["gText_WouldYouLikeToRestYourPkmn"] = 0x2726EB, + ["gText_XDotY"] = 0x5ED7F4, + ["gText_XDotY2"] = 0x5ED838, + ["gText_XDotY3"] = 0x5ED848, + ["gText_XMinYDotZSec"] = 0x5EF8A3, + ["gText_XNature"] = 0x5EA5DB, + ["gText_XNatureFatefulEncounter"] = 0x5EA56A, + ["gText_XNatureHatchedAtYZ"] = 0x5EA516, + ["gText_XNatureHatchedSomewhereAt"] = 0x5EA617, + ["gText_XNatureMetAtYZ"] = 0x5EA4EC, + ["gText_XNatureMetSomewhereAt"] = 0x5EA5EB, + ["gText_XNatureObtainedInTrade"] = 0x5EA544, + ["gText_XNatureProbablyMetAt"] = 0x5EA5A8, + ["gText_XSentOverY"] = 0x5EE966, + ["gText_XWillBeSentToY"] = 0x5EE942, + ["gText_YaySmileEmoji"] = 0x5ED43A, + ["gText_YellowFlute"] = 0x5EB622, + ["gText_YellowFlute2"] = 0x5EAF70, + ["gText_YellowPokeblock"] = 0x5E92A1, + ["gText_YellowShard"] = 0x5EB04A, + ["gText_Yes"] = 0x5EAE62, + ["gText_Yes2"] = 0x5EEC49, + ["gText_Yes3"] = 0x5EECCA, + ["gText_YesNo"] = 0x5EE491, + ["gText_YouCannotQuitHere"] = 0x5EC672, + ["gText_YouCantReleaseAnEgg"] = 0x5EB946, + ["gText_YouDontHaveMoney"] = 0x5E9539, + ["gText_YouDontHaveThreeCoins"] = 0x5EF734, + ["gText_YouHaveNoBerries"] = 0x5ED724, + ["gText_YouWantTheUsual"] = 0x272982, + ["gText_YouWantedVar1ThatllBeVar2"] = 0x5E9493, + ["gText_YourFeelingAtTheBattlesStart"] = 0x5EC192, + ["gText_YourName"] = 0x5EEABE, + ["gText_YourPartnerHasRetired"] = 0x24979B, + ["gText_YourPartysFull"] = 0x5EB900, + ["gText_YourProfile"] = 0x5EC185, + ["gText_YoureHoldingAPkmn"] = 0x5EB913, + ["gText_YouveGot9999Coins"] = 0x5EF6F8, + ["gText_YouveRunOutOfCoins"] = 0x5EF710, + ["gText_Zinc1000"] = 0x5EB74B, + ["gText_Zinc1BP"] = 0x5ECD3A, + ["gText_xVar1"] = 0x5E8DA0, + ["gTrickHouse_Mechadoll_55Yen"] = 0x27ED74, + ["gTrickHouse_Mechadoll_60Yen"] = 0x27ED70, + ["gTrickHouse_Mechadoll_Azurill"] = 0x27ECD5, + ["gTrickHouse_Mechadoll_BurnHeal"] = 0x27ED4F, + ["gTrickHouse_Mechadoll_Carvanha"] = 0x27ED46, + ["gTrickHouse_Mechadoll_CostLess"] = 0x27ED95, + ["gTrickHouse_Mechadoll_CostMore"] = 0x27ED80, + ["gTrickHouse_Mechadoll_Dustox"] = 0x27ECEB, + ["gTrickHouse_Mechadoll_Eight"] = 0x27EE03, + ["gTrickHouse_Mechadoll_Eight2"] = 0x27EE09, + ["gTrickHouse_Mechadoll_ElderlyLadies"] = 0x27EDD5, + ["gTrickHouse_Mechadoll_ElderlyMen"] = 0x27EDC9, + ["gTrickHouse_Mechadoll_Female"] = 0x27EDBA, + ["gTrickHouse_Mechadoll_Four"] = 0x27EDFD, + ["gTrickHouse_Mechadoll_HarborMail"] = 0x27ED59, + ["gTrickHouse_Mechadoll_Lotad"] = 0x27ECDD, + ["gTrickHouse_Mechadoll_Male"] = 0x27EDB5, + ["gTrickHouse_Mechadoll_Neither"] = 0x27EDC1, + ["gTrickHouse_Mechadoll_Nincada"] = 0x27ECF8, + ["gTrickHouse_Mechadoll_None"] = 0x27EDF0, + ["gTrickHouse_Mechadoll_Nothing"] = 0x27ED78, + ["gTrickHouse_Mechadoll_Oddish"] = 0x27ECBC, + ["gTrickHouse_Mechadoll_One"] = 0x27EDF5, + ["gTrickHouse_Mechadoll_Poochyena"] = 0x27ECC3, + ["gTrickHouse_Mechadoll_Poochyena2"] = 0x27ED18, + ["gTrickHouse_Mechadoll_Poochyena3"] = 0x27ED36, + ["gTrickHouse_Mechadoll_Ralts"] = 0x27ED00, + ["gTrickHouse_Mechadoll_SameNumber"] = 0x27EDE4, + ["gTrickHouse_Mechadoll_SamePrice"] = 0x27ED65, + ["gTrickHouse_Mechadoll_SamePrice2"] = 0x27EDAA, + ["gTrickHouse_Mechadoll_Seven"] = 0x27EE01, + ["gTrickHouse_Mechadoll_Seven2"] = 0x27EE07, + ["gTrickHouse_Mechadoll_Shroomish"] = 0x27ED22, + ["gTrickHouse_Mechadoll_Six"] = 0x27EDFF, + ["gTrickHouse_Mechadoll_Six2"] = 0x27EE05, + ["gTrickHouse_Mechadoll_Slakoth"] = 0x27ED10, + ["gTrickHouse_Mechadoll_Taillow"] = 0x27ECCD, + ["gTrickHouse_Mechadoll_Three"] = 0x27EDFB, + ["gTrickHouse_Mechadoll_Two"] = 0x27EDF7, + ["gTrickHouse_Mechadoll_Two2"] = 0x27EDF9, + ["gTrickHouse_Mechadoll_Wingull"] = 0x27ECE3, + ["gTrickHouse_Mechadoll_Zigzagoon"] = 0x27ED06, + ["gTrickHouse_Mechadoll_Zigzagoon2"] = 0x27ED2C, + ["gTrickHouse_Mechadoll_Zubat"] = 0x27ECF2, + ["gTrickHouse_Mechadoll_Zubat2"] = 0x27ED40, + ["sCreditsText_AiMashima"] = 0x5E6264, + ["sCreditsText_AimiTomita"] = 0x5E5FB9, + ["sCreditsText_AkihitoTomisawa"] = 0x5E60BF, + ["sCreditsText_AkikoShinozaki"] = 0x5E6152, + ["sCreditsText_AkiraKinashi"] = 0x5E6229, + ["sCreditsText_AkitoMori"] = 0x5E5EF8, + ["sCreditsText_ArtDirector"] = 0x5E5C34, + ["sCreditsText_Artwork"] = 0x5E5E62, + ["sCreditsText_AstukoFujii"] = 0x5E6162, + ["sCreditsText_AsukaIwashita"] = 0x5E5FAA, + ["sCreditsText_AtsukoNishida"] = 0x5E5FE6, + ["sCreditsText_AtsushiTada"] = 0x5E61CC, + ["sCreditsText_AustralianBrailleAuthority"] = 0x5E6575, + ["sCreditsText_AzusaTajima"] = 0x5E63C6, + ["sCreditsText_BattleDirector"] = 0x5E5C41, + ["sCreditsText_BattleFrontierData"] = 0x5E5E39, + ["sCreditsText_BattleSystemPgrms"] = 0x5E5C61, + ["sCreditsText_BrailleCodeCheck"] = 0x5E5E17, + ["sCreditsText_ChiakiShinkai"] = 0x5E64A4, + ["sCreditsText_ChieMatsumiya"] = 0x5E6143, + ["sCreditsText_Coordinators"] = 0x5E5DC6, + ["sCreditsText_Credits"] = 0x5E5C10, + ["sCreditsText_DaisukeHoshino"] = 0x5E634C, + ["sCreditsText_Director"] = 0x5E5C2B, + ["sCreditsText_EmptyString"] = 0x5E5BF7, + ["sCreditsText_EnglishVersion"] = 0x5E63FF, + ["sCreditsText_EnvAndToolPgrms"] = 0x5E5D84, + ["sCreditsText_ErikJohnson"] = 0x5E65ED, + ["sCreditsText_EuropeanBlindUnion"] = 0x5E6560, + ["sCreditsText_ExecProducers"] = 0x5E5DDD, + ["sCreditsText_ExecutiveDirector"] = 0x5E5C18, + ["sCreditsText_FieldSystemPgrms"] = 0x5E5C7B, + ["sCreditsText_FujikoNomura"] = 0x5E6398, + ["sCreditsText_GakujiNomoto"] = 0x5E626F, + ["sCreditsText_GameDesigners"] = 0x5E5D0A, + ["sCreditsText_GoIchinose"] = 0x5E6010, + ["sCreditsText_GraphicDesigner"] = 0x5E6443, + ["sCreditsText_GraphicDesigners"] = 0x5E5CB5, + ["sCreditsText_HidenoriSaeki"] = 0x5E647A, + ["sCreditsText_HideyukiNakajima"] = 0x5E6468, + ["sCreditsText_HiroNakamura"] = 0x5E61FB, + ["sCreditsText_HiroakiTsuru"] = 0x5E62CA, + ["sCreditsText_HirokiEnomoto"] = 0x5E60D0, + ["sCreditsText_HiromiSagawa"] = 0x5E60FC, + ["sCreditsText_HironobuYoshida"] = 0x5E5F7A, + ["sCreditsText_HiroyukiJinnai"] = 0x5E62BA, + ["sCreditsText_HiroyukiNakamura"] = 0x5E5F22, + ["sCreditsText_HiroyukiUesugi"] = 0x5E6209, + ["sCreditsText_HisashiSogabe"] = 0x5E5EDB, + ["sCreditsText_HitomiSato"] = 0x5E6055, + ["sCreditsText_HitoshiYamagami"] = 0x5E628D, + ["sCreditsText_InfoSupervisors"] = 0x5E5DF1, + ["sCreditsText_JapanBrailleLibrary"] = 0x5E6317, + ["sCreditsText_JunOkutani"] = 0x5E5FDA, + ["sCreditsText_JunichiMasuda"] = 0x5E5E9D, + ["sCreditsText_KanakoEo"] = 0x5E5FD0, + ["sCreditsText_KazukiYoshihara"] = 0x5E63A6, + ["sCreditsText_KazuyaSuyama"] = 0x5E62F7, + ["sCreditsText_KazuyukiTerada"] = 0x5E60DF, + ["sCreditsText_KeitaKagaya"] = 0x5E5F03, + ["sCreditsText_KenSugimori"] = 0x5E5EAC, + ["sCreditsText_KenjiMatsushima"] = 0x5E6037, + ["sCreditsText_KenjiTominaga"] = 0x5E610A, + ["sCreditsText_KenjiroIto"] = 0x5E635C, + ["sCreditsText_KenkichiToyama"] = 0x5E617C, + ["sCreditsText_KimikoNakamichi"] = 0x5E64E0, + ["sCreditsText_KojiNishino"] = 0x5E602A, + ["sCreditsText_KyokoWatanabe"] = 0x5E629E, + ["sCreditsText_LeadGraphicArtist"] = 0x5E5E7A, + ["sCreditsText_LeadProgrammer"] = 0x5E5E6A, + ["sCreditsText_MainGraphicDesigner"] = 0x5E5C9F, + ["sCreditsText_MainProgrammer"] = 0x5E5C51, + ["sCreditsText_MakikoTakada"] = 0x5E6248, + ["sCreditsText_MapDataDesigners"] = 0x5E5D4F, + ["sCreditsText_MapDesigners"] = 0x5E5D41, + ["sCreditsText_MasaoTaya"] = 0x5E5F34, + ["sCreditsText_MichikoTakizawa"] = 0x5E6237, + ["sCreditsText_MikaKurosawa"] = 0x5E651E, + ["sCreditsText_MikikoOhhashi"] = 0x5E633D, + ["sCreditsText_MiyukiIwasawa"] = 0x5E5F5D, + ["sCreditsText_MorikazuAoki"] = 0x5E601C, + ["sCreditsText_MotofumiFujiwara"] = 0x5E5F8B, + ["sCreditsText_MotoyasuTojima"] = 0x5E65BD, + ["sCreditsText_MuneoSaito"] = 0x5E5FF5, + ["sCreditsText_MusicComposition"] = 0x5E5CD9, + ["sCreditsText_NCLCoordinator"] = 0x5E6433, + ["sCreditsText_NCLProductTesting"] = 0x5E5DA3, + ["sCreditsText_NCLSuperMarioClub"] = 0x5E61B7, + ["sCreditsText_NOAProductTesting"] = 0x5E6454, + ["sCreditsText_NaokoYanase"] = 0x5E61AA, + ["sCreditsText_NationalFederationBlind"] = 0x5E652C, + ["sCreditsText_NicolaPrattBarlow"] = 0x5E65CD, + ["sCreditsText_NobOgasawara"] = 0x5E64C1, + ["sCreditsText_NorihideOkamura"] = 0x5E61EA, + ["sCreditsText_NozomuSaito"] = 0x5E616F, + ["sCreditsText_PackageAndManual"] = 0x5E63E1, + ["sCreditsText_ParametricDesigners"] = 0x5E5D62, + ["sCreditsText_PatriciaAMaurer"] = 0x5E654D, + ["sCreditsText_PkmnDesigners"] = 0x5E5CC7, + ["sCreditsText_PkmnEmeraldVersion"] = 0x5E5BF8, + ["sCreditsText_PokedexText"] = 0x5E5D77, + ["sCreditsText_Producers"] = 0x5E5DD3, + ["sCreditsText_Programmers"] = 0x5E5C93, + ["sCreditsText_RenaYoshikawa"] = 0x5E6001, + ["sCreditsText_RetsujiNomoto"] = 0x5E63B7, + ["sCreditsText_RoyalNewZealandFederationBlind"] = 0x5E6592, + ["sCreditsText_RuiKawaguchi"] = 0x5E6368, + ["sCreditsText_SachikoHamano"] = 0x5E6134, + ["sCreditsText_SachikoNakamichi"] = 0x5E6386, + ["sCreditsText_SakaeKimura"] = 0x5E6497, + ["sCreditsText_SatoruIwata"] = 0x5E62EA, + ["sCreditsText_SatoshiMitsuhara"] = 0x5E6305, + ["sCreditsText_SatoshiNohara"] = 0x5E5F3F, + ["sCreditsText_SatoshiOhta"] = 0x5E5F9D, + ["sCreditsText_SatoshiTajiri"] = 0x5E5E8E, + ["sCreditsText_Scenario"] = 0x5E5D27, + ["sCreditsText_ScenarioPlot"] = 0x5E5D19, + ["sCreditsText_ScriptDesigners"] = 0x5E5D30, + ["sCreditsText_SethMcMahill"] = 0x5E64B3, + ["sCreditsText_ShellieDow"] = 0x5E65E1, + ["sCreditsText_ShigekiMorimoto"] = 0x5E5EB9, + ["sCreditsText_ShigeruOhmori"] = 0x5E608A, + ["sCreditsText_ShunsukeKohori"] = 0x5E6376, + ["sCreditsText_ShusakuEgami"] = 0x5E63D3, + ["sCreditsText_SosukeTamada"] = 0x5E5EEA, + ["sCreditsText_SouichiYamamoto"] = 0x5E64F1, + ["sCreditsText_SoundEffectsAndPkmnVoices"] = 0x5E5CEB, + ["sCreditsText_SpecialThanks"] = 0x5E5DB7, + ["sCreditsText_SuguruNakatsui"] = 0x5E618C, + ["sCreditsText_SupportProgrammers"] = 0x5E5E4E, + ["sCreditsText_TadashiTakahashi"] = 0x5E6099, + ["sCreditsText_TakahiroOhnishi"] = 0x5E61D9, + ["sCreditsText_TakanaoKondo"] = 0x5E6256, + ["sCreditsText_TakaoNakano"] = 0x5E62AD, + ["sCreditsText_TakaoUnno"] = 0x5E5FC5, + ["sCreditsText_TakehiroIzushi"] = 0x5E627D, + ["sCreditsText_TakenoriOhta"] = 0x5E5F6C, + ["sCreditsText_TakeshiKawachimaru"] = 0x5E6061, + ["sCreditsText_TaskManagers"] = 0x5E5E09, + ["sCreditsText_TeikoSasaki"] = 0x5E6127, + ["sCreditsText_TeresaLillygren"] = 0x5E64CF, + ["sCreditsText_TerukiMurakawa"] = 0x5E6219, + ["sCreditsText_TeruyukiShimoyamada"] = 0x5E6075, + ["sCreditsText_TetsujiOhta"] = 0x5E6048, + ["sCreditsText_TetsuyaWatanabe"] = 0x5E5ECA, + ["sCreditsText_TextEditor"] = 0x5E6427, + ["sCreditsText_ThomasHertzog"] = 0x5E650F, + ["sCreditsText_TomomichiOhta"] = 0x5E5F4E, + ["sCreditsText_TomotakaKomura"] = 0x5E632D, + ["sCreditsText_ToshinobuMatsumiya"] = 0x5E60AB, + ["sCreditsText_Translator"] = 0x5E641C, + ["sCreditsText_TsunekazIshihara"] = 0x5E62D8, + ["sCreditsText_WorldDirector"] = 0x5E5E2A, + ["sCreditsText_YokoWatanabe"] = 0x5E6489, + ["sCreditsText_YoshinoriMatsuda"] = 0x5E5F10, + ["sCreditsText_YoshioTajiri"] = 0x5E6119, + ["sCreditsText_YuichiroIto"] = 0x5E6502, + ["sCreditsText_YumiFunasaka"] = 0x5E619C, + ["sCreditsText_YuriSakurai"] = 0x5E60EF, + ["sEmptyItemName"] = 0x625403, + ["sExpandedPlaceholder_PokedexDescription"] = 0x55D30C, + ["sGenderlessIconString"] = 0x624BE4, + ["sJPText_BadTvReady"] = 0x339CDF, + ["sJPText_Chat"] = 0x2EDA0C, + ["sJPText_DistMysteryEvent"] = 0x2EDA38, + ["sJPText_DistWonderCard"] = 0x2EDA1C, + ["sJPText_DistWonderNews"] = 0x2EDA28, + ["sJPText_DoubleBattle"] = 0x2ED9D8, + ["sJPText_GoodTvReady"] = 0x339CD2, + ["sJPText_HoldBerryCrush"] = 0x2EDA5C, + ["sJPText_HoldBerryPicking"] = 0x2EDA70, + ["sJPText_HoldPokemonJump"] = 0x2EDA4C, + ["sJPText_HoldSpinShop"] = 0x2EDA94, + ["sJPText_HoldSpinTrade"] = 0x2EDA80, + ["sJPText_MultiBattle"] = 0x2ED9E8, + ["sJPText_PressBButtonToQuit"] = 0x32DB2E, + ["sJPText_SearchingForParticipants"] = 0x2EDBD0, + ["sJPText_SingleBattle"] = 0x2ED9C8, + ["sJPText_TradePokemon"] = 0x2ED9F8, + ["sJPText_Vowels"] = 0x2F7B78, + ["sJapaneseEggNickname"] = 0x32B708, + ["sMemoMiscTextColor"] = 0x61CE7B, + ["sMemoNatureTextColor"] = 0x61CE74, + ["sMovesPPLayout"] = 0x61CE97, + ["sStatsLeftColumnLayout"] = 0x61CE82, + ["sStatsRightColumnLayout"] = 0x61CE8E, + ["sText_123Poof"] = 0x5CB447, + ["sText_1PlayerNeeded"] = 0x2EDAE0, + ["sText_2PlayerMode"] = 0x2EDB27, + ["sText_2PlayersNeeded"] = 0x2EDAF1, + ["sText_3PlayerMode"] = 0x2EDB35, + ["sText_3PlayersNeeded"] = 0x2EDB03, + ["sText_4PlayerMode"] = 0x2EDB43, + ["sText_4PlayersNeeded"] = 0x2EDB15, + ["sText_5PlayerMode"] = 0x2EDB51, + ["sText_ABoosted"] = 0x5C9B99, + ["sText_AarghAlmostHadIt"] = 0x5CBF6A, + ["sText_Accuracy"] = 0x5CBDE9, + ["sText_AllyPkmnPrefix"] = 0x5CBD95, + ["sText_AllyPkmnPrefix2"] = 0x5CBD9E, + ["sText_AllyPkmnPrefix3"] = 0x5CBDA7, + ["sText_AlphabetUpperLower"] = 0x58BDC8, + ["sText_AnOKWasSentToPlayer"] = 0x2EDC9C, + ["sText_AndEllipsis"] = 0x5CB473, + ["sText_ApostropheS"] = 0x5CCBB9, + ["sText_ApostropheSPokeblockCaseIsFull"] = 0x3398DD, + ["sText_AreTheseMembersOK"] = 0x2EDC5C, + ["sText_AskTrainerToMakeTrade"] = 0x2EF65C, + ["sText_Attack"] = 0x5CCAF4, + ["sText_Attack2"] = 0x5CBDC4, + ["sText_AuroraTicketBagFull"] = 0x675497, + ["sText_AuroraTicketForYou"] = 0x6753A9, + ["sText_AuroraTicketThankYou"] = 0x67546A, + ["sText_AuroraTicketUseAtPort"] = 0x675406, + ["sText_AwaitingCommunication"] = 0x2ED960, + ["sText_AwaitingCommunucation2"] = 0x2ED934, + ["sText_AwaitingLinkPressStart"] = 0x2ED990, + ["sText_AwaitingOtherMembers"] = 0x2EDE48, + ["sText_AwaitingPlayersResponse"] = 0x2EFC3C, + ["sText_AwaitingPlayersResponseAboutTrade"] = 0x2EE004, + ["sText_AwaitingResponseFromPlayer"] = 0x2EE358, + ["sText_AwaitingResponseFromTrainer"] = 0x2EE32C, + ["sText_AwaitingResponseFromTrainer2"] = 0x2EF688, + ["sText_AwaitingResponseFromWirelessSystem"] = 0x2EFC90, + ["sText_BButtonCancel"] = 0x2EDBC4, + ["sText_Battle"] = 0x2EFD60, + ["sText_BattleChallenge"] = 0x2EE3DC, + ["sText_BattleDeclinedFemale"] = 0x2EEA14, + ["sText_BattleDeclinedMale"] = 0x2EE9B4, + ["sText_BattleSurpriseFemale"] = 0x2EEE4C, + ["sText_BattleSurpriseMale"] = 0x2EED98, + ["sText_BattleTowerLv50"] = 0x2EFEC0, + ["sText_BattleTowerOpenLv"] = 0x2EFED4, + ["sText_BattleWillBeStarted"] = 0x2EE94C, + ["sText_BeautyContest"] = 0x2EFE80, + ["sText_BellChimed"] = 0x5CB8CF, + ["sText_BerryBlender"] = 0x2EFE50, + ["sText_BerryBlenderStart"] = 0x33973A, + ["sText_BerryCrush"] = 0x2EFE2C, + ["sText_BerryPicking"] = 0x2EFE38, + ["sText_BerryProgramUpdate"] = 0x617E78, + ["sText_BerryProgramWillBeUpdatedPressA"] = 0x617EA3, + ["sText_BerrySuffix"] = 0x5CC0AD, + ["sText_BerryUsed"] = 0x339909, + ["sText_BlendingResults"] = 0x3398F5, + ["sText_BoxIsFull"] = 0x5CC074, + ["sText_ButItFailed"] = 0x5CB66F, + ["sText_ButNoEffect"] = 0x5CAECF, + ["sText_ButNoPPLeft"] = 0x5CB931, + ["sText_ButNothingHappened"] = 0x5CB659, + ["sText_CallingDots"] = 0x6227F4, + ["sText_Cancel"] = 0x32DAF9, + ["sText_CancelModeWithTheseMembers"] = 0x2EDC78, + ["sText_CancelRegistrationOfEgg"] = 0x2EF590, + ["sText_CancelRegistrationOfMon"] = 0x2EF564, + ["sText_CancelTrade"] = 0x32DB20, + ["sText_CanceledReadingCard"] = 0x2F257C, + ["sText_CantEscape"] = 0x5CB622, + ["sText_CantEscape2"] = 0x5C9D95, + ["sText_CantTradeForPartnersMon"] = 0x2EF774, + ["sText_CantTransmitTrainerTooFar"] = 0x2EDCEC, + ["sText_Cards"] = 0x2EFDF8, + ["sText_Chat"] = 0x2EFDF0, + ["sText_Chat2"] = 0x2EFD68, + ["sText_ChatDeclinedFemale"] = 0x2EECEC, + ["sText_ChatDeclinedMale"] = 0x2EECA4, + ["sText_ChatDropped"] = 0x2EE544, + ["sText_ChatEnded"] = 0x2EE598, + ["sText_ChatInvitation"] = 0x2EE430, + ["sText_ChooseAPkmn"] = 0x32DB00, + ["sText_ChooseJoinCancel"] = 0x2EF7DC, + ["sText_ChooseLeaderBattleTowerLv50"] = 0x2EFB08, + ["sText_ChooseLeaderBattleTowerOpenLv"] = 0x2EFB3C, + ["sText_ChooseLeaderBeautyContest"] = 0x2EFA50, + ["sText_ChooseLeaderBerryBlender"] = 0x2EF9CC, + ["sText_ChooseLeaderBerryCrush"] = 0x2EF974, + ["sText_ChooseLeaderBerryPicking"] = 0x2EF99C, + ["sText_ChooseLeaderCoolContest"] = 0x2EFA24, + ["sText_ChooseLeaderCuteContest"] = 0x2EFA7C, + ["sText_ChooseLeaderMultiBattle"] = 0x2EF874, + ["sText_ChooseLeaderPokemonJump"] = 0x2EF940, + ["sText_ChooseLeaderRecordCorner"] = 0x2EF9F8, + ["sText_ChooseLeaderSmartContest"] = 0x2EFAA8, + ["sText_ChooseLeaderToughContest"] = 0x2EFAD8, + ["sText_ChooseRequestedMonType"] = 0x2EF47C, + ["sText_ChooseTrainer"] = 0x2EF7F8, + ["sText_ChooseTrainerDoubleBattle"] = 0x2EF844, + ["sText_ChooseTrainerSingleBattle"] = 0x2EF814, + ["sText_ChooseTrainerToShareWonderCards"] = 0x2EF8D0, + ["sText_ChooseTrainerToShareWonderNews"] = 0x2EF908, + ["sText_ChooseTrainerToTradeWith"] = 0x2EF8A4, + ["sText_ChooseTrainerToTradeWith2"] = 0x2EF614, + ["sText_Clear17"] = 0x597C8C, + ["sText_CoinsScattered"] = 0x5CB86D, + ["sText_Colon"] = 0x2ED8C8, + ["sText_Communicating"] = 0x2EE02C, + ["sText_CommunicatingPleaseWait"] = 0x2EDFE8, + ["sText_CommunicatingWithPlayer"] = 0x2EE098, + ["sText_CommunicationStandby"] = 0x32DB49, + ["sText_CoolContest"] = 0x2EFE70, + ["sText_CriticalHit"] = 0x5CB424, + ["sText_CuteContest"] = 0x2EFE90, + ["sText_DashesNoPlayer"] = 0x5B3D84, + ["sText_DashesNoScore"] = 0x5B3D8C, + ["sText_DeclineChatMale"] = 0x2EEC50, + ["sText_Defense"] = 0x5CCAFB, + ["sText_Defense2"] = 0x5CBDCB, + ["sText_Digits"] = 0x2F7B88, + ["sText_DoSomethingAgainFemale"] = 0x2EE2B8, + ["sText_DoSomethingAgainMale"] = 0x2EE294, + ["sText_DoSomethingFemale"] = 0x2EE274, + ["sText_DoSomethingMale"] = 0x2EE25C, + ["sText_DoYouWantXMode"] = 0x2EDFA8, + ["sText_DoYouWantXMode2"] = 0x2EDFC4, + ["sText_DoneWaitingBattleFemale"] = 0x2EE8D4, + ["sText_DoneWaitingBattleMale"] = 0x2EE880, + ["sText_DoneWaitingChatFemale"] = 0x2EE904, + ["sText_DoneWaitingChatMale"] = 0x2EE8B8, + ["sText_DontBeAThief"] = 0x5CBEC8, + ["sText_DontHaveEggTrainerWants"] = 0x2EF718, + ["sText_DontHaveTypeTrainerWants"] = 0x2EF6E4, + ["sText_DontLeaveBirch"] = 0x5CB631, + ["sText_Dot"] = 0x339941, + ["sText_Dot2"] = 0x339970, + ["sText_DoubleBattle"] = 0x2EFDC0, + ["sText_DownpourContinues"] = 0x5CB6F7, + ["sText_DownpourStarted"] = 0x5CB6CB, + ["sText_Dude"] = 0x3397C4, + ["sText_EggTrade"] = 0x2EF7D0, + ["sText_ElectricityWeakened"] = 0x5CADC5, + ["sText_Ellipsis"] = 0x2F2DC4, + ["sText_Emerald"] = 0x617E9B, + ["sText_EmptyStatus"] = 0x5CD422, + ["sText_EmptyString4"] = 0x5C9B98, + ["sText_EmptyString8"] = 0x5CBD90, + ["sText_EnigmaBerry"] = 0x5CC0A0, + ["sText_EnsureGBAConnectionMatches"] = 0x617F07, + ["sText_EnteringChat"] = 0x2EE96C, + ["sText_Evasiveness"] = 0x5CBDF2, + ["sText_ExclamationMark"] = 0x5CBDB7, + ["sText_ExclamationMark2"] = 0x5CBDB9, + ["sText_ExclamationMark3"] = 0x5CBDBB, + ["sText_ExclamationMark4"] = 0x5CBDBD, + ["sText_ExclamationMark5"] = 0x5CBDBF, + ["sText_Exit"] = 0x2EFD7C, + ["sText_Exit2"] = 0x2EFD84, + ["sText_FailedToSpitUp"] = 0x5CB7E5, + ["sText_FailedToSwallow"] = 0x5CB807, + ["sText_FaintInThree"] = 0x5CB8DE, + ["sText_FemaleTraded1"] = 0x2EF178, + ["sText_FemaleTraded2"] = 0x2EF198, + ["sText_Final"] = 0x5CCD17, + ["sText_FinishedCheckingPlayersTrainerCard"] = 0x2F0020, + ["sText_FireWeakened"] = 0x5CADE7, + ["sText_FoePkmnPrefix"] = 0x5CBD8B, + ["sText_FoePkmnPrefix2"] = 0x5CBD91, + ["sText_FoePkmnPrefix3"] = 0x5CBD9A, + ["sText_FoePkmnPrefix4"] = 0x5CBDA3, + ["sText_FourQuestionMarks"] = 0x32DADF, + ["sText_FullParty"] = 0x674E6A, + ["sText_GladToMeetYouFemale"] = 0x2EFFFC, + ["sText_GladToMeetYouMale"] = 0x2EFFDC, + ["sText_GotAwaySafely"] = 0x5CB4CA, + ["sText_Greetings"] = 0x2EFD70, + ["sText_HMMovesCantBeForgotten"] = 0x5CB479, + ["sText_HP"] = 0x5CCAF1, + ["sText_HP2"] = 0x5CBDC1, + ["sText_HailContinues"] = 0x5CB7BB, + ["sText_HailStopped"] = 0x5CB7D3, + ["sText_HasNoBerriesToPut"] = 0x3398B0, + ["sText_HiDoSomethingAgainFemale"] = 0x2EE218, + ["sText_HiDoSomethingAgainMale"] = 0x2EE1E0, + ["sText_HiDoSomethingFemale"] = 0x2EE1B8, + ["sText_HiDoSomethingMale"] = 0x2EE188, + ["sText_HofTime"] = 0x56FB4C, + ["sText_ID"] = 0x2ED8CC, + ["sText_IfYouWantToDoSomethingFemale"] = 0x2EEB4C, + ["sText_IfYouWantToDoSomethingMale"] = 0x2EEB10, + ["sText_Info"] = 0x2EFD8C, + ["sText_IsThisTradeOkay"] = 0x32DAE5, + ["sText_ItAppearedCaught"] = 0x5CBF4B, + ["sText_ItDodgedBall"] = 0x5CBEDA, + ["sText_ItHurtConfusion"] = 0x5CB67E, + ["sText_ItIsRaining"] = 0x5CC04E, + ["sText_ItSuckedLiquidOoze"] = 0x5CB25A, + ["sText_ItemsCantBeUsedNow"] = 0x5CAE3D, + ["sText_ItsBronzeCard"] = 0x2EFF00, + ["sText_ItsCopperCard"] = 0x2EFF14, + ["sText_ItsGoldCard"] = 0x2EFF3C, + ["sText_ItsNormalCard"] = 0x2EFEEC, + ["sText_ItsSilverCard"] = 0x2EFF28, + ["sText_JoinChatFemale"] = 0x2EE638, + ["sText_JoinChatMale"] = 0x2EE5C0, + ["sText_Laddie"] = 0x3397AF, + ["sText_Lanettes"] = 0x5CC01F, + ["sText_Lassie"] = 0x3397B6, + ["sText_LearnedSomethingFemale"] = 0x2EEF7C, + ["sText_LearnedSomethingMale"] = 0x2EEECC, + ["sText_Letters"] = 0x2F7B80, + ["sText_LinkWithFriendDropped"] = 0x2EDF40, + ["sText_Lv"] = 0x32DAD6, + ["sText_MaleTraded1"] = 0x2EF110, + ["sText_MaleTraded2"] = 0x2EF138, + ["sText_Master"] = 0x3397BD, + ["sText_MaximumSpeed"] = 0x33992E, + ["sText_MemberNoLongerAvailable"] = 0x2EDD8C, + ["sText_Min"] = 0x339921, + ["sText_MirrorMoveFailed"] = 0x5CB69F, + ["sText_Miss"] = 0x3397C9, + ["sText_Mister"] = 0x3397A8, + ["sText_ModeWithTheseMembersWillBeCanceled"] = 0x2EDD58, + ["sText_MultiBattle"] = 0x2EFDD0, + ["sText_MustHaveTwoMonsForDoubleBattle"] = 0x2EFC00, + ["sText_MysteryGiftAlteringCave"] = 0x675708, + ["sText_MysteryGiftBattleCountCard"] = 0x675184, + ["sText_MysteryGiftBattleCountCard_WonPrize"] = 0x67529C, + ["sText_MysteryGiftEgg"] = 0x674DDE, + ["sText_MysteryGiftOldSeaMapBagFull"] = 0x67596A, + ["sText_MysteryGiftOldSeaMapForYou"] = 0x675861, + ["sText_MysteryGiftOldSeaMapThankYou"] = 0x67593D, + ["sText_MysteryGiftOldSeaMapUseAtPort"] = 0x6758D9, + ["sText_MysteryGiftStampCard"] = 0x674CDD, + ["sText_MysteryGiftVisitingTrainerArrived"] = 0x675035, + ["sText_MysteryGiftVisitingTrainerInstructions"] = 0x674EEA, + ["sText_MysticTicketBagFull"] = 0x67566F, + ["sText_MysticTicketForYou"] = 0x675581, + ["sText_MysticTicketThankYou"] = 0x675642, + ["sText_MysticTicketUseAtPort"] = 0x6755DE, + ["sText_NameWantedOfferLv"] = 0x2EFD94, + ["sText_NeedTwoMonsOfLevel30OrLower1"] = 0x2EEBD0, + ["sText_NeedTwoMonsOfLevel30OrLower2"] = 0x2EEC14, + ["sText_NewParagraph"] = 0x33979B, + ["sText_No000"] = 0x55D2B8, + ["sText_NoCardsSharedRightNow"] = 0x2EFD04, + ["sText_NoNewsSharedRightNow"] = 0x2EFD30, + ["sText_NoPPLeft"] = 0x5CB90E, + ["sText_NoRunningFromTrainers"] = 0x5CB5F3, + ["sText_NobodyHasRegistered"] = 0x2EF438, + ["sText_NotRegisteredAMonForTrade"] = 0x2EF6B4, + ["sText_NotVeryEffective"] = 0x5CB49B, + ["sText_OfferDeclined1"] = 0x2EE560, + ["sText_OfferDeclined2"] = 0x2EE57C, + ["sText_OfferToTradeEgg"] = 0x2EE4F0, + ["sText_OfferToTradeMon"] = 0x2EE47C, + ["sText_OneHitKO"] = 0x5CB434, + ["sText_OnlyPkmnForBattle"] = 0x32DB9C, + ["sText_OtherTrainerUnavailableNow"] = 0x2EDCB4, + ["sText_OutOf30"] = 0x571737, + ["sText_OutOfSafariBalls"] = 0x5CBB02, + ["sText_PkmnBrokeFree"] = 0x5CBF2B, + ["sText_PlayerCantTradeForYourMon"] = 0x2EF740, + ["sText_PlayerContactedYouAddToMembers"] = 0x2EDC34, + ["sText_PlayerContactedYouForXAccept"] = 0x2EDBE8, + ["sText_PlayerContactedYouShareX"] = 0x2EDC0C, + ["sText_PlayerHasBeenAskedToRegisterYouPleaseWait"] = 0x2EFC54, + ["sText_PlayerHasContactedYou"] = 0x2EE308, + ["sText_PlayerJoinChatFemale"] = 0x2EE674, + ["sText_PlayerJoinChatMale"] = 0x2EE5F8, + ["sText_PlayerOKdRegistration"] = 0x2EDE0C, + ["sText_PlayerRepliedNo"] = 0x2EDE34, + ["sText_PlayerRepliedNo2"] = 0x2EDF6C, + ["sText_PlayerSentBackOK"] = 0x2EDDF4, + ["sText_PleaseFollowInstructionsOnScreen"] = 0x618092, + ["sText_PleaseStartOver"] = 0x2ED8D0, + ["sText_PleaseWaitForOtherTrainersToGather"] = 0x2EFCCC, + ["sText_PokeblockWasTooBitter"] = 0x5CBE4B, + ["sText_PokeblockWasTooDry"] = 0x5CBE2F, + ["sText_PokeblockWasTooSour"] = 0x5CBE5B, + ["sText_PokeblockWasTooSpicy"] = 0x5CBE20, + ["sText_PokeblockWasTooSweet"] = 0x5CBE3C, + ["sText_PokemonJump"] = 0x2EFE1C, + ["sText_PokemonTrades"] = 0x2EFDE0, + ["sText_PokenavCallEllipsis"] = 0x60EF70, + ["sText_PressAToStart"] = 0x3397E8, + ["sText_Pyramid"] = 0x57F53C, + ["sText_PyramidFloor1"] = 0x57F4CC, + ["sText_PyramidFloor2"] = 0x57F4DC, + ["sText_PyramidFloor3"] = 0x57F4EC, + ["sText_PyramidFloor4"] = 0x57F4FC, + ["sText_PyramidFloor5"] = 0x57F50C, + ["sText_PyramidFloor6"] = 0x57F51C, + ["sText_PyramidFloor7"] = 0x57F52C, + ["sText_QuestionForfeitMatch"] = 0x5CD1EC, + ["sText_QuitBeingMember"] = 0x2EDE64, + ["sText_RPM"] = 0x33993C, + ["sText_RainContinues"] = 0x5CB6DF, + ["sText_RainStopped"] = 0x5CB70F, + ["sText_RandomChatFemale1"] = 0x2EEFBC, + ["sText_RandomChatFemale2"] = 0x2EEFEC, + ["sText_RandomChatMale1"] = 0x2EEF2C, + ["sText_RandomChatMale2"] = 0x2EEF50, + ["sText_Ranking"] = 0x339947, + ["sText_RecordCorner"] = 0x2EFE60, + ["sText_RefDraw"] = 0x5CD0DB, + ["sText_RefIfNothingIsDecided"] = 0x5CCF12, + ["sText_RefJudgeBody"] = 0x5CD029, + ["sText_RefJudgeMind"] = 0x5CCF9B, + ["sText_RefJudgeSkill"] = 0x5CCFE2, + ["sText_RefThatsIt"] = 0x5CCF54, + ["sText_RegisterMonAtTradingBoard"] = 0x2EF20C, + ["sText_RegistrationCanceled"] = 0x2EF4FC, + ["sText_RegistrationCanceled2"] = 0x2EF5B8, + ["sText_RegistrationCompleted"] = 0x2EF520, + ["sText_Round1"] = 0x5CCCFD, + ["sText_Round2"] = 0x5CCD05, + ["sText_RubySapphire"] = 0x617E8D, + ["sText_RunOutOfBerriesForBlending"] = 0x339858, + ["sText_SandstormBrewed"] = 0x5CB721, + ["sText_SandstormIsRaging"] = 0x5CC05D, + ["sText_SandstormRages"] = 0x5CB735, + ["sText_SandstormSubsided"] = 0x5CB74A, + ["sText_Search"] = 0x2EFE48, + ["sText_SearchingForWirelessSystemWait"] = 0x2EFBC8, + ["sText_Sec"] = 0x339928, + ["sText_Semifinal"] = 0x5CCD0D, + ["sText_SharedPain"] = 0x5CB8AF, + ["sText_ShedinjaJP"] = 0x32DF94, + ["sText_ShedinjaJapaneseName"] = 0x5B58C4, + ["sText_ShedinjaJpnName"] = 0x31ACA0, + ["sText_ShootSoClose"] = 0x5CBF80, + ["sText_ShowTrainerCard"] = 0x2EE380, + ["sText_ShowTrainerCardDeclinedFemale"] = 0x2EEAC0, + ["sText_ShowTrainerCardDeclinedMale"] = 0x2EEA70, + ["sText_ShowTrainerCardFemale"] = 0x2EE7F8, + ["sText_ShowTrainerCardMale"] = 0x2EE754, + ["sText_ShowedTrainerCardFemale1"] = 0x2EF090, + ["sText_ShowedTrainerCardFemale2"] = 0x2EF0D0, + ["sText_ShowedTrainerCardMale1"] = 0x2EF030, + ["sText_ShowedTrainerCardMale2"] = 0x2EF06C, + ["sText_SingleBattle"] = 0x2EFDB0, + ["sText_Slash"] = 0x32DAD4, + ["sText_SmartContest"] = 0x2EFEA0, + ["sText_SomebodyHasContactedYou"] = 0x2EE2E8, + ["sText_Someones"] = 0x5CC015, + ["sText_SoothingAroma"] = 0x5CAE13, + ["sText_SpAtk"] = 0x5CCB03, + ["sText_SpAtk2"] = 0x5CBDD9, + ["sText_SpDef"] = 0x5CCB0B, + ["sText_SpDef2"] = 0x5CBDE1, + ["sText_Space"] = 0x339945, + ["sText_SpaceBerry"] = 0x339914, + ["sText_SpaceIs"] = 0x5CCBB5, + ["sText_SpaceMove"] = 0x32DACC, + ["sText_Speed"] = 0x5CBDD3, + ["sText_SpikesScattered"] = 0x5CA900, + ["sText_StartedHail"] = 0x5CB7A7, + ["sText_StartedToRain"] = 0x5CB6B7, + ["sText_StatChangesGone"] = 0x5CB84B, + ["sText_StatFell"] = 0x5CB399, + ["sText_StatHarshly"] = 0x5CB390, + ["sText_StatSharply"] = 0x5CB381, + ["sText_StoppedBeingMember"] = 0x2EDE7C, + ["sText_Summary"] = 0x32DB12, + ["sText_Summary2"] = 0x32DB3B, + ["sText_SunlightFaded"] = 0x5CB793, + ["sText_SunlightGotBright"] = 0x5CB762, + ["sText_SunlightStrong"] = 0x5CB77B, + ["sText_SuperEffective"] = 0x5CB4B4, + ["sText_SwitchedMonsFemale"] = 0x2EEE78, + ["sText_SwitchedMonsMale"] = 0x2EEDBC, + ["sText_TenDashes"] = 0x55D2FE, + ["sText_TenDashes2"] = 0x56E688, + ["sText_ThankYouForRegistering"] = 0x2EF3D4, + ["sText_ThatsFunnyFemale"] = 0x2EEFA0, + ["sText_ThatsFunnyMale"] = 0x2EEEF0, + ["sText_TheFeelIs"] = 0x33995D, + ["sText_TheLevelIs"] = 0x33994F, + ["sText_TheTradeHasBeenCanceled"] = 0x32DB76, + ["sText_TheWallShattered"] = 0x5CAEBB, + ["sText_ThreeDashes"] = 0x32DADB, + ["sText_Time"] = 0x33991B, + ["sText_TooWeakForSubstitute"] = 0x5CB889, + ["sText_ToughContest"] = 0x2EFEB0, + ["sText_Trade"] = 0x32DB1A, + ["sText_Trade2"] = 0x32DB43, + ["sText_TradeCanceled"] = 0x2EF544, + ["sText_TradeOfferRejected"] = 0x2EF7B0, + ["sText_TradeTrainersWillBeListed"] = 0x2EF5E0, + ["sText_TradeWillBeStarted"] = 0x2EE92C, + ["sText_TradingBoardInfo"] = 0x2EF298, + ["sText_TrainerAppearsBusy"] = 0x2EE6C8, + ["sText_TrainerAppearsUnavailable"] = 0x2EDDCC, + ["sText_TrainerBattleBusy"] = 0x2EEB88, + ["sText_TrainerBlockedBall"] = 0x5CBEAA, + ["sText_TrainerCardInfoPage1"] = 0x2EFF64, + ["sText_TrainerCardInfoPage2"] = 0x2EFFA4, + ["sText_TrainersNotReadyYet"] = 0x2EDD24, + ["sText_TransmissionFailureTryAgain"] = 0x6180D7, + ["sText_TransmittingPleaseWait"] = 0x618020, + ["sText_TurnOffPowerHoldingStartSelect"] = 0x617F97, + ["sText_UnusedEmpty"] = 0x32DAE4, + ["sText_UnusedTextFormat"] = 0x32DABD, + ["sText_UseNextPkmn"] = 0x5C9C6F, + ["sText_UsedGoodMoveFemale"] = 0x2EEE24, + ["sText_UsedGoodMoveMale"] = 0x2EED6C, + ["sText_WaitForBattleFemale"] = 0x2EE78C, + ["sText_WaitForBattleMale"] = 0x2EE6EC, + ["sText_WaitForChatFemale"] = 0x2EE7C0, + ["sText_WaitForChatMale"] = 0x2EE720, + ["sText_WaitForChatMale2"] = 0x2EE84C, + ["sText_WaitingForYourFriend"] = 0x32DBC1, + ["sText_WasMade"] = 0x33979D, + ["sText_WhichMonWillYouOffer"] = 0x2EF4C4, + ["sText_WildPkmnPrefix"] = 0x5CBD85, + ["sText_WindBecameHeatWave"] = 0x5CB829, + ["sText_WirelessLinkDropped"] = 0x2EDF04, + ["sText_WirelessLinkEstablished"] = 0x2EDEC4, + ["sText_WirelessSearchCanceled"] = 0x2ED8F8, + ["sText_WonderCards"] = 0x2EFE00, + ["sText_WonderNews"] = 0x2EFE10, + ["sText_WouldLikeToBlendAnotherBerry"] = 0x339831, + ["sText_XCheckedTradingBoard"] = 0x2EF1EC, + ["sText_YouMissedPkmn"] = 0x5CBF13, + ["sText_YouThrowABallNowRight"] = 0x5CC23F, + ["sText_YourFriendWantsToTrade"] = 0x32DBED, + ["sText_YourPokeblockCaseIsFull"] = 0x339896, + ["sText_YoureToughFemale"] = 0x2EEDF4, + ["sText_YoureToughMale"] = 0x2EED44, + ["sUnusedText_2"] = 0x3396FB, + ["sUnusedText_LinkPartnerNotFound"] = 0x33970C, + ["sUnusedText_Space"] = 0x3396FD, + ["sUnusedText_Terminating"] = 0x3396FF, + ["sUnusedText_YesNo"] = 0x3396F4, + ["sUnusedTransparentWhite"] = 0x2ED160, + ["stext_DeclineChatFemale"] = 0x2EEC7C, + }, + battle = { + ["gText_BattleRecordedOnPass"] = 0x5CD2CB, + ["gText_DefendersStatRose"] = 0x5CB3AA, + ["gText_Judgment"] = 0x5CCDCB, + ["gText_OpponentMon1Name"] = 0x5CCDB8, + ["gText_PkmnGettingPumped"] = 0x5CA459, + ["gText_PkmnShroudedInMist"] = 0x5CA424, + ["gText_PkmnsXPreventsSwitching"] = 0x5CB2A1, + ["gText_PlayerMon1Name"] = 0x5CCDB2, + ["gText_WhatWillPkmnDo"] = 0x5CC9F3, + ["gText_WhatWillPkmnDo2"] = 0x5CCA04, + ["sText_AttackMissed"] = 0x5C9C81, + ["sText_AttackerCantEscape"] = 0x5C9DA4, + ["sText_AttackerFainted"] = 0x5C9D16, + ["sText_AttackerUsedX"] = 0x5CBDAC, + ["sText_AttackersStatFell"] = 0x5CB3CF, + ["sText_AttackersStatRose"] = 0x5CB39F, + ["sText_AvoidedDamage"] = 0x5C9CAA, + ["sText_CantGetCloser"] = 0x5CBA5D, + ["sText_CreptCloser"] = 0x5CBA46, + ["sText_DefeatedOpponentByReferee"] = 0x5CD107, + ["sText_DefendersStatFell"] = 0x5CB3DA, + ["sText_DidNotLearnMove"] = 0x5C9C59, + ["sText_DoItPkmn"] = 0x5CBCAD, + ["sText_EnemyAboutToSwitchPkmn"] = 0x5CB9ED, + ["sText_FoeStoppedWorking"] = 0x5CA096, + ["sText_ForXCommaYZ"] = 0x5CAE59, + ["sText_ForfeitedMatch"] = 0x5CD21E, + ["sText_GiveNicknameCaptured"] = 0x5CBFDB, + ["sText_GlintAppearsInEye"] = 0x5CCE9F, + ["sText_GoForItPkmn"] = 0x5CBCB8, + ["sText_GoPkmn"] = 0x5CBC8E, + ["sText_GoPkmn2"] = 0x5CBCA5, + ["sText_GoTwoPkmn"] = 0x5CBC96, + ["sText_GotchaPkmnCaughtPlayer"] = 0x5CBF9D, + ["sText_GotchaPkmnCaughtWally"] = 0x5CBFBB, + ["sText_HitXTimes"] = 0x5C9DB5, + ["sText_InGamePartnerSentOutZGoN"] = 0x5CCE38, + ["sText_ItDoesntAffect"] = 0x5C9D00, + ["sText_ItemAllowsOnlyYMove"] = 0x5CC206, + ["sText_LegendaryPkmnAppeared"] = 0x5CBB5A, + ["sText_LinkPartnerSentOutPkmnGoPkmn"] = 0x5CBCE5, + ["sText_LinkTrainer1WithdrewPkmn"] = 0x5CBD65, + ["sText_LinkTrainer2WithdrewPkmn"] = 0x5CBD75, + ["sText_LinkTrainerMultiSentOutPkmn"] = 0x5CBC7E, + ["sText_LinkTrainerSentOutPkmn"] = 0x5CBC27, + ["sText_LinkTrainerSentOutPkmn2"] = 0x5CBC6E, + ["sText_LinkTrainerSentOutTwoPkmn"] = 0x5CBC37, + ["sText_LinkTrainerWantsToBattle"] = 0x5CBBB9, + ["sText_LinkTrainerWantsToBattlePause"] = 0x5CD301, + ["sText_LostToOpponentByReferee"] = 0x5CD13C, + ["sText_MagnitudeStrength"] = 0x5CA9A2, + ["sText_NaturePowerTurnedInto"] = 0x5CAADD, + ["sText_OpponentMon1Appeared"] = 0x5CBB39, + ["sText_PkmnAbsorbedNutrients"] = 0x5CAC35, + ["sText_PkmnAfflictedByCurse"] = 0x5CA8E2, + ["sText_PkmnAlreadyAsleep"] = 0x5C9DEC, + ["sText_PkmnAlreadyAsleep2"] = 0x5C9E02, + ["sText_PkmnAlreadyConfused"] = 0x5CA0F5, + ["sText_PkmnAlreadyHasBurn"] = 0x5C9EEB, + ["sText_PkmnAlreadyPoisoned"] = 0x5C9E68, + ["sText_PkmnAnchoredItself"] = 0x5CAC5B, + ["sText_PkmnAnchorsItselfWith"] = 0x5CB0D5, + ["sText_PkmnAttack"] = 0x5CAA94, + ["sText_PkmnAvoidedAttack"] = 0x5C9CE9, + ["sText_PkmnBadlyPoisoned"] = 0x5C9E80, + ["sText_PkmnBeganGrowlingDeeply"] = 0x5CCEDA, + ["sText_PkmnBeganToNap"] = 0x5CB98B, + ["sText_PkmnBlewAwaySpikes"] = 0x5CAA21, + ["sText_PkmnBlownAway"] = 0x5CA150, + ["sText_PkmnBracedItself"] = 0x5CA97C, + ["sText_PkmnBuffetedBySandstorm"] = 0x5CA49B, + ["sText_PkmnBurnedBy"] = 0x5C9EC0, + ["sText_PkmnCalmedDown"] = 0x5CA5B3, + ["sText_PkmnCantSleepInUproar"] = 0x5CA5C3, + ["sText_PkmnCantSleepInUproar2"] = 0x5CA613, + ["sText_PkmnCantStockpile"] = 0x5CA5F6, + ["sText_PkmnCantUseMoveSealed"] = 0x5CAF6D, + ["sText_PkmnCantUseMoveTaunt"] = 0x5CAF4B, + ["sText_PkmnCantUseMoveTorment"] = 0x5CAF12, + ["sText_PkmnCausedUproar"] = 0x5CA586, + ["sText_PkmnCenterAttention"] = 0x5CAAA1, + ["sText_PkmnChangedType"] = 0x5CA163, + ["sText_PkmnChangedTypeWith"] = 0x5CB02B, + ["sText_PkmnChargingPower"] = 0x5CAAC4, + ["sText_PkmnChoseXAsDestiny"] = 0x5CAA78, + ["sText_PkmnClamped"] = 0x5CA3D2, + ["sText_PkmnComeBack"] = 0x5CBD1B, + ["sText_PkmnCopiedFoe"] = 0x5CABE7, + ["sText_PkmnCopiedStatChanges"] = 0x5CA9D8, + ["sText_PkmnCoveredByVeil"] = 0x5CA228, + ["sText_PkmnCrashed"] = 0x5CA409, + ["sText_PkmnCuriousAboutX"] = 0x5CBA90, + ["sText_PkmnCutHPMaxedAttack"] = 0x5CA9B0, + ["sText_PkmnCutsAttackWith"] = 0x5CB0F0, + ["sText_PkmnDataAddedToDex"] = 0x5CC029, + ["sText_PkmnDreamEaten"] = 0x5CA02F, + ["sText_PkmnDugHole"] = 0x5CA333, + ["sText_PkmnEagerForMore"] = 0x5CCEF4, + ["sText_PkmnEncoreEnded"] = 0x5CA7D3, + ["sText_PkmnEnduredHit"] = 0x5CA98E, + ["sText_PkmnEnergyDrained"] = 0x5C9E96, + ["sText_PkmnEnthralledByX"] = 0x5CBAAC, + ["sText_PkmnEvadedAttack"] = 0x5CA4F3, + ["sText_PkmnFastAsleep"] = 0x5CA52E, + ["sText_PkmnFatigueConfusion"] = 0x5CA69B, + ["sText_PkmnFellAsleep"] = 0x5C9DC5, + ["sText_PkmnFellForTaunt"] = 0x5CAB53, + ["sText_PkmnFellInLove"] = 0x5CA10D, + ["sText_PkmnFellIntoNightmare"] = 0x5CA881, + ["sText_PkmnFledFromBattle"] = 0x5CAA36, + ["sText_PkmnFledUsing"] = 0x5CB4FB, + ["sText_PkmnFledUsingIts"] = 0x5CB4E0, + ["sText_PkmnFlewHigh"] = 0x5CA322, + ["sText_PkmnFlinched"] = 0x5CA184, + ["sText_PkmnForesawAttack"] = 0x5CAA4B, + ["sText_PkmnForgotMove"] = 0x5C9C35, + ["sText_PkmnFreedFrom"] = 0x5CA3F3, + ["sText_PkmnFrozenBy"] = 0x5C9F17, + ["sText_PkmnGainedEXP"] = 0x5C9B7B, + ["sText_PkmnGettingIntoPosition"] = 0x5CCEBD, + ["sText_PkmnGoodComeBack"] = 0x5CBD3D, + ["sText_PkmnGotEncore"] = 0x5CA7C1, + ["sText_PkmnGotFree"] = 0x5CA9F5, + ["sText_PkmnGrewToLv"] = 0x5C9BA4, + ["sText_PkmnHPFull"] = 0x5CA1A5, + ["sText_PkmnHasNoMovesLeft"] = 0x5CAEE5, + ["sText_PkmnHasSubstitute"] = 0x5CA711, + ["sText_PkmnHealedConfusion"] = 0x5CA0C4, + ["sText_PkmnHealedParalysis"] = 0x5CA013, + ["sText_PkmnHidUnderwater"] = 0x5CA342, + ["sText_PkmnHitWithRecoil"] = 0x5CA46F, + ["sText_PkmnHungOnWithX"] = 0x5CC225, + ["sText_PkmnHurtBy"] = 0x5CA3E1, + ["sText_PkmnHurtByBurn"] = 0x5C9ED3, + ["sText_PkmnHurtByPoison"] = 0x5C9E52, + ["sText_PkmnHurtBySpikes"] = 0x5CA936, + ["sText_PkmnHurtsWith"] = 0x5CB126, + ["sText_PkmnIdentified"] = 0x5CA94C, + ["sText_PkmnIgnoredOrders"] = 0x5CB978, + ["sText_PkmnIgnoredX"] = 0x5CBAC8, + ["sText_PkmnIgnoresAsleep"] = 0x5CB958, + ["sText_PkmnImmobilizedByLove"] = 0x5CA135, + ["sText_PkmnInLove"] = 0x5CA11E, + ["sText_PkmnIncapableOfPower"] = 0x5CCE76, + ["sText_PkmnIsAlreadyParalyzed"] = 0x5C9FFA, + ["sText_PkmnIsConfused"] = 0x5CA0B4, + ["sText_PkmnIsFrozen"] = 0x5C9F2F, + ["sText_PkmnIsGlowing"] = 0x5CA313, + ["sText_PkmnIsParalyzed"] = 0x5C9FDA, + ["sText_PkmnKnockedOff"] = 0x5CAC91, + ["sText_PkmnLaidCurse"] = 0x5CA8B8, + ["sText_PkmnLearnedMove"] = 0x5C9BBA, + ["sText_PkmnLearnedMove2"] = 0x5CBA1F, + ["sText_PkmnLoafing"] = 0x5CB99C, + ["sText_PkmnLockedInNightmare"] = 0x5CA89B, + ["sText_PkmnLostFocus"] = 0x5CAE81, + ["sText_PkmnLostPPGrudge"] = 0x5CAD1B, + ["sText_PkmnLoweredHead"] = 0x5CA2FE, + ["sText_PkmnMadeItRain"] = 0x5CAF8A, + ["sText_PkmnMadeSleep"] = 0x5C9DD5, + ["sText_PkmnMadeSubstitute"] = 0x5CA6FB, + ["sText_PkmnMadeWish"] = 0x5CABFA, + ["sText_PkmnMakesGroundMiss"] = 0x5C9CC5, + ["sText_PkmnMakingUproar"] = 0x5CA59B, + ["sText_PkmnMoveBounced"] = 0x5CAD60, + ["sText_PkmnMoveDisabledNoMore"] = 0x5CA7A9, + ["sText_PkmnMoveIsDisabled"] = 0x5CAEFC, + ["sText_PkmnMoveWasDisabled"] = 0x5CA793, + ["sText_PkmnMustRecharge"] = 0x5CA76A, + ["sText_PkmnObtainedX"] = 0x5CABA7, + ["sText_PkmnObtainedX2"] = 0x5CABB7, + ["sText_PkmnObtainedXYObtainedZ"] = 0x5CABC7, + ["sText_PkmnOkComeBack"] = 0x5CBD2A, + ["sText_PkmnPeltedByHail"] = 0x5CA4BC, + ["sText_PkmnPerishCountFell"] = 0x5CA95E, + ["sText_PkmnPlantedRoots"] = 0x5CAC1F, + ["sText_PkmnPoisonedBy"] = 0x5C9E3D, + ["sText_PkmnPretendNotNotice"] = 0x5CB9D1, + ["sText_PkmnPreventsConfusionWith"] = 0x5CB09A, + ["sText_PkmnPreventsParalysisWith"] = 0x5CB048, + ["sText_PkmnPreventsPoisoningWith"] = 0x5CB07E, + ["sText_PkmnPreventsRomanceWith"] = 0x5CB064, + ["sText_PkmnPreventsStatLossWith"] = 0x5CB10A, + ["sText_PkmnPreventsUsage"] = 0x5CAFD2, + ["sText_PkmnProtectedBy"] = 0x5CAFBA, + ["sText_PkmnProtectedByMist"] = 0x5CA440, + ["sText_PkmnProtectedItself"] = 0x5C9C95, + ["sText_PkmnProtectedItself2"] = 0x5CA486, + ["sText_PkmnRageBuilding"] = 0x5CA77C, + ["sText_PkmnRaisedDef"] = 0x5CA1EF, + ["sText_PkmnRaisedDefALittle"] = 0x5CA207, + ["sText_PkmnRaisedFirePowerWith"] = 0x5CB0B6, + ["sText_PkmnRaisedSpDef"] = 0x5CA1B6, + ["sText_PkmnRaisedSpDefALittle"] = 0x5CA1CE, + ["sText_PkmnRaisedSpeed"] = 0x5CAFA0, + ["sText_PkmnReadyToHelp"] = 0x5CAB6A, + ["sText_PkmnReducedPP"] = 0x5CA843, + ["sText_PkmnRegainedHealth"] = 0x5CA191, + ["sText_PkmnRestoredHPUsing"] = 0x5CAFF5, + ["sText_PkmnSafeguardExpired"] = 0x5CA26F, + ["sText_PkmnSappedByLeechSeed"] = 0x5CA509, + ["sText_PkmnSealedOpponentMove"] = 0x5CACD1, + ["sText_PkmnSeeded"] = 0x5CA4E4, + ["sText_PkmnSentToPC"] = 0x5CBFFF, + ["sText_PkmnShedLeechSeed"] = 0x5CAA0D, + ["sText_PkmnShroudedItself"] = 0x5CAD46, + ["sText_PkmnSketchedMove"] = 0x5CA7F9, + ["sText_PkmnSleptHealthy"] = 0x5CA2B1, + ["sText_PkmnSnatchedMove"] = 0x5CADAE, + ["sText_PkmnSprangUp"] = 0x5CA355, + ["sText_PkmnSqueezedByBind"] = 0x5CA363, + ["sText_PkmnStatusNormal"] = 0x5CAAFA, + ["sText_PkmnStayedAwakeUsing"] = 0x5CA652, + ["sText_PkmnStockpiled"] = 0x5CA5E4, + ["sText_PkmnStoleItem"] = 0x5CA85A, + ["sText_PkmnStoringEnergy"] = 0x5CA670, + ["sText_PkmnSubjectedToTorment"] = 0x5CAB1A, + ["sText_PkmnSubstituteFaded"] = 0x5CA752, + ["sText_PkmnSwappedAbilities"] = 0x5CACA9, + ["sText_PkmnSwitchedItems"] = 0x5CAB82, + ["sText_PkmnThatsEnough"] = 0x5CBCFD, + ["sText_PkmnTighteningFocus"] = 0x5CAB37, + ["sText_PkmnTookAim"] = 0x5CA7E6, + ["sText_PkmnTookAttack"] = 0x5CAA61, + ["sText_PkmnTookFoe"] = 0x5CA82F, + ["sText_PkmnTookSunlight"] = 0x5CA2E9, + ["sText_PkmnTraced"] = 0x5CB137, + ["sText_PkmnTransformed"] = 0x5CB278, + ["sText_PkmnTransformedInto"] = 0x5CA6E3, + ["sText_PkmnTrappedBySandTomb"] = 0x5CA39F, + ["sText_PkmnTrappedInVortex"] = 0x5CA381, + ["sText_PkmnTryingToTakeFoe"] = 0x5CA809, + ["sText_PkmnTurnedAway"] = 0x5CB9C1, + ["sText_PkmnUnaffected"] = 0x5CA6D1, + ["sText_PkmnUnleashedEnergy"] = 0x5CA686, + ["sText_PkmnUproarKeptAwake"] = 0x5CA54D, + ["sText_PkmnUsedSafeguard"] = 0x5CA249, + ["sText_PkmnUsedXToGetPumped"] = 0x5CAE67, + ["sText_PkmnWaitsForTarget"] = 0x5CAD88, + ["sText_PkmnWantsGrudge"] = 0x5CACF3, + ["sText_PkmnWasBurned"] = 0x5C9EB1, + ["sText_PkmnWasConfused"] = 0x5CA0E1, + ["sText_PkmnWasDefrosted"] = 0x5C9F43, + ["sText_PkmnWasDefrosted2"] = 0x5C9F55, + ["sText_PkmnWasDefrostedBy"] = 0x5C9F67, + ["sText_PkmnWasDraggedOut"] = 0x5CAEA6, + ["sText_PkmnWasFrozen"] = 0x5C9F02, + ["sText_PkmnWasMadeDrowsy"] = 0x5CAC7E, + ["sText_PkmnWasParalyzed"] = 0x5C9F7F, + ["sText_PkmnWasParalyzedBy"] = 0x5C9FAA, + ["sText_PkmnWasPoisoned"] = 0x5C9E2C, + ["sText_PkmnWasntAffected"] = 0x5C9E18, + ["sText_PkmnWatchingCarefully"] = 0x5CBA76, + ["sText_PkmnWentToSleep"] = 0x5CA29F, + ["sText_PkmnWhippedWhirlwind"] = 0x5CA2CE, + ["sText_PkmnWishCameTrue"] = 0x5CAC0A, + ["sText_PkmnWokeUp"] = 0x5CA541, + ["sText_PkmnWokeUpInUproar"] = 0x5CA56C, + ["sText_PkmnWontObey"] = 0x5CB9B2, + ["sText_PkmnWrappedBy"] = 0x5CA3BC, + ["sText_PkmnsItemCuredParalysis"] = 0x5CC0B4, + ["sText_PkmnsItemCuredPoison"] = 0x5CC0CD, + ["sText_PkmnsItemCuredProblem"] = 0x5CC157, + ["sText_PkmnsItemDefrostedIt"] = 0x5CC0FC, + ["sText_PkmnsItemHealedBurn"] = 0x5CC0E3, + ["sText_PkmnsItemNormalizedStatus"] = 0x5CC175, + ["sText_PkmnsItemRestoredHPALittle"] = 0x5CC1E4, + ["sText_PkmnsItemRestoredHealth"] = 0x5CC194, + ["sText_PkmnsItemRestoredPP"] = 0x5CC1AD, + ["sText_PkmnsItemRestoredStatus"] = 0x5CC1C7, + ["sText_PkmnsItemSnappedOut"] = 0x5CC132, + ["sText_PkmnsItemWokeIt"] = 0x5CC112, + ["sText_PkmnsXBlocksY"] = 0x5CB162, + ["sText_PkmnsXBlocksY2"] = 0x5CB175, + ["sText_PkmnsXCuredItsYProblem"] = 0x5CB346, + ["sText_PkmnsXCuredYProblem"] = 0x5CB23C, + ["sText_PkmnsXHadNoEffectOnY"] = 0x5CB364, + ["sText_PkmnsXInfatuatedY"] = 0x5CB208, + ["sText_PkmnsXIntensifiedSun"] = 0x5CB1CA, + ["sText_PkmnsXMadeItIneffective"] = 0x5CB2E6, + ["sText_PkmnsXMadeYIneffective"] = 0x5CB21F, + ["sText_PkmnsXMadeYUseless"] = 0x5CB012, + ["sText_PkmnsXPreventsBurns"] = 0x5CB14A, + ["sText_PkmnsXPreventsFlinching"] = 0x5CB303, + ["sText_PkmnsXPreventsYLoss"] = 0x5CB1EE, + ["sText_PkmnsXPreventsYsZ"] = 0x5CB31F, + ["sText_PkmnsXRestoredHPALittle2"] = 0x5CB188, + ["sText_PkmnsXTookAttack"] = 0x5CB288, + ["sText_PkmnsXWhippedUpSandstorm"] = 0x5CB1AA, + ["sText_PkmnsXWoreOff"] = 0x5CA4D2, + ["sText_PlayerBattledToDrawLinkTrainer"] = 0x5CB581, + ["sText_PlayerBattledToDrawTrainer1"] = 0x5CD267, + ["sText_PlayerBattledToDrawVsTwo"] = 0x5CB5A6, + ["sText_PlayerDefeatedLinkTrainer"] = 0x5CB524, + ["sText_PlayerDefeatedLinkTrainerTrainer1"] = 0x5CBA2E, + ["sText_PlayerGotMoney"] = 0x5C9D30, + ["sText_PlayerLostAgainstLinkTrainer"] = 0x5CB54F, + ["sText_PlayerLostAgainstTrainer1"] = 0x5CD24C, + ["sText_PlayerLostToTwo"] = 0x5CB567, + ["sText_PlayerPickedUpMoney"] = 0x5CA6BE, + ["sText_PlayerUsedItem"] = 0x5CBE80, + ["sText_PlayerWhiteout"] = 0x5C9D49, + ["sText_PlayerWhiteout2"] = 0x5C9D67, + ["sText_PreventedFromWorking"] = 0x5CB2BE, + ["sText_PreventsEscape"] = 0x5C9D78, + ["sText_RefCommenceBattle"] = 0x5CD1A1, + ["sText_RefOpponentWon"] = 0x5CD0A6, + ["sText_RefPlayerWon"] = 0x5CD071, + ["sText_StatsWontDecrease"] = 0x5CA05E, + ["sText_StatsWontDecrease2"] = 0x5CB405, + ["sText_StatsWontIncrease"] = 0x5CA045, + ["sText_StatsWontIncrease2"] = 0x5CB3E5, + ["sText_StopLearningMove"] = 0x5C9C44, + ["sText_SubstituteDamaged"] = 0x5CA72E, + ["sText_TargetCantEscapeNow"] = 0x5CA86C, + ["sText_TargetFainted"] = 0x5C9D23, + ["sText_TeamStoppedWorking"] = 0x5CA076, + ["sText_ThrewPokeblockAtPkmn"] = 0x5CBAE6, + ["sText_TiedOpponentByReferee"] = 0x5CD170, + ["sText_Trainer1Fled"] = 0x5CD23C, + ["sText_Trainer1LoseText"] = 0x5C9B78, + ["sText_Trainer1SentOutPkmn"] = 0x5CBBE7, + ["sText_Trainer1SentOutPkmn2"] = 0x5CBC14, + ["sText_Trainer1SentOutTwoPkmn"] = 0x5CBBFA, + ["sText_Trainer1UsedItem"] = 0x5CBE9B, + ["sText_Trainer1WantsToBattle"] = 0x5CBB9C, + ["sText_Trainer1WinText"] = 0x5CD236, + ["sText_Trainer1WithdrewPkmn"] = 0x5CBD52, + ["sText_Trainer2LoseText"] = 0x5CCE73, + ["sText_Trainer2SentOutPkmn"] = 0x5CCE04, + ["sText_Trainer2WinText"] = 0x5CD239, + ["sText_TryToLearnMove1"] = 0x5C9BCC, + ["sText_TryToLearnMove2"] = 0x5C9BE7, + ["sText_TryToLearnMove3"] = 0x5C9C12, + ["sText_TwoInGameTrainersDefeated"] = 0x5CCE53, + ["sText_TwoLinkTrainersDefeated"] = 0x5CB538, + ["sText_TwoLinkTrainersSentOutPkmn"] = 0x5CBC4E, + ["sText_TwoLinkTrainersWantToBattle"] = 0x5CBBCD, + ["sText_TwoLinkTrainersWantToBattlePause"] = 0x5CD318, + ["sText_TwoTrainersSentPkmn"] = 0x5CCDDE, + ["sText_TwoTrainersWantToBattle"] = 0x5CCE17, + ["sText_TwoWildFled"] = 0x5CB5DF, + ["sText_TwoWildPkmnAppeared"] = 0x5CBB82, + ["sText_UproarKeptPkmnAwake"] = 0x5CA634, + ["sText_UsingItemTheStatOfPkmnRose"] = 0x5CB3B5, + ["sText_WallyUsedItem"] = 0x5CBE8C, + ["sText_WildFled"] = 0x5CB5D2, + ["sText_WildPkmnAppeared"] = 0x5CBB47, + ["sText_WildPkmnAppearedPause"] = 0x5CBB6D, + ["sText_WildPkmnFled"] = 0x5CB512, + ["sText_XFoundOneY"] = 0x5CAE02, + ["sText_YourFoesWeakGetEmPkmn"] = 0x5CBCC7, + }, +} diff --git a/src/mods/rom_text_labels/firered.lua b/src/mods/rom_text_labels/firered.lua new file mode 100644 index 000000000..b32284efd --- /dev/null +++ b/src/mods/rom_text_labels/firered.lua @@ -0,0 +1,3708 @@ +-- Generated label/address facts for firered; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + text = { + ["Aide_Text_DontHaveAnyRoomForItem"] = 0x1A594D, + ["Aide_Text_GetEnoughMonsComeBackForItem"] = 0x1A5909, + ["Aide_Text_HaventCaughtEnoughMonsForItem"] = 0x1A58A7, + ["CableClub_Text_AdapterNotConnected"] = 0x1BCA13, + ["CableClub_Text_AskAboutLinking"] = 0x1BDB85, + ["CableClub_Text_AwaitingLinkupBCancel"] = 0x1BC54C, + ["CableClub_Text_BattleUsingLinkCable"] = 0x1BC3C7, + ["CableClub_Text_CanMakeBerryPowder"] = 0x1BCAF2, + ["CableClub_Text_CanMixRecords"] = 0x1BCB27, + ["CableClub_Text_CancelSelectedItem"] = 0x1BCB42, + ["CableClub_Text_CantDoubleBattleWithXPlayers"] = 0x1BC700, + ["CableClub_Text_CantSingleBattleWithXPlayers"] = 0x1BC6CA, + ["CableClub_Text_CantTradeEnigmaBerry"] = 0x1BC47C, + ["CableClub_Text_ChooseGroupLeader"] = 0x1BD5F1, + ["CableClub_Text_ChooseGroupLeaderOfFour"] = 0x1BD582, + ["CableClub_Text_ChooseGroupLeaderOfTwo"] = 0x1BD51B, + ["CableClub_Text_CloseThisMenu"] = 0x1BC403, + ["CableClub_Text_ComeAgain"] = 0x1BCFD1, + ["CableClub_Text_DirectYouToYourRoom"] = 0x1BC59E, + ["CableClub_Text_EnjoyUnionRoom"] = 0x1BD86A, + ["CableClub_Text_ExplainBattleModes"] = 0x1BD390, + ["CableClub_Text_ExplainWirelessClub"] = 0x1BDBF8, + ["CableClub_Text_ExplainWirelessClubFirstTime"] = 0x1BD966, + ["CableClub_Text_FirstTimeRightThisWay"] = 0x1BD898, + ["CableClub_Text_Hello"] = 0x1BCA7F, + ["CableClub_Text_HopeToSeeYouAgain"] = 0x1BC943, + ["CableClub_Text_HopeYouEnjoyWirelessSystem"] = 0x1BDEDF, + ["CableClub_Text_IncorrectNumberOfParticipants"] = 0x1BC6A1, + ["CableClub_Text_LinkErrorPleaseReset"] = 0x1BC615, + ["CableClub_Text_NeedBerryForBerryCrush"] = 0x1BCB81, + ["CableClub_Text_NeedFourPlayers"] = 0x1BC736, + ["CableClub_Text_NeedTwoMonsForDoubleBattle"] = 0x1BC409, + ["CableClub_Text_NeedTwoMonsForUnionRoom"] = 0x1BCBC3, + ["CableClub_Text_NeedTwoMonsToTrade"] = 0x1BC442, + ["CableClub_Text_NoEnigmaBerryInUnionRoom"] = 0x1BCC00, + ["CableClub_Text_NotSetUpForFarAwayRegion"] = 0x1BC95C, + ["CableClub_Text_OhExcuseMe"] = 0x1BCCBE, + ["CableClub_Text_OkayToSaveProgress"] = 0x1BC572, + ["CableClub_Text_OtherTrainerNotReady"] = 0x1BC9C0, + ["CableClub_Text_ParticipantsStepUpToCounter"] = 0x1BCA43, + ["CableClub_Text_PlayWhichBattleMode"] = 0x1BD338, + ["CableClub_Text_PlayersMadeDifferentSelections"] = 0x1BC64E, + ["CableClub_Text_PleaseConfirmNumberAndRestart"] = 0x1BC76B, + ["CableClub_Text_PleaseEnter"] = 0x1BC590, + ["CableClub_Text_PleaseVisitAgain"] = 0x1BC68A, + ["CableClub_Text_PleaseWait"] = 0x1BCA86, + ["CableClub_Text_PleaseWaitBCancel"] = 0x1BC4AC, + ["CableClub_Text_ReturnsToPreviousStep"] = 0x1BCB73, + ["CableClub_Text_SomeoneIsNotReadyToLink"] = 0x1BC5C0, + ["CableClub_Text_StartLinkWithXPlayersAConfirmBCancel"] = 0x1BC50D, + ["CableClub_Text_TradeMonsUsingLinkCable"] = 0x1BC388, + ["CableClub_Text_TradePokemon"] = 0x1BD317, + ["CableClub_Text_TrainerCardDataOverwritten"] = 0x1BC918, + ["CableClub_Text_UnionRoomAdapterNotConnected"] = 0x1BCC3A, + ["CableClub_Text_UnionRoomInfo"] = 0x1BD706, + ["CableClub_Text_UseBerryCrush"] = 0x1BD362, + ["CableClub_Text_WelcomeUnionRoomEnter"] = 0x1BD65B, + ["CableClub_Text_WelcomeWhichCableClubService"] = 0x1BC311, + ["CableClub_Text_WelcomeWhichDirectCornerRoom"] = 0x1BD28D, + ["CableClub_Text_WhenAllPlayersReadyAConfirmBCancel"] = 0x1BC4CE, + ["CableClub_Text_WhichBattleMode"] = 0x1BCB62, + ["CableClub_Text_WhichService"] = 0x1BC35E, + ["CableClub_Text_YouHaveAMonThatCantBeTaken"] = 0x1BC9E0, + ["CableClub_Text_YouMayBattleHere"] = 0x1BCACB, + ["CableClub_Text_YouMayTradeHere"] = 0x1BCA95, + ["CeladonCity_GameCorner_Text_BigSmileForPhoto"] = 0x1B1E42, + ["CeladonCity_GameCorner_Text_ChoosePrintType"] = 0x1B1E24, + ["CeladonCity_GameCorner_Text_DontHaveEnoughMoney"] = 0x1B1E07, + ["CeladonCity_GameCorner_Text_PrintIsReadyCheckTrainerCard"] = 0x1B1E7D, + ["CeladonCity_GameCorner_Text_TryPokemonPrinter"] = 0x1B1D7D, + ["DayCare_Text_ComeAgain"] = 0x1BF976, + ["DayCare_Text_DoYouWantEgg"] = 0x1BF5E3, + ["DayCare_Text_FineThenComeAgain"] = 0x1BF916, + ["DayCare_Text_GoodToSeeYou"] = 0x1BF988, + ["DayCare_Text_HeresYourMon"] = 0x1BFA67, + ["DayCare_Text_Huh"] = 0x1BFB5A, + ["DayCare_Text_HusbandWasLookingForYou"] = 0x1BF8F6, + ["DayCare_Text_IWillKeepDoYouWantIt"] = 0x1BF7B6, + ["DayCare_Text_IllKeepIt"] = 0x1BF6CF, + ["DayCare_Text_ImDaycareManSpeakToMyWife"] = 0x1BF555, + ["DayCare_Text_ItWillCostX"] = 0x1BFA3B, + ["DayCare_Text_NotEnoughMoney"] = 0x1BF932, + ["DayCare_Text_ReceivedEgg"] = 0x1BF72A, + ["DayCare_Text_SeeWifeIfYouWantToPickUpMon"] = 0x1BF76B, + ["DayCare_Text_TakeBackWhichMon"] = 0x1BFA28, + ["DayCare_Text_TakeGoodCareOfIt"] = 0x1BF755, + ["DayCare_Text_TakeOtherOneBackToo"] = 0x1BF94F, + ["DayCare_Text_TakeYourMonBack"] = 0x1BFAE8, + ["DayCare_Text_TookBackMon"] = 0x1BFA85, + ["DayCare_Text_WeCanRaiseOneMore"] = 0x1BF89F, + ["DayCare_Text_WellRaiseYourMon"] = 0x1BF860, + ["DayCare_Text_WhatWillYouBattleWith"] = 0x1BFB09, + ["DayCare_Text_WhichMonShouldWeRaise"] = 0x1BF839, + ["DayCare_Text_WouldYouLikeUsToRaiseMon"] = 0x1BF7E4, + ["DayCare_Text_YouHaveJustOneMon"] = 0x1BFAAD, + ["DayCare_Text_YouHaveNoRoomForIt"] = 0x1BF6F0, + ["DayCare_Text_YourMonHasGrownXLevels"] = 0x1BF9CC, + ["DayCare_Text_YourMonIsDoingFine"] = 0x1BF69A, + ["DayCare_Text_YourMonsAreDoingFine"] = 0x1BF789, + ["DayCare_Text_YourPartyIsFull"] = 0x1BF9EF, + ["EventScript_ExplainPokemonJumpRequirements"] = 0x1BD0CF, + ["FiveIsland_MemorialPillar_Text_ChazRematchIntro"] = 0x1C3E0F, + ["FiveIsland_MemorialPillar_Text_HaroldRematchIntro"] = 0x1C3E6A, + ["FiveIsland_MemorialPillar_Text_MiloRematchIntro"] = 0x1C3DA2, + ["FiveIsland_ResortGorgeous_Text_CelinaRematchIntro"] = 0x1C3C28, + ["FiveIsland_ResortGorgeous_Text_DaisyRematchIntro"] = 0x1C3BF6, + ["FiveIsland_ResortGorgeous_Text_DestinRematchIntro"] = 0x1C3D47, + ["FiveIsland_ResortGorgeous_Text_GillianRematchIntro"] = 0x1C3CF1, + ["FiveIsland_ResortGorgeous_Text_JackiRematchIntro"] = 0x1C3CB0, + ["FiveIsland_ResortGorgeous_Text_RaynaRematchIntro"] = 0x1C3C70, + ["FiveIsland_ResortGorgeous_Text_TobyRematchIntro"] = 0x1C3D73, + ["FiveIsland_WaterLabyrinth_Text_AlizeRematchIntro"] = 0x1C3BB7, + ["FourIsland_House2_Text_BragAboutSomethingForMe"] = 0x1B1FEF, + ["FourIsland_House2_Text_BragAboutWhatToday"] = 0x1B2025, + ["FourIsland_House2_Text_ComeBackWithBetterStoryEggs"] = 0x1B251B, + ["FourIsland_House2_Text_ComeBackWithBetterStoryHoF"] = 0x1B227C, + ["FourIsland_House2_Text_ComeBackWithBetterStoryLinkWins"] = 0x1B27DB, + ["FourIsland_House2_Text_EggStickerApplied"] = 0x1B24EA, + ["FourIsland_House2_Text_GiveYouStickerIfYouBrag"] = 0x1B1EF8, + ["FourIsland_House2_Text_HoFStickerApplied"] = 0x1B2242, + ["FourIsland_House2_Text_LivingLegendEggs"] = 0x1B254F, + ["FourIsland_House2_Text_LivingLegendHoF"] = 0x1B22B0, + ["FourIsland_House2_Text_LivingLegendLinkWins"] = 0x1B280F, + ["FourIsland_House2_Text_VictoryStickerApplied"] = 0x1B27A6, + ["FourIsland_House2_Text_WishICouldShowOffStickers"] = 0x1B1EB3, + ["FourIsland_House2_Text_WowEggsHigh"] = 0x1B23E0, + ["FourIsland_House2_Text_WowEggsHighest"] = 0x1B2448, + ["FourIsland_House2_Text_WowEggsLow"] = 0x1B2372, + ["FourIsland_House2_Text_WowEggsLowest"] = 0x1B2308, + ["FourIsland_House2_Text_WowHoFHigh"] = 0x1B2133, + ["FourIsland_House2_Text_WowHoFHighest"] = 0x1B21C4, + ["FourIsland_House2_Text_WowHoFLow"] = 0x1B20CF, + ["FourIsland_House2_Text_WowHoFLowest"] = 0x1B206E, + ["FourIsland_House2_Text_WowLinkWinsHigh"] = 0x1B267B, + ["FourIsland_House2_Text_WowLinkWinsHighest"] = 0x1B26F6, + ["FourIsland_House2_Text_WowLinkWinsLow"] = 0x1B2609, + ["FourIsland_House2_Text_WowLinkWinsLowest"] = 0x1B25A7, + ["GiddyText_DontYouAgree"] = 0x418B1A, + ["GiddyText_IBoughtCrayonsWith120Colors"] = 0x418B40, + ["GiddyText_ISoWantToGoOnAVacation"] = 0x418B25, + ["GiddyText_Is"] = 0x418B17, + ["GiddyText_LyingBackInTheGreenGrass"] = 0x418BDA, + ["GiddyText_SecretBasesAreSoWonderful"] = 0x418BF3, + ["GiddyText_SoAmusing"] = 0x418B02, + ["GiddyText_SoDarling"] = 0x418ACA, + ["GiddyText_SoDesirable"] = 0x418AEB, + ["GiddyText_SoExciting"] = 0x418AF6, + ["GiddyText_SoMagical"] = 0x418B0D, + ["GiddyText_SoPretty"] = 0x418AC0, + ["GiddyText_SoRelaxed"] = 0x418AD4, + ["GiddyText_SoSunny"] = 0x418ADF, + ["GiddyText_WhatsTheBottomOfTheSeaLike"] = 0x418B99, + ["GiddyText_WhenYouSeeTheSettingSunDoesIt"] = 0x418BC0, + ["GiddyText_WhenYouWriteOnASandyBeach"] = 0x418B77, + ["GiddyText_WouldntItBeNiceIfWeCouldFloat"] = 0x418B5C, + ["Help_Text_Ability"] = 0x1B7D1F, + ["Help_Text_AboutThisGame"] = 0x1B2E48, + ["Help_Text_Accuracy"] = 0x1B7D4E, + ["Help_Text_AnswerCanIBuyPokeBalls"] = 0x1B4645, + ["Help_Text_AnswerCantCatchMons"] = 0x1B4483, + ["Help_Text_AnswerCantFindPersonIWant"] = 0x1B379A, + ["Help_Text_AnswerCantGetOutOfRoom"] = 0x1B36EB, + ["Help_Text_AnswerFoeMonsTooStrong"] = 0x1B4E0B, + ["Help_Text_AnswerHowAreCavesDifferent"] = 0x1B3D1B, + ["Help_Text_AnswerHowAreRoadsForestsDifferent"] = 0x1B3C99, + ["Help_Text_AnswerHowDoIPrepareForBattle"] = 0x1B406C, + ["Help_Text_AnswerHowDoIProgress"] = 0x1B3DE3, + ["Help_Text_AnswerHowDoIWinAgainstTrainer"] = 0x1B47F0, + ["Help_Text_AnswerICantGoOn"] = 0x1B3972, + ["Help_Text_AnswerMyMonsAreHurt"] = 0x1B41D7, + ["Help_Text_AnswerNothingIWantToKnow"] = 0x1B4FB2, + ["Help_Text_AnswerOutOfThingsToDo"] = 0x1B3A51, + ["Help_Text_AnswerPlayingForFirstTime"] = 0x1B3525, + ["Help_Text_AnswerRanOutOfPotions"] = 0x1B457C, + ["Help_Text_AnswerSomeoneBlockingMyWay"] = 0x1B3876, + ["Help_Text_AnswerTalkedToEveryoneNowWhat"] = 0x1B3849, + ["Help_Text_AnswerWantToAddMoreMoves"] = 0x1B4C54, + ["Help_Text_AnswerWantToEndGame"] = 0x1B51B1, + ["Help_Text_AnswerWantToEndSafari"] = 0x1B5589, + ["Help_Text_AnswerWantToMakeMonStronger"] = 0x1B4D26, + ["Help_Text_AnswerWhatAreHiddenMoves"] = 0x1B4A72, + ["Help_Text_AnswerWhatAreMoves"] = 0x1B497A, + ["Help_Text_AnswerWhatAreMyAdventureBasics"] = 0x1B3BB6, + ["Help_Text_AnswerWhatAreSafariRules"] = 0x1B54E1, + ["Help_Text_AnswerWhatDoIDoInCave"] = 0x1B4ED8, + ["Help_Text_AnswerWhatDoIDoInSafari"] = 0x1B547C, + ["Help_Text_AnswerWhatDoesHiddenMoveDo"] = 0x1B5382, + ["Help_Text_AnswerWhatHappenedToItemIGot"] = 0x1B3ACC, + ["Help_Text_AnswerWhatHappensIfAllMyMonsFaint"] = 0x1B439D, + ["Help_Text_AnswerWhatIsAGym"] = 0x1B55F4, + ["Help_Text_AnswerWhatIsAMonsVitality"] = 0x1B410B, + ["Help_Text_AnswerWhatIsStatusProblem"] = 0x1B42B3, + ["Help_Text_AnswerWhatIsThatPersonLike"] = 0x1B5325, + ["Help_Text_AnswerWhatMovesShouldIUse"] = 0x1B4B65, + ["Help_Text_AnswerWhatShouldIBeDoing"] = 0x1B35E6, + ["Help_Text_AnswerWhatsABattle"] = 0x1B3F7F, + ["Help_Text_AnswerWhatsAMon"] = 0x1B5272, + ["Help_Text_AnswerWhatsATrainer"] = 0x1B470A, + ["Help_Text_AnswerWhatsPokemonCenter"] = 0x1B4FFD, + ["Help_Text_AnswerWhatsPokemonMart"] = 0x1B50FF, + ["Help_Text_AnswerWhenCanIUseItem"] = 0x1B3EBC, + ["Help_Text_AnswerWhereDoMonsAppear"] = 0x1B48C6, + ["Help_Text_Attack"] = 0x1B7CDF, + ["Help_Text_Badges"] = 0x1B7D88, + ["Help_Text_Badges2"] = 0x1B7E0F, + ["Help_Text_BattleScene"] = 0x1B7D9A, + ["Help_Text_BattleStyle"] = 0x1B7DA7, + ["Help_Text_ButtonMode"] = 0x1B7DBA, + ["Help_Text_CanIBuyPokeBalls"] = 0x1B32E3, + ["Help_Text_Cancel"] = 0x1B2E6F, + ["Help_Text_Cancel2"] = 0x1B7DCC, + ["Help_Text_CantCatchMons"] = 0x1B32B6, + ["Help_Text_CantFindPersonIWant"] = 0x1B30DC, + ["Help_Text_CantGetOutOfRoom"] = 0x1B30C1, + ["Help_Text_Defense"] = 0x1B7CE6, + ["Help_Text_DefineAbility"] = 0x1B87B8, + ["Help_Text_DefineAccuracy"] = 0x1B8C94, + ["Help_Text_DefineAttack"] = 0x1B80EC, + ["Help_Text_DefineBadges"] = 0x1B90E8, + ["Help_Text_DefineBadges2"] = 0x1B9B2F, + ["Help_Text_DefineBattleScene"] = 0x1B91C2, + ["Help_Text_DefineBattleStyle"] = 0x1B91F9, + ["Help_Text_DefineButtonMode"] = 0x1B92ED, + ["Help_Text_DefineCancel2"] = 0x1B9439, + ["Help_Text_DefineDefense"] = 0x1B81C2, + ["Help_Text_DefineEXP"] = 0x1B7F0A, + ["Help_Text_DefineEvolution"] = 0x1B9749, + ["Help_Text_DefineFNT"] = 0x1B8D1D, + ["Help_Text_DefineFrame"] = 0x1B93D8, + ["Help_Text_DefineHM"] = 0x1B9560, + ["Help_Text_DefineHMMove"] = 0x1B9656, + ["Help_Text_DefineHP"] = 0x1B7E16, + ["Help_Text_DefineIDNo"] = 0x1B8A84, + ["Help_Text_DefineIDNo2"] = 0x1B99C4, + ["Help_Text_DefineItem"] = 0x1B86E2, + ["Help_Text_DefineItems"] = 0x1B8DD4, + ["Help_Text_DefineKeyItems"] = 0x1B8E67, + ["Help_Text_DefineLevel"] = 0x1B847B, + ["Help_Text_DefineMoney"] = 0x1B8897, + ["Help_Text_DefineMoney2"] = 0x1B9AA2, + ["Help_Text_DefineMoveType"] = 0x1B8924, + ["Help_Text_DefineMoves"] = 0x1B800A, + ["Help_Text_DefineNature"] = 0x1B8A04, + ["Help_Text_DefineOT"] = 0x1B8647, + ["Help_Text_DefinePP"] = 0x1B8B62, + ["Help_Text_DefinePlayTime"] = 0x1B90A7, + ["Help_Text_DefinePokeBalls"] = 0x1B8F4D, + ["Help_Text_DefinePokedex"] = 0x1B901B, + ["Help_Text_DefinePokemon"] = 0x1B991C, + ["Help_Text_DefinePower"] = 0x1B8C18, + ["Help_Text_DefineSound"] = 0x1B92B8, + ["Help_Text_DefineSpAtk"] = 0x1B8256, + ["Help_Text_DefineSpDef"] = 0x1B8348, + ["Help_Text_DefineSpeed"] = 0x1B83EF, + ["Help_Text_DefineStatusProblem"] = 0x1B984F, + ["Help_Text_DefineTM"] = 0x1B9497, + ["Help_Text_DefineTextSpeed"] = 0x1B9170, + ["Help_Text_DefineType"] = 0x1B8550, + ["Help_Text_DescAboutThisGame"] = 0x1B2F43, + ["Help_Text_DescExit"] = 0x1B2FA9, + ["Help_Text_DescGameFundamentals1"] = 0x1B9E75, + ["Help_Text_DescGameFundamentals2"] = 0x1B9F09, + ["Help_Text_DescGameFundamentals3"] = 0x1B9FCE, + ["Help_Text_DescHowDoIDoThis"] = 0x1B2EC8, + ["Help_Text_DescTheGame"] = 0x1B9D04, + ["Help_Text_DescTheHelpSystem"] = 0x1B9C2F, + ["Help_Text_DescTypeMatchupList"] = 0x1B2F74, + ["Help_Text_DescWhatArePokemon"] = 0x1BA027, + ["Help_Text_DescWhatDoesThisTermMean"] = 0x1B2F00, + ["Help_Text_DescWhatShouldIDo"] = 0x1B2E88, + ["Help_Text_DescWirelessAdapter"] = 0x1B9DC5, + ["Help_Text_EXP"] = 0x1B7CC4, + ["Help_Text_EnteringName"] = 0x1B5824, + ["Help_Text_Evolution"] = 0x1B7DE1, + ["Help_Text_Exit"] = 0x1B2E6A, + ["Help_Text_FNT"] = 0x1B7D57, + ["Help_Text_FoeMonsTooStrong"] = 0x1B33CA, + ["Help_Text_Frame"] = 0x1B7DC6, + ["Help_Text_GameFundamentals1"] = 0x1B9BE1, + ["Help_Text_GameFundamentals2"] = 0x1B9BF5, + ["Help_Text_GameFundamentals3"] = 0x1B9C09, + ["Help_Text_Greetings"] = 0x1B2FC9, + ["Help_Text_HM"] = 0x1B7DD6, + ["Help_Text_HMMove"] = 0x1B7DD9, + ["Help_Text_HP"] = 0x1B7CC1, + ["Help_Text_HowAreCavesDifferent"] = 0x1B31D3, + ["Help_Text_HowAreRoadsForestsDifferent"] = 0x1B31AE, + ["Help_Text_HowDoIDoThis"] = 0x1B2E1C, + ["Help_Text_HowDoIPrepareForBattle"] = 0x1B3226, + ["Help_Text_HowDoIProgress"] = 0x1B31EC, + ["Help_Text_HowDoIWinAgainstTrainer"] = 0x1B330B, + ["Help_Text_HowToEnterName"] = 0x1B678E, + ["Help_Text_HowToMoveItems"] = 0x1B6C4F, + ["Help_Text_HowToOpenMenu"] = 0x1B7108, + ["Help_Text_HowToReadPokedex"] = 0x1B752C, + ["Help_Text_HowToRegisterKeyItem"] = 0x1B79CB, + ["Help_Text_HowToRideBicycle"] = 0x1B66BA, + ["Help_Text_HowToUseAnItem"] = 0x1B5E41, + ["Help_Text_HowToUseBag"] = 0x1B5D87, + ["Help_Text_HowToUseBag2"] = 0x1B747E, + ["Help_Text_HowToUseBait"] = 0x1B7AEE, + ["Help_Text_HowToUseBall"] = 0x1B7A60, + ["Help_Text_HowToUseBillsPC"] = 0x1B68CD, + ["Help_Text_HowToUseDeposit"] = 0x1B6A9A, + ["Help_Text_HowToUseDepositItem"] = 0x1B6EC1, + ["Help_Text_HowToUseDepositItem2"] = 0x1B77DD, + ["Help_Text_HowToUseFight"] = 0x1B71EA, + ["Help_Text_HowToUseHM"] = 0x1B6525, + ["Help_Text_HowToUseHallOfFame"] = 0x1B7C57, + ["Help_Text_HowToUseHomePC"] = 0x1B7611, + ["Help_Text_HowToUseItem"] = 0x1B5CDF, + ["Help_Text_HowToUseItemStorage"] = 0x1B7692, + ["Help_Text_HowToUseKeyItem"] = 0x1B5F10, + ["Help_Text_HowToUseMailbox"] = 0x1B6FA8, + ["Help_Text_HowToUseMailbox2"] = 0x1B7884, + ["Help_Text_HowToUseMove"] = 0x1B6B6E, + ["Help_Text_HowToUseMoveOutsideOfBattle"] = 0x1B65E7, + ["Help_Text_HowToUseOption"] = 0x1B6203, + ["Help_Text_HowToUsePC"] = 0x1B6883, + ["Help_Text_HowToUsePlayer"] = 0x1B606C, + ["Help_Text_HowToUsePlayersPC"] = 0x1B6D4A, + ["Help_Text_HowToUsePokeBall"] = 0x1B5FA6, + ["Help_Text_HowToUsePokedex"] = 0x1B5A4D, + ["Help_Text_HowToUsePokemon"] = 0x1B5B0C, + ["Help_Text_HowToUsePokemon2"] = 0x1B723B, + ["Help_Text_HowToUsePotion"] = 0x1B62E4, + ["Help_Text_HowToUseProfOaksPC"] = 0x1B7075, + ["Help_Text_HowToUseRock"] = 0x1B7BBE, + ["Help_Text_HowToUseRun"] = 0x1B7931, + ["Help_Text_HowToUseSave"] = 0x1B6140, + ["Help_Text_HowToUseShift"] = 0x1B7319, + ["Help_Text_HowToUseSummary"] = 0x1B5B7D, + ["Help_Text_HowToUseSummary2"] = 0x1B73E8, + ["Help_Text_HowToUseSwitch"] = 0x1B5C13, + ["Help_Text_HowToUseTM"] = 0x1B6478, + ["Help_Text_HowToUseTownMap"] = 0x1B6397, + ["Help_Text_HowToUseTypeMatchupList"] = 0x1BA416, + ["Help_Text_HowToUseWithdraw"] = 0x1B69B9, + ["Help_Text_HowToUseWithdrawItem"] = 0x1B6E02, + ["Help_Text_HowToUseWithdrawItem2"] = 0x1B771E, + ["Help_Text_ICantGoOn"] = 0x1B3140, + ["Help_Text_IDNo"] = 0x1B7D3E, + ["Help_Text_IDNo2"] = 0x1B7E02, + ["Help_Text_Item"] = 0x1B7D1A, + ["Help_Text_Items"] = 0x1B7D5B, + ["Help_Text_KeyItems"] = 0x1B7D61, + ["Help_Text_Level"] = 0x1B7D04, + ["Help_Text_Money"] = 0x1B7D27, + ["Help_Text_Money2"] = 0x1B7E09, + ["Help_Text_MoveType"] = 0x1B7D2D, + ["Help_Text_Moves"] = 0x1B7CD9, + ["Help_Text_MovingItems"] = 0x1B5884, + ["Help_Text_MyMonsAreHurt"] = 0x1B3261, + ["Help_Text_Nature"] = 0x1B7D37, + ["Help_Text_NothingIWantToKnow"] = 0x1B3402, + ["Help_Text_OT"] = 0x1B7D17, + ["Help_Text_OpeningMenu"] = 0x1B58FD, + ["Help_Text_OutOfThingsToDo"] = 0x1B314F, + ["Help_Text_OwnMoveBug"] = 0x1BA3ED, + ["Help_Text_OwnMoveDark"] = 0x1BA10D, + ["Help_Text_OwnMoveDragon"] = 0x1BA2DB, + ["Help_Text_OwnMoveElectric"] = 0x1BA279, + ["Help_Text_OwnMoveFighting"] = 0x1BA194, + ["Help_Text_OwnMoveFire"] = 0x1BA395, + ["Help_Text_OwnMoveFlying"] = 0x1BA366, + ["Help_Text_OwnMoveGhost"] = 0x1BA1F4, + ["Help_Text_OwnMoveGrass"] = 0x1BA1C7, + ["Help_Text_OwnMoveGround"] = 0x1BA24A, + ["Help_Text_OwnMoveIce"] = 0x1BA221, + ["Help_Text_OwnMoveNormal"] = 0x1BA30A, + ["Help_Text_OwnMovePoison"] = 0x1BA2AC, + ["Help_Text_OwnMovePsychic"] = 0x1BA163, + ["Help_Text_OwnMoveRock"] = 0x1BA138, + ["Help_Text_OwnMoveSteel"] = 0x1BA339, + ["Help_Text_OwnMoveWater"] = 0x1BA3C0, + ["Help_Text_OwnPokemonBug"] = 0x1BA400, + ["Help_Text_OwnPokemonDark"] = 0x1BA121, + ["Help_Text_OwnPokemonDragon"] = 0x1BA2F1, + ["Help_Text_OwnPokemonElectric"] = 0x1BA291, + ["Help_Text_OwnPokemonFighting"] = 0x1BA1AC, + ["Help_Text_OwnPokemonFire"] = 0x1BA3A9, + ["Help_Text_OwnPokemonFlying"] = 0x1BA37C, + ["Help_Text_OwnPokemonGhost"] = 0x1BA209, + ["Help_Text_OwnPokemonGrass"] = 0x1BA1DC, + ["Help_Text_OwnPokemonGround"] = 0x1BA260, + ["Help_Text_OwnPokemonIce"] = 0x1BA234, + ["Help_Text_OwnPokemonNormal"] = 0x1BA320, + ["Help_Text_OwnPokemonPoison"] = 0x1BA2C2, + ["Help_Text_OwnPokemonPsychic"] = 0x1BA17A, + ["Help_Text_OwnPokemonRock"] = 0x1BA14C, + ["Help_Text_OwnPokemonSteel"] = 0x1BA34E, + ["Help_Text_OwnPokemonWater"] = 0x1BA3D5, + ["Help_Text_PP"] = 0x1B7D45, + ["Help_Text_PlayTime"] = 0x1B7D7E, + ["Help_Text_PlayingForFirstTime"] = 0x1B3083, + ["Help_Text_PokeBalls"] = 0x1B7D6B, + ["Help_Text_Pokedex"] = 0x1B7D76, + ["Help_Text_Pokemon"] = 0x1B7DFA, + ["Help_Text_Power"] = 0x1B7D48, + ["Help_Text_RanOutOfPotions"] = 0x1B32CD, + ["Help_Text_ReadingPokedex"] = 0x1B595D, + ["Help_Text_RegisterKeyItem"] = 0x1B59F5, + ["Help_Text_RidingBicycle"] = 0x1B580D, + ["Help_Text_SomeoneBlockingMyWay"] = 0x1B311F, + ["Help_Text_Sound"] = 0x1B7DB4, + ["Help_Text_SpAtk"] = 0x1B7CEE, + ["Help_Text_SpDef"] = 0x1B7CF6, + ["Help_Text_Speed"] = 0x1B7CFE, + ["Help_Text_StatusProblem"] = 0x1B7DEB, + ["Help_Text_TM"] = 0x1B7DD3, + ["Help_Text_TalkedToEveryoneNowWhat"] = 0x1B30FC, + ["Help_Text_TextSpeed"] = 0x1B7D8F, + ["Help_Text_TheGame"] = 0x1B9BC7, + ["Help_Text_TheHelpSystem"] = 0x1B9BB7, + ["Help_Text_TryDoingItOnYourOwn"] = 0x1B2E76, + ["Help_Text_Type"] = 0x1B7D12, + ["Help_Text_TypeMatchupList"] = 0x1B2E58, + ["Help_Text_TypeMatchupOwnMoveBug"] = 0x1BB0DF, + ["Help_Text_TypeMatchupOwnMoveDark"] = 0x1BA4E6, + ["Help_Text_TypeMatchupOwnMoveDragon"] = 0x1BAC89, + ["Help_Text_TypeMatchupOwnMoveElectric"] = 0x1BAB18, + ["Help_Text_TypeMatchupOwnMoveFighting"] = 0x1BA71F, + ["Help_Text_TypeMatchupOwnMoveFire"] = 0x1BAF6B, + ["Help_Text_TypeMatchupOwnMoveFlying"] = 0x1BAEA8, + ["Help_Text_TypeMatchupOwnMoveGhost"] = 0x1BA8D3, + ["Help_Text_TypeMatchupOwnMoveGrass"] = 0x1BA7E9, + ["Help_Text_TypeMatchupOwnMoveGround"] = 0x1BAA44, + ["Help_Text_TypeMatchupOwnMoveIce"] = 0x1BA98D, + ["Help_Text_TypeMatchupOwnMoveNormal"] = 0x1BAD20, + ["Help_Text_TypeMatchupOwnMovePoison"] = 0x1BABCC, + ["Help_Text_TypeMatchupOwnMovePsychic"] = 0x1BA66F, + ["Help_Text_TypeMatchupOwnMoveRock"] = 0x1BA595, + ["Help_Text_TypeMatchupOwnMoveSteel"] = 0x1BADA2, + ["Help_Text_TypeMatchupOwnMoveWater"] = 0x1BB02E, + ["Help_Text_TypeMatchupOwnPokemonBug"] = 0x1BB156, + ["Help_Text_TypeMatchupOwnPokemonDark"] = 0x1BA539, + ["Help_Text_TypeMatchupOwnPokemonDragon"] = 0x1BACC4, + ["Help_Text_TypeMatchupOwnPokemonElectric"] = 0x1BAB7A, + ["Help_Text_TypeMatchupOwnPokemonFighting"] = 0x1BA796, + ["Help_Text_TypeMatchupOwnPokemonFire"] = 0x1BAFCA, + ["Help_Text_TypeMatchupOwnPokemonFlying"] = 0x1BAF01, + ["Help_Text_TypeMatchupOwnPokemonGhost"] = 0x1BA92A, + ["Help_Text_TypeMatchupOwnPokemonGrass"] = 0x1BA862, + ["Help_Text_TypeMatchupOwnPokemonGround"] = 0x1BAAB6, + ["Help_Text_TypeMatchupOwnPokemonIce"] = 0x1BA9F1, + ["Help_Text_TypeMatchupOwnPokemonNormal"] = 0x1BAD60, + ["Help_Text_TypeMatchupOwnPokemonPoison"] = 0x1BAC29, + ["Help_Text_TypeMatchupOwnPokemonPsychic"] = 0x1BA6C9, + ["Help_Text_TypeMatchupOwnPokemonRock"] = 0x1BA5F2, + ["Help_Text_TypeMatchupOwnPokemonSteel"] = 0x1BADF7, + ["Help_Text_TypeMatchupOwnPokemonWater"] = 0x1BB084, + ["Help_Text_UsingAnItem"] = 0x1B5744, + ["Help_Text_UsingBag"] = 0x1B5737, + ["Help_Text_UsingBag2"] = 0x1B5950, + ["Help_Text_UsingBait"] = 0x1B5A1B, + ["Help_Text_UsingBall"] = 0x1B5A0D, + ["Help_Text_UsingBillsPC"] = 0x1B583F, + ["Help_Text_UsingDeposit"] = 0x1B5863, + ["Help_Text_UsingDepositItem"] = 0x1B58BC, + ["Help_Text_UsingDepositItem2"] = 0x1B59BF, + ["Help_Text_UsingFight"] = 0x1B590E, + ["Help_Text_UsingHM"] = 0x1B57DE, + ["Help_Text_UsingHallOfFame"] = 0x1B5A37, + ["Help_Text_UsingHomePC"] = 0x1B5974, + ["Help_Text_UsingItem"] = 0x1B5728, + ["Help_Text_UsingItemStorage"] = 0x1B5989, + ["Help_Text_UsingKeyItem"] = 0x1B5754, + ["Help_Text_UsingMailbox"] = 0x1B58D3, + ["Help_Text_UsingMailbox2"] = 0x1B59D6, + ["Help_Text_UsingMove"] = 0x1B5875, + ["Help_Text_UsingMoveOutsideOfBattle"] = 0x1B57EE, + ["Help_Text_UsingOption"] = 0x1B5795, + ["Help_Text_UsingPC"] = 0x1B5834, + ["Help_Text_UsingPlayer"] = 0x1B577B, + ["Help_Text_UsingPlayersPC"] = 0x1B5893, + ["Help_Text_UsingPokeBall"] = 0x1B5767, + ["Help_Text_UsingPokedex"] = 0x1B56E3, + ["Help_Text_UsingPokemon"] = 0x1B56F4, + ["Help_Text_UsingPokemon2"] = 0x1B591D, + ["Help_Text_UsingPotion"] = 0x1B57A5, + ["Help_Text_UsingProfOaksPC"] = 0x1B58E5, + ["Help_Text_UsingRock"] = 0x1B5A29, + ["Help_Text_UsingRun"] = 0x1B59E8, + ["Help_Text_UsingSave"] = 0x1B5787, + ["Help_Text_UsingShift"] = 0x1B592E, + ["Help_Text_UsingSummary"] = 0x1B5705, + ["Help_Text_UsingSummary2"] = 0x1B593E, + ["Help_Text_UsingSwitch"] = 0x1B5717, + ["Help_Text_UsingTM"] = 0x1B57CF, + ["Help_Text_UsingTownMap"] = 0x1B57B8, + ["Help_Text_UsingTypeMatchupList"] = 0x1BA0F1, + ["Help_Text_UsingWithdraw"] = 0x1B5850, + ["Help_Text_UsingWithdrawItem"] = 0x1B58A4, + ["Help_Text_UsingWithdrawItem2"] = 0x1B59A7, + ["Help_Text_WantToAddMoreMoves"] = 0x1B338C, + ["Help_Text_WantToEndGame"] = 0x1B3457, + ["Help_Text_WantToEndSafari"] = 0x1B34F6, + ["Help_Text_WantToMakeMonStronger"] = 0x1B33A6, + ["Help_Text_WhatAreHiddenMoves"] = 0x1B335C, + ["Help_Text_WhatAreMoves"] = 0x1B3344, + ["Help_Text_WhatAreMyAdventureBasics"] = 0x1B3189, + ["Help_Text_WhatArePokemon"] = 0x1B9C1D, + ["Help_Text_WhatAreSafariRules"] = 0x1B34D6, + ["Help_Text_WhatDoIDoInCave"] = 0x1B33EA, + ["Help_Text_WhatDoIDoInSafari"] = 0x1B34B7, + ["Help_Text_WhatDoesHiddenMoveDo"] = 0x1B349B, + ["Help_Text_WhatDoesThisTermMean"] = 0x1B2E2E, + ["Help_Text_WhatHappenedToItemIGot"] = 0x1B3168, + ["Help_Text_WhatHappensIfAllMyMonsFaint"] = 0x1B3290, + ["Help_Text_WhatIsAGym"] = 0x1B3516, + ["Help_Text_WhatIsAMonsVitality"] = 0x1B3243, + ["Help_Text_WhatIsStatusProblem"] = 0x1B3276, + ["Help_Text_WhatIsThatPersonLike"] = 0x1B3481, + ["Help_Text_WhatMovesShouldIUse"] = 0x1B3373, + ["Help_Text_WhatShouldIBeDoing"] = 0x1B30A9, + ["Help_Text_WhatShouldIDo"] = 0x1B2DF8, + ["Help_Text_WhatsABattle"] = 0x1B3215, + ["Help_Text_WhatsAMon"] = 0x1B346F, + ["Help_Text_WhatsATrainer"] = 0x1B32F9, + ["Help_Text_WhatsPokemonCenter"] = 0x1B3427, + ["Help_Text_WhatsPokemonMart"] = 0x1B3440, + ["Help_Text_WhenCanIUseItem"] = 0x1B31FF, + ["Help_Text_WhereDoMonsAppear"] = 0x1B332B, + ["Help_Text_WirelessAdapter"] = 0x1B9BD0, + ["LilycoveCity_Text_BecauseYoureTrainer"] = 0x1C07DF, + ["LilycoveCity_Text_BerrySuitsYou"] = 0x1C0799, + ["LilycoveCity_Text_PokeblocksSuitPokemon"] = 0x1C07FB, + ["OneIsland_KindleRoad_Text_AbigailRematchIntro"] = 0x1C37E7, + ["OneIsland_KindleRoad_Text_BryceRematchIntro"] = 0x1C3987, + ["OneIsland_KindleRoad_Text_ClaireRematchIntro"] = 0x1C39C6, + ["OneIsland_KindleRoad_Text_FinnRematchIntro"] = 0x1C3807, + ["OneIsland_KindleRoad_Text_GarrettRematchIntro"] = 0x1C3835, + ["OneIsland_KindleRoad_Text_HughRematchIntro"] = 0x1C3943, + ["OneIsland_KindleRoad_Text_KiaRematchIntro"] = 0x1C3A05, + ["OneIsland_KindleRoad_Text_MariaRematchIntro"] = 0x1C37B5, + ["OneIsland_KindleRoad_Text_MikRematchIntro"] = 0x1C3A55, + ["OneIsland_KindleRoad_Text_SharonRematchIntro"] = 0x1C389F, + ["OneIsland_KindleRoad_Text_SheaRematchIntro"] = 0x1C38FA, + ["OneIsland_KindleRoad_Text_TanyaRematchIntro"] = 0x1C38CA, + ["OneIsland_KindleRoad_Text_TommyRematchIntro"] = 0x1C386A, + ["OneIsland_TreasureBeach_Text_AmaraRematchIntro"] = 0x1C3773, + ["PalletTown_Text_HmmIsThatRight"] = 0x1B1C8B, + ["PalletTown_Text_LookCopiedTrainerTipsSign"] = 0x1B1D0B, + ["PalletTown_Text_OhLookLook"] = 0x1B1C9F, + ["PalletTown_Text_PressStartToOpenMenu"] = 0x1B1CC1, + ["PalletTown_Text_PressStartToOpenMenuCopy"] = 0x1B1D51, + ["PalletTown_Text_ReadItReadIt"] = 0x1B1CAF, + ["PalletTown_Text_SignsAreUsefulArentThey"] = 0x1B1CEC, + ["PetalburgCity_Gym_Text_GiveEnigmaBerry"] = 0x1C0523, + ["PokedexRating_Text_Complete"] = 0x1A7175, + ["PokedexRating_Text_HaHa"] = 0x1A72A6, + ["PokedexRating_Text_HowIsPokedexComingAlong"] = 0x1A6C51, + ["PokedexRating_Text_LessThan10"] = 0x1A6D17, + ["PokedexRating_Text_LessThan100"] = 0x1A7031, + ["PokedexRating_Text_LessThan110"] = 0x1A7063, + ["PokedexRating_Text_LessThan120"] = 0x1A70A5, + ["PokedexRating_Text_LessThan130"] = 0x1A70D8, + ["PokedexRating_Text_LessThan140"] = 0x1A7108, + ["PokedexRating_Text_LessThan150"] = 0x1A7137, + ["PokedexRating_Text_LessThan20"] = 0x1A6D6D, + ["PokedexRating_Text_LessThan30"] = 0x1A6DDF, + ["PokedexRating_Text_LessThan40"] = 0x1A6E36, + ["PokedexRating_Text_LessThan50"] = 0x1A6EA4, + ["PokedexRating_Text_LessThan60"] = 0x1A6F0B, + ["PokedexRating_Text_LessThan70"] = 0x1A6F71, + ["PokedexRating_Text_LessThan80"] = 0x1A6FAB, + ["PokedexRating_Text_LessThan90"] = 0x1A6FF1, + ["PokedexRating_Text_LookForwardToFilledNationalDex"] = 0x1A71EE, + ["PokedexRating_Text_LoveSeeingYourPokedex"] = 0x1A72F2, + ["PokedexRating_Text_NationalDexSeenXOwnedY"] = 0x1A71AA, + ["PokedexRating_Text_SeenXOwnedY"] = 0x1A6CA3, + ["PokedexRating_Text_ThankYouMadeDreamReality"] = 0x1A72AD, + ["PokedexRating_Text_Wroooaaarrr"] = 0x1A7291, + ["PokedexRating_Text_YouveCompletedDex"] = 0x1A722F, + ["Pokedude_Text_BattlersTakeTurnsAttacking"] = 0x1C5FDC, + ["Pokedude_Text_BestIfTargetStatused"] = 0x1C66CF, + ["Pokedude_Text_ButterfreeDoubleResistsGrass"] = 0x1C6446, + ["Pokedude_Text_ButterfreeGoodAgainstOddish"] = 0x1C657A, + ["Pokedude_Text_CantDoubleUpOnStatus"] = 0x1C6787, + ["Pokedude_Text_GrassEffectiveAgainstWater"] = 0x1C6301, + ["Pokedude_Text_HealStatusRightAway"] = 0x1C615A, + ["Pokedude_Text_LetMeThrowBall"] = 0x1C684B, + ["Pokedude_Text_LetsTryShiftingMons"] = 0x1C63A9, + ["Pokedude_Text_MyRattataFasterThanPidgey"] = 0x1C5FA7, + ["Pokedude_Text_MyRattataWonGetsEXP"] = 0x1C601C, + ["Pokedude_Text_PickBestKindOfBall"] = 0x1C686C, + ["Pokedude_Text_ShiftingUsesTurn"] = 0x1C63F9, + ["Pokedude_Text_SpeedierBattlerGoesFirst"] = 0x1C5F69, + ["Pokedude_Text_UhOhRattataPoisoned"] = 0x1C60FA, + ["Pokedude_Text_UsingItemTakesTurn"] = 0x1C6196, + ["Pokedude_Text_WaterNotVeryEffectiveAgainstGrass"] = 0x1C6202, + ["Pokedude_Text_WeakenMonBeforeCatching"] = 0x1C6645, + ["Pokedude_Text_YayWeManagedToWin"] = 0x1C61EA, + ["Pokedude_Text_YeahWeWon"] = 0x1C6637, + ["PokemonJournal_Text_SpecialFeatureAgatha"] = 0x1B19A8, + ["PokemonJournal_Text_SpecialFeatureBlaine"] = 0x1B17E9, + ["PokemonJournal_Text_SpecialFeatureBrock"] = 0x1B146A, + ["PokemonJournal_Text_SpecialFeatureBruno"] = 0x1B1913, + ["PokemonJournal_Text_SpecialFeatureDaisyOak"] = 0x1B1B3D, + ["PokemonJournal_Text_SpecialFeatureErika"] = 0x1B161B, + ["PokemonJournal_Text_SpecialFeatureKoga"] = 0x1B16A8, + ["PokemonJournal_Text_SpecialFeatureLance"] = 0x1B1A31, + ["PokemonJournal_Text_SpecialFeatureLorelei"] = 0x1B187F, + ["PokemonJournal_Text_SpecialFeatureLtSurge"] = 0x1B1558, + ["PokemonJournal_Text_SpecialFeatureMisty"] = 0x1B14E8, + ["PokemonJournal_Text_SpecialFeatureMrFuji"] = 0x1B1BD1, + ["PokemonJournal_Text_SpecialFeatureProfOak"] = 0x1B1AB9, + ["PokemonJournal_Text_SpecialFeatureSabrina"] = 0x1B1753, + ["RecordCorner_Text_ThanksForComing"] = 0x1BC906, + ["Route104_PrettyPetalFlowerShop_Text_BerriesExplanation"] = 0x1C0A4E, + ["Route104_PrettyPetalFlowerShop_Text_FlowersBringHappiness"] = 0x1C0B0B, + ["Route104_PrettyPetalFlowerShop_Text_ImGrowingFlowers"] = 0x1C0BE5, + ["Route104_PrettyPetalFlowerShop_Text_IntroLearnAboutBerries"] = 0x1C0A1A, + ["Route104_PrettyPetalFlowerShop_Text_LearnAboutBerries"] = 0x1C0A07, + ["Route104_PrettyPetalFlowerShop_Text_MachineMixesBerries"] = 0x1C0C12, + ["Route104_PrettyPetalFlowerShop_Text_ThisIsPrettyPetalFlowerShop"] = 0x1C09DF, + ["Route104_PrettyPetalFlowerShop_Text_WailmerPailExplanation"] = 0x1C0B73, + ["Route104_PrettyPetalFlowerShop_Text_YouCanHaveThis"] = 0x1C0B29, + ["Route104_Text_PlantBerriesInSoilTakeThis"] = 0x1C054C, + ["Route104_Text_TrainersOftenMakeMonHoldBerries"] = 0x1C05A8, + ["Route10_Text_CarolRematchIntro"] = 0x1C1D14, + ["Route10_Text_ClarkRematchIntro"] = 0x1C1C4F, + ["Route10_Text_HeidiRematchIntro"] = 0x1C1C9A, + ["Route10_Text_HermanRematchIntro"] = 0x1C1C76, + ["Route10_Text_MarkRematchIntro"] = 0x1C1BFA, + ["Route10_Text_TrentRematchIntro"] = 0x1C1CD3, + ["Route111_Text_TryToMakeRedPokeblock"] = 0x1C0629, + ["Route111_Text_WateredPlantsEveryDayTakeBerry"] = 0x1C05ED, + ["Route111_Text_WhatColorBerriesToLookForToday"] = 0x1C064A, + ["Route114_Text_GatheringBerriesForContest"] = 0x1C0662, + ["Route114_Text_GoodLuckToYouToo"] = 0x1C069C, + ["Route114_Text_WhatBerriesShouldIPlantToday"] = 0x1C06A6, + ["Route11_Text_BernieRematchIntro"] = 0x1C1F1D, + ["Route11_Text_BraxtonRematchIntro"] = 0x1C1DE1, + ["Route11_Text_DarianRematchIntro"] = 0x1C1E9F, + ["Route11_Text_DaveRematchIntro"] = 0x1C1F40, + ["Route11_Text_DillonRematchIntro"] = 0x1C1E1F, + ["Route11_Text_DirkRematchIntro"] = 0x1C1E57, + ["Route11_Text_EddieRematchIntro"] = 0x1C1DB5, + ["Route11_Text_HugoRematchIntro"] = 0x1C1D50, + ["Route11_Text_JasperRematchIntro"] = 0x1C1D79, + ["Route11_Text_YasuRematchIntro"] = 0x1C1EE2, + ["Route120_Text_BerryIsRareRaiseItWithCare"] = 0x1C075F, + ["Route120_Text_IllGetMoreBerriesFromBerryMaster"] = 0x1C0782, + ["Route120_Text_OwnImpressionsImportantTakeThis"] = 0x1C073B, + ["Route120_Text_PokeblocksExpressionOfLove"] = 0x1C06DE, + ["Route120_Text_YouUnderstandTakeThis"] = 0x1C071B, + ["Route121_SafariZoneEntrance_Text_TrainerTip"] = 0x1C0159, + ["Route123_BerryMastersHouse_Text_Ah"] = 0x1C09DA, + ["Route123_BerryMastersHouse_Text_DoneForToday"] = 0x1C08D5, + ["Route123_BerryMastersHouse_Text_GoodSayingTakeThis"] = 0x1C0974, + ["Route123_BerryMastersHouse_Text_HeardAGoodSayingLately"] = 0x1C08FD, + ["Route123_BerryMastersHouse_Text_InspirationalTakeThis"] = 0x1C0948, + ["Route123_BerryMastersHouse_Text_JoyNeverGoesOutOfMyLife"] = 0x1C09A4, + ["Route123_BerryMastersHouse_Text_VisitPrettyPetalFlowerShop"] = 0x1C089C, + ["Route123_BerryMastersHouse_Text_WhyBeStingyTakeAnother"] = 0x1C0888, + ["Route123_BerryMastersHouse_Text_YoureDeservingOfBerry"] = 0x1C0825, + ["Route12_Text_AndrewRematchIntro"] = 0x1C2134, + ["Route12_Text_ChipRematchIntro"] = 0x1C1FD8, + ["Route12_Text_ElliotRematchIntro"] = 0x1C20D4, + ["Route12_Text_GiaRematchIntro"] = 0x1C219B, + ["Route12_Text_HankRematchIntro"] = 0x1C209C, + ["Route12_Text_JesRematchIntro"] = 0x1C216B, + ["Route12_Text_JustinRematchIntro"] = 0x1C2008, + ["Route12_Text_LucaRematchIntro"] = 0x1C203B, + ["Route12_Text_NedRematchIntro"] = 0x1C1F9D, + ["Route13_Text_AlmaRematchIntro"] = 0x1C2299, + ["Route13_Text_GwenRematchIntro"] = 0x1C225B, + ["Route13_Text_JaredRematchIntro"] = 0x1C236B, + ["Route13_Text_LolaRematchIntro"] = 0x1C2306, + ["Route13_Text_PerryRematchIntro"] = 0x1C22CA, + ["Route13_Text_RobertRematchIntro"] = 0x1C2383, + ["Route13_Text_SebastianRematchIntro"] = 0x1C21EE, + ["Route13_Text_SheilaRematchIntro"] = 0x1C2340, + ["Route13_Text_SusieRematchIntro"] = 0x1C220C, + ["Route13_Text_ValerieRematchIntro"] = 0x1C223C, + ["Route14_Text_BeckRematchIntro"] = 0x1C2425, + ["Route14_Text_BennyRematchIntro"] = 0x1C2505, + ["Route14_Text_CarterRematchIntro"] = 0x1C23C1, + ["Route14_Text_DonaldRematchIntro"] = 0x1C24CB, + ["Route14_Text_GeraldRematchIntro"] = 0x1C259E, + ["Route14_Text_IsaacRematchIntro"] = 0x1C2572, + ["Route14_Text_JanRematchIntro"] = 0x1C261B, + ["Route14_Text_KiriRematchIntro"] = 0x1C25FB, + ["Route14_Text_LukasRematchIntro"] = 0x1C2531, + ["Route14_Text_MalikRematchIntro"] = 0x1C25D6, + ["Route14_Text_MarlonRematchIntro"] = 0x1C2461, + ["Route14_Text_MitchRematchIntro"] = 0x1C23EF, + ["Route15_Text_AlexRematchIntro"] = 0x1C2814, + ["Route15_Text_BeckyRematchIntro"] = 0x1C268D, + ["Route15_Text_CeliaRematchIntro"] = 0x1C2846, + ["Route15_Text_ChesterRematchIntro"] = 0x1C2717, + ["Route15_Text_EdwinRematchIntro"] = 0x1C26D3, + ["Route15_Text_ErnestRematchIntro"] = 0x1C27E7, + ["Route15_Text_GraceRematchIntro"] = 0x1C2753, + ["Route15_Text_KindraRematchIntro"] = 0x1C2650, + ["Route15_Text_MyaRematchIntro"] = 0x1C28A1, + ["Route15_Text_OliviaRematchIntro"] = 0x1C279D, + ["Route15_Text_RonRematchIntro"] = 0x1C28EC, + ["Route15_Text_YazminRematchIntro"] = 0x1C287D, + ["Route16_Text_CamronRematchIntro"] = 0x1C29B0, + ["Route16_Text_HideoRematchIntro"] = 0x1C297B, + ["Route16_Text_JedRematchIntro"] = 0x1C2A19, + ["Route16_Text_KojiRematchIntro"] = 0x1C2925, + ["Route16_Text_LaoRematchIntro"] = 0x1C2913, + ["Route16_Text_LeaRematchIntro"] = 0x1C2A53, + ["Route16_Text_LukeRematchIntro"] = 0x1C2944, + ["Route16_Text_RubenRematchIntro"] = 0x1C29EB, + ["Route17_Text_BillyRematchIntro"] = 0x1C2B06, + ["Route17_Text_CoreyRematchIntro"] = 0x1C2BDA, + ["Route17_Text_IsaiahRematchIntro"] = 0x1C2AC4, + ["Route17_Text_JamalRematchIntro"] = 0x1C2B9C, + ["Route17_Text_JaxonRematchIntro"] = 0x1C2BE6, + ["Route17_Text_NikolasRematchIntro"] = 0x1C2B2C, + ["Route17_Text_RaulRematchIntro"] = 0x1C2A88, + ["Route17_Text_VirgilRematchIntro"] = 0x1C2AF0, + ["Route17_Text_WilliamRematchIntro"] = 0x1C2C10, + ["Route17_Text_ZeekRematchIntro"] = 0x1C2B5E, + ["Route18_Text_JacobRematchIntro"] = 0x1C2CA8, + ["Route18_Text_RamiroRematchIntro"] = 0x1C2C7B, + ["Route18_Text_WiltonRematchIntro"] = 0x1C2C2B, + ["Route19_Text_AliceRematchIntro"] = 0x1C2E9D, + ["Route19_Text_AnyaRematchIntro"] = 0x1C2E4A, + ["Route19_Text_AxleRematchIntro"] = 0x1C2EC0, + ["Route19_Text_ConnieRematchIntro"] = 0x1C2EFC, + ["Route19_Text_DavidRematchIntro"] = 0x1C2DA7, + ["Route19_Text_DouglasRematchIntro"] = 0x1C2D7D, + ["Route19_Text_LiaRematchIntro"] = 0x1C2F41, + ["Route19_Text_LucRematchIntro"] = 0x1C2FAE, + ["Route19_Text_MatthewRematchIntro"] = 0x1C2D4B, + ["Route19_Text_ReeceRematchIntro"] = 0x1C2D19, + ["Route19_Text_RichardRematchIntro"] = 0x1C2CEE, + ["Route19_Text_TonyRematchIntro"] = 0x1C2DE9, + ["Route20_Text_BarryRematchIntro"] = 0x1C2FF3, + ["Route20_Text_DarrinRematchIntro"] = 0x1C30ED, + ["Route20_Text_DeanRematchIntro"] = 0x1C30B0, + ["Route20_Text_IreneRematchIntro"] = 0x1C3095, + ["Route20_Text_MelissaRematchIntro"] = 0x1C31C4, + ["Route20_Text_MissyRematchIntro"] = 0x1C3185, + ["Route20_Text_NoraRematchIntro"] = 0x1C3149, + ["Route20_Text_RogerRematchIntro"] = 0x1C312C, + ["Route20_Text_ShirleyRematchIntro"] = 0x1C302E, + ["Route20_Text_TiffanyRematchIntro"] = 0x1C305F, + ["Route21_North_Text_CueBallRematchIntro"] = 0x1C3287, + ["Route21_North_Text_IanRematchIntro"] = 0x1C33AE, + ["Route21_North_Text_LilRematchIntro"] = 0x1C3378, + ["Route21_North_Text_RonaldRematchIntro"] = 0x1C3208, + ["Route21_North_Text_SpencerRematchIntro"] = 0x1C3264, + ["Route21_North_Text_WadeRematchIntro"] = 0x1C3231, + ["Route21_South_Text_ClaudeRematchIntro"] = 0x1C331D, + ["Route21_South_Text_JackRematchIntro"] = 0x1C3298, + ["Route21_South_Text_JeromeRematchIntro"] = 0x1C32D3, + ["Route21_South_Text_NolanRematchIntro"] = 0x1C3356, + ["Route21_South_Text_RolandRematchIntro"] = 0x1C32FD, + ["Route24_Text_AliRematchIntro"] = 0x1C3685, + ["Route24_Text_CaleRematchIntro"] = 0x1C36DA, + ["Route24_Text_EthanRematchIntro"] = 0x1C360E, + ["Route24_Text_ReliRematchIntro"] = 0x1C3624, + ["Route24_Text_ShaneRematchIntro"] = 0x1C35EE, + ["Route24_Text_TimmyRematchIntro"] = 0x1C3657, + ["Route25_Text_ChadRematchIntro"] = 0x1C34D4, + ["Route25_Text_DanRematchIntro"] = 0x1C3404, + ["Route25_Text_FlintRematchIntro"] = 0x1C3445, + ["Route25_Text_FranklinRematchIntro"] = 0x1C353B, + ["Route25_Text_HaleyRematchIntro"] = 0x1C350A, + ["Route25_Text_JoeyRematchIntro"] = 0x1C33E7, + ["Route25_Text_KelseyRematchIntro"] = 0x1C349C, + ["Route25_Text_NobRematchIntro"] = 0x1C357E, + ["Route25_Text_WayneRematchIntro"] = 0x1C35BC, + ["Route3_Text_BenRematchIntro"] = 0x1C149D, + ["Route3_Text_CalvinRematchIntro"] = 0x1C1588, + ["Route3_Text_ColtonRematchIntro"] = 0x1C147A, + ["Route3_Text_GregRematchIntro"] = 0x1C1521, + ["Route3_Text_JamesRematchIntro"] = 0x1C15C5, + ["Route3_Text_JaniceRematchIntro"] = 0x1C14F3, + ["Route3_Text_RobinRematchIntro"] = 0x1C15F9, + ["Route3_Text_SallyRematchIntro"] = 0x1C155D, + ["Route4_Text_CrissyRematchIntro"] = 0x1C160F, + ["Route6_Text_ElijahRematchIntro"] = 0x1C176B, + ["Route6_Text_IsabelleRematchIntro"] = 0x1C1746, + ["Route6_Text_JeffRematchIntro"] = 0x1C1723, + ["Route6_Text_KeigoRematchIntro"] = 0x1C16E2, + ["Route6_Text_NancyRematchIntro"] = 0x1C166D, + ["Route6_Text_RickyRematchIntro"] = 0x1C163C, + ["Route8_Text_AidanRematchIntro"] = 0x1C1793, + ["Route8_Text_AndreaRematchIntro"] = 0x1C18AA, + ["Route8_Text_AnneRematchIntro"] = 0x1C1A0D, + ["Route8_Text_EliRematchIntro"] = 0x1C19E5, + ["Route8_Text_GlennRematchIntro"] = 0x1C17F1, + ["Route8_Text_JarenRematchIntro"] = 0x1C19BC, + ["Route8_Text_JuliaRematchIntro"] = 0x1C1955, + ["Route8_Text_LeslieRematchIntro"] = 0x1C1873, + ["Route8_Text_MeganRematchIntro"] = 0x1C18DC, + ["Route8_Text_PaigeRematchIntro"] = 0x1C1834, + ["Route8_Text_RicardoRematchIntro"] = 0x1C199C, + ["Route8_Text_RichRematchIntro"] = 0x1C191F, + ["Route8_Text_StanRematchIntro"] = 0x1C17CD, + ["Route9_Text_AlanRematchIntro"] = 0x1C1BBE, + ["Route9_Text_AliciaRematchIntro"] = 0x1C1A2E, + ["Route9_Text_BrentRematchIntro"] = 0x1C1B83, + ["Route9_Text_BriceRematchIntro"] = 0x1C1B5E, + ["Route9_Text_CaitlinRematchIntro"] = 0x1C1AFB, + ["Route9_Text_ChrisRematchIntro"] = 0x1C1A5D, + ["Route9_Text_ConnerRematchIntro"] = 0x1C1BDC, + ["Route9_Text_DrewRematchIntro"] = 0x1C1A9B, + ["Route9_Text_JeremyRematchIntro"] = 0x1C1B37, + ["SafariZone_North_Text_Fisherman"] = 0x1C00B6, + ["SafariZone_North_Text_Man"] = 0x1C00EF, + ["SafariZone_Northwest_Text_Man"] = 0x1C0079, + ["SafariZone_RestHouse_Text_FatMan"] = 0x1C0243, + ["SafariZone_RestHouse_Text_PsychicM"] = 0x1C01FB, + ["SafariZone_RestHouse_Text_Youngster"] = 0x1C01B4, + ["SafariZone_South_Text_Boy"] = 0x1BFFCE, + ["SafariZone_South_Text_EnjoyTheRestOfYourAdventure"] = 0x1BFF51, + ["SafariZone_South_Text_ExitEarlyThankYouForPlaying"] = 0x1BFF66, + ["SafariZone_South_Text_GoodLuck"] = 0x1BFFA1, + ["SafariZone_South_Text_Man"] = 0x1BFFFD, + ["SafariZone_South_Text_StillHaveTimeExit"] = 0x1BFF30, + ["SafariZone_South_Text_Youngster"] = 0x1C011B, + ["SafariZone_Southwest_Text_RestHouseSign"] = 0x1C0190, + ["SafariZone_Southwest_Text_Woman"] = 0x1C003F, + ["SafariZone_Text_ComeInAndEnjoy"] = 0x1BFD52, + ["SafariZone_Text_ExcuseMeYourPCBoxIsFull"] = 0x1BFEAC, + ["SafariZone_Text_FirstTimeInfo"] = 0x1BFD67, + ["SafariZone_Text_HereAreYourSafariBalls"] = 0x1BFE47, + ["SafariZone_Text_NotEnoughMoney"] = 0x1BFE28, + ["SafariZone_Text_OutOfBalls"] = 0x1BFC53, + ["SafariZone_Text_PlayAnotherTime"] = 0x1BFE0F, + ["SafariZone_Text_PleaseEnjoyYourself"] = 0x1BFE70, + ["SafariZone_Text_Received30SafariBalls"] = 0x1BFE58, + ["SafariZone_Text_ThatWillBe500Please"] = 0x1BFE35, + ["SafariZone_Text_TimesUp"] = 0x1BFC1B, + ["SafariZone_Text_WelcomeFirstTime"] = 0x1BFD30, + ["SafariZone_Text_WelcomeToSafariZone"] = 0x1BFC9D, + ["SafariZone_Text_WouldYouLikeToExit"] = 0x1BFBE9, + ["SafariZone_Text_WouldYouLikeToPlay"] = 0x1BFDD7, + ["SafariZone_Text_YouNeedPokeblockCase"] = 0x1BFECC, + ["SevenIsland_SevaultCanyon_Entrance_Text_EveRematchIntro"] = 0x1C4416, + ["SevenIsland_SevaultCanyon_Entrance_Text_JonRematchIntro"] = 0x1C444C, + ["SevenIsland_SevaultCanyon_Entrance_Text_MadelineRematchIntro"] = 0x1C43EC, + ["SevenIsland_SevaultCanyon_Entrance_Text_MasonRematchIntro"] = 0x1C4374, + ["SevenIsland_SevaultCanyon_Entrance_Text_MiahRematchIntro"] = 0x1C4327, + ["SevenIsland_SevaultCanyon_Entrance_Text_NicolasRematchIntro"] = 0x1C43AD, + ["SevenIsland_SevaultCanyon_Text_CyndyRematchIntro"] = 0x1C4491, + ["SevenIsland_SevaultCanyon_Text_EvanRematchIntro"] = 0x1C44CB, + ["SevenIsland_SevaultCanyon_Text_JacksonRematchIntro"] = 0x1C454E, + ["SevenIsland_SevaultCanyon_Text_KatelynRematchIntro"] = 0x1C458E, + ["SevenIsland_SevaultCanyon_Text_LeroyRematchIntro"] = 0x1C45C2, + ["SevenIsland_SevaultCanyon_Text_LexRematchIntro"] = 0x1C4662, + ["SevenIsland_SevaultCanyon_Text_MichelleRematchIntro"] = 0x1C45FB, + ["SevenIsland_SevaultCanyon_Text_NyaRematchIntro"] = 0x1C4699, + ["SevenIsland_TanobyRuins_Text_BenjaminRematchIntro"] = 0x1C470A, + ["SevenIsland_TanobyRuins_Text_BrandonRematchIntro"] = 0x1C46D3, + ["SevenIsland_TanobyRuins_Text_CliffordRematchIntro"] = 0x1C476A, + ["SevenIsland_TanobyRuins_Text_EdnaRematchIntro"] = 0x1C4739, + ["SevenIsland_TrainerTower_Text_DarioRematchIntro"] = 0x1C42D6, + ["SevenIsland_TrainerTower_Text_RodetteRematchIntro"] = 0x1C42EE, + ["SixIsland_GreenPath_Text_JaclynRematchIntro"] = 0x1C3FE9, + ["SixIsland_OutcastIsland_Text_AvaRematchIntro"] = 0x1C3F7B, + ["SixIsland_OutcastIsland_Text_GebRematchIntro"] = 0x1C3FB3, + ["SixIsland_OutcastIsland_Text_MymoRematchIntro"] = 0x1C3F11, + ["SixIsland_OutcastIsland_Text_NicoleRematchIntro"] = 0x1C3F51, + ["SixIsland_OutcastIsland_Text_TylorRematchIntro"] = 0x1C3ED0, + ["SixIsland_RuinValley_Text_DarylRematchIntro"] = 0x1C4280, + ["SixIsland_RuinValley_Text_FosterRematchIntro"] = 0x1C41D4, + ["SixIsland_RuinValley_Text_HectorRematchIntro"] = 0x1C42A0, + ["SixIsland_RuinValley_Text_LarryRematchIntro"] = 0x1C4210, + ["SixIsland_RuinValley_Text_StanlyRematchIntro"] = 0x1C4196, + ["SixIsland_WaterPath_Text_DeniseRematchIntro"] = 0x1C40D9, + ["SixIsland_WaterPath_Text_EarlRematchIntro"] = 0x1C40FA, + ["SixIsland_WaterPath_Text_EdwardRematchIntro"] = 0x1C4057, + ["SixIsland_WaterPath_Text_MiaRematchIntro"] = 0x1C4166, + ["SixIsland_WaterPath_Text_MiuRematchIntro"] = 0x1C4138, + ["SixIsland_WaterPath_Text_RoseRematchIntro"] = 0x1C4028, + ["SixIsland_WaterPath_Text_SamirRematchIntro"] = 0x1C407F, + ["SootopolisCity_Text_BiggestMonPlayersXCmColossus"] = 0x1C0399, + ["SootopolisCity_Text_GiveYouThisBerryToo"] = 0x1C0CF5, + ["SootopolisCity_Text_GrandMonThanksForShowingMe"] = 0x1C02CB, + ["SootopolisCity_Text_HuhBagIsCrammedFull"] = 0x1C0317, + ["SootopolisCity_Text_IveSeenBiggerMonThanThisOne"] = 0x1C032B, + ["SootopolisCity_Text_LikeSeasonBornIn"] = 0x1C0D32, + ["SootopolisCity_Text_NameIsKiriHaveOneOfThese"] = 0x1C0C74, + ["SootopolisCity_Text_OhDoesntMatter"] = 0x1C0D8F, + ["SootopolisCity_Text_PleaseShowMeBigMon"] = 0x1C0283, + ["SootopolisCity_Text_QuiteSomethingButNotMon"] = 0x1C034D, + ["SootopolisCity_Text_ReceivedOneItem"] = 0x1C0309, + ["SootopolisCity_Text_ThenILoveAutumn"] = 0x1C0D66, + ["SootopolisCity_Text_TooBadDontHaveBigMon"] = 0x1C036C, + ["SootopolisCity_Text_WhatKindOfWishInYourName"] = 0x1C0D16, + ["SouthernIsland_Exterior_Text_Sign"] = 0x1C0500, + ["SouthernIsland_Interior_Text_Sign"] = 0x1C04DC, + ["Test_Text_Empty"] = 0x1C55C8, + ["Test_Text_ThisIsACoordEvent"] = 0x1C55A4, + ["Test_Text_ThisIsASignpost"] = 0x1C558D, + ["Test_Text_WelcomeToWorldOfPokemon"] = 0x1C556D, + ["Text_ATownMap"] = 0x1C0DF1, + ["Text_AccessWhichPC"] = 0x1A508A, + ["Text_AccessedBillsPC"] = 0x1A50EF, + ["Text_AccessedPlayersPC"] = 0x1A50DD, + ["Text_AccessedProfOaksPC"] = 0x1A5BC6, + ["Text_AccessedSomeonesPC"] = 0x1A50A7, + ["Text_AdapterNotConnectedMinigame"] = 0x1BCFE8, + ["Text_AdvertisingPoster"] = 0x1C10FC, + ["Text_AllAboardSeagallopNum"] = 0x1A641B, + ["Text_AllGoodToGo"] = 0x1BD080, + ["Text_AppearsToBeUndergoingAdjustments"] = 0x1A5667, + ["Text_AsYouLikeThen"] = 0x1C04C9, + ["Text_BagIsFull"] = 0x1A5207, + ["Text_BagItemCanBeRegistered"] = 0x1A5375, + ["Text_BeautifulSkyWindow"] = 0x1C11DC, + ["Text_BigHoleInTheWall"] = 0x1A5606, + ["Text_BlinkingLights"] = 0x1C1217, + ["Text_Blueprints"] = 0x1C0FD8, + ["Text_Bookshelf"] = 0x1C0DB0, + ["Text_BootedUpPC"] = 0x1A5075, + ["Text_Burglary"] = 0x1C12D6, + ["Text_CantLetYouPass"] = 0x1A5776, + ["Text_CardKeyOpenedDoor"] = 0x1A5B88, + ["Text_ChangeYourProfile"] = 0x1BF1FB, + ["Text_ClosedLinkToProfOaksPC"] = 0x1A5C2E, + ["Text_CoinCaseIsFull"] = 0x1A5294, + ["Text_Computer"] = 0x1C10C1, + ["Text_Cup"] = 0x1C1194, + ["Text_CurrentTooFast"] = 0x1A55B3, + ["Text_CutTreeDown"] = 0x1BDF94, + ["Text_DadLetterArrivedForYouPlayer"] = 0x1C03B5, + ["Text_DadPassForShipNeverSeenBefore"] = 0x1C03D7, + ["Text_DecidedNotTo"] = 0x1BF1B2, + ["Text_DecorTransferredToPC"] = 0x1A532F, + ["Text_DescribeWhichGame"] = 0x1BCCFF, + ["Text_DishesPlatesNeatlyLinedUp"] = 0x1C0E19, + ["Text_DiveCantBeUsedHere"] = 0x1BE52F, + ["Text_DodrioBerryPickingInfo"] = 0x1BCE73, + ["Text_DontBeLikeThat"] = 0x1BF17B, + ["Text_DoorOpenedFarAway"] = 0x1A55EA, + ["Text_Dresser"] = 0x1C0E73, + ["Text_DugUpItemFromGround"] = 0x1A5700, + ["Text_EnterWhichPokemon"] = 0x1BD059, + ["Text_ExplainProfile"] = 0x1BF053, + ["Text_FerryToSlateportOhThatPass"] = 0x1C0407, + ["Text_FillOutQuestionnaire"] = 0x1A622C, + ["Text_FirstShouldRestoreMonsHealth"] = 0x1A5E89, + ["Text_Food"] = 0x1C0F99, + ["Text_FoundOneItem"] = 0x1A5231, + ["Text_FoundTMHMContainsMove"] = 0x1A63E8, + ["Text_FoundXCoins"] = 0x1A525C, + ["Text_FoundXItems"] = 0x1A52DD, + ["Text_GiveNicknameToReceivedMon"] = 0x1A5446, + ["Text_GiveNicknameToThisMon"] = 0x1A56A7, + ["Text_Gyaoo"] = 0x1A6448, + ["Text_HadQuiteAnExperienceTakeRest"] = 0x1A5FDF, + ["Text_HandedOverItem"] = 0x1A5690, + ["Text_HavePokedexRated"] = 0x1A5C03, + ["Text_HavingDiscountSaleToday"] = 0x1A51A3, + ["Text_HeardThatBefore"] = 0x1BF30D, + ["Text_HiWeMeetAgain"] = 0x1BF1E8, + ["Text_ISee"] = 0x1A5CCE, + ["Text_IdLikeToSeeRequest"] = 0x1A5C79, + ["Text_IdLoveToHearYourProfile"] = 0x1BEF88, + ["Text_ImpressiveMachine"] = 0x1C1273, + ["Text_Indigo_HighestAuthority"] = 0x1C0F59, + ["Text_Indigo_UltimateGoal"] = 0x1C0F19, + ["Text_ItNeedsCardKey"] = 0x1A5BAD, + ["Text_ItemfinderResponding"] = 0x1A56D2, + ["Text_Kitchen"] = 0x1C0E40, + ["Text_LeavingDoComeAgain"] = 0x1BD0BC, + ["Text_LightFilteringUseDive"] = 0x1BE4EF, + ["Text_LikeProfileWayItIs"] = 0x1BF252, + ["Text_LinkWasCanceled"] = 0x1A5435, + ["Text_LookedAtPlayersTrainerCard"] = 0x1BC84B, + ["Text_LookedAtPlayersTrainerCardColored"] = 0x1BC874, + ["Text_LooksLikeNothingHere"] = 0x1BE56D, + ["Text_MakingPreparations"] = 0x1A5351, + ["Text_MayIHelpYou"] = 0x1A6211, + ["Text_MomExplainHPGetPotions"] = 0x1A6046, + ["Text_MonFlewAway"] = 0x1A63C4, + ["Text_MonMayGoUnderwater"] = 0x1BE42B, + ["Text_MonMayPushBoulder"] = 0x1BE244, + ["Text_MonMaySmashRock"] = 0x1BE0E2, + ["Text_MonMaySurface"] = 0x1BE4A9, + ["Text_MonSentToBoxBillsBoxFull"] = 0x1A5DB1, + ["Text_MonSentToBoxInBillsPC"] = 0x1A5D31, + ["Text_MonSentToBoxInPC"] = 0x1A5E05, + ["Text_MonSentToBoxInSomeonesPC"] = 0x1A5CF1, + ["Text_MonSentToBoxSomeonesBoxFull"] = 0x1A5D6E, + ["Text_MonUsedDive"] = 0x1BE49B, + ["Text_MonUsedMove"] = 0x1BDFD7, + ["Text_MonUsedStrengthCanMoveBoulders"] = 0x1BE1FA, + ["Text_MonUsedWaterfall"] = 0x1BE378, + ["Text_MonsHealed"] = 0x1A5F9B, + ["Text_MonsHealedShouldBuyPotions"] = 0x1A5EC0, + ["Text_MoveCanOnlyBeLearnedOnce"] = 0x1A644F, + ["Text_NeatlyLinedUpTools"] = 0x1C124B, + ["Text_NoMoreRoomForPokemon"] = 0x1A5E31, + ["Text_NoRoomForAnotherDecor"] = 0x1A5301, + ["Text_NothingButPokemonBooks"] = 0x1C0DD4, + ["Text_NothingHereSailBackToLilycove"] = 0x1C049D, + ["Text_NothingToPutThemIn"] = 0x1A52B4, + ["Text_ObtainedTheDecor"] = 0x1A52F0, + ["Text_ObtainedTheX"] = 0x1A51F6, + ["Text_OhThatsBadgeGoRightAhead"] = 0x1A579D, + ["Text_OnlyDodrioAllowed"] = 0x1BD213, + ["Text_OnlyPassWithBadgeDontHaveYet"] = 0x1A57C1, + ["Text_OnlyPassWithBadgeOhGoAhead"] = 0x1A5839, + ["Text_OnlySkilledTrainersAllowedThrough"] = 0x1A572A, + ["Text_OpenedPkmnStorage"] = 0x1A50BE, + ["Text_Painting"] = 0x1C101B, + ["Text_PlayWhichGame"] = 0x1BD02D, + ["Text_PlayerBootedUpPC"] = 0x1A5420, + ["Text_PlayerFacingTVScreen"] = 0x1C1300, + ["Text_PlayerIsPlayingRightNowGoForIt"] = 0x1BCCCA, + ["Text_PlayerObtainedTheMon"] = 0x1A5DF1, + ["Text_PlayerWhatCanIDoForYou"] = 0x1A51D0, + ["Text_PleaseComeAgain"] = 0x1A5190, + ["Text_PokeMartShelf"] = 0x1C0DFD, + ["Text_PokecenterSign"] = 0x1C0EF7, + ["Text_PokemartSign"] = 0x1C0ECB, + ["Text_PokemonJumpInfo"] = 0x1BCD4D, + ["Text_PolishedWindow"] = 0x1C11BA, + ["Text_PowerPlantMachine"] = 0x1C1060, + ["Text_ProfileSaysSomethingAboutYou"] = 0x1BF28E, + ["Text_PutCoinsAwayInCoinCase"] = 0x1A526C, + ["Text_PutItemAway"] = 0x1A5218, + ["Text_QuestionnaireThankYou"] = 0x1A6265, + ["Text_RepelWoreOff"] = 0x1BFB6E, + ["Text_RestoredPkmnToFullHealth"] = 0x1A552B, + ["Text_RetryFromStartPlease"] = 0x1BD274, + ["Text_SailHomeWeWill"] = 0x1C04BB, + ["Text_SeaIsDeepUseDive"] = 0x1BE469, + ["Text_ShortJumpingPokemonAllowed"] = 0x1BD14B, + ["Text_Snacks"] = 0x1C0EAC, + ["Text_StrengthMadeMovingBouldersPossible"] = 0x1BE284, + ["Text_TakeSeatStartBattle"] = 0x1BC8AD, + ["Text_TakeSeatStartTrade"] = 0x1BC8DA, + ["Text_TakeYourPkmnForFewSeconds"] = 0x1A54E1, + ["Text_TalkToManToPlay"] = 0x1BCF2E, + ["Text_TastyFood"] = 0x1C1134, + ["Text_Telephone"] = 0x1C109C, + ["Text_TellMeWhatsYourProfile"] = 0x1BF017, + ["Text_TellMoreNextTime"] = 0x1BF36A, + ["Text_TerminateLinkConfirmation"] = 0x1BC7E2, + ["Text_TerminateLinkIfYouLeaveRoom"] = 0x1BC7A1, + ["Text_TestMsg"] = 0x1ACD45, + ["Text_ThankYouForAccessingMysteryGift"] = 0x1A6393, + ["Text_ThankYouForShowingMe"] = 0x1A5C9F, + ["Text_ThatsNotRight"] = 0x1A5CC3, + ["Text_TheDoorIsClosed"] = 0x1A5CD3, + ["Text_TheDoorIsOpen"] = 0x1A5CDF, + ["Text_TheresBedLetsRest"] = 0x1A63D6, + ["Text_TooBadBagFull"] = 0x1A5242, + ["Text_TrainerSchoolEmail"] = 0x1A53B2, + ["Text_TrainerTooBusyToNotice"] = 0x1BC827, + ["Text_TrashBin"] = 0x1C116E, + ["Text_TreeCanBeCutDown"] = 0x1BDFE3, + ["Text_UseRockSmash"] = 0x1BE09D, + ["Text_UseStrength"] = 0x1BE19A, + ["Text_UseWaterfall"] = 0x1BE33F, + ["Text_UsedSurf"] = 0x1A55A5, + ["Text_VideoGame"] = 0x1C12A7, + ["Text_VoiceRangOutDontRunAway"] = 0x1A5C4D, + ["Text_WallOfWaterCrashingDown"] = 0x1BE30A, + ["Text_WantToSurf"] = 0x1A556E, + ["Text_WantWhichFloor"] = 0x1A535C, + ["Text_WeHopeToSeeYouAgain"] = 0x1A5511, + ["Text_WeHopeToSeeYouAgain2"] = 0x1A505B, + ["Text_WelcomeCanYouWait"] = 0x1BCF69, + ["Text_WelcomeColosseum"] = 0x1A5133, + ["Text_WelcomeTimeCapsule"] = 0x1A5160, + ["Text_WelcomeTradeCenter"] = 0x1A5103, + ["Text_WelcomeWantToHealPkmn"] = 0x1A5483, + ["Text_WhatAreYouLikeNow"] = 0x1BF222, + ["Text_WhatsYourProfile"] = 0x1BF166, + ["Text_WhereDoYouWantToGo"] = 0x1A6407, + ["Text_WhitedOut"] = 0x1A61E5, + ["Text_WhitedOutLostMoney"] = 0x1A6197, + ["Text_WirelessClubUndergoingAdjustments"] = 0x1A561A, + ["Text_WouldYouLikeToMixRecords"] = 0x1A5028, + ["Text_YouCanAccessMysteryGift"] = 0x1A6359, + ["Text_YouKnowThoseWords"] = 0x1A62D3, + ["Text_YoureOneWhoBroughtOddPass"] = 0x1C0426, + ["ThreeIsland_BondBridge_Text_AlexisRematchIntro"] = 0x1C3B1D, + ["ThreeIsland_BondBridge_Text_AmiraRematchIntro"] = 0x1C3AF2, + ["ThreeIsland_BondBridge_Text_JoyRematchIntro"] = 0x1C3B6E, + ["ThreeIsland_BondBridge_Text_MegRematchIntro"] = 0x1C3B99, + ["ThreeIsland_BondBridge_Text_NikkiRematchIntro"] = 0x1C3AA7, + ["ThreeIsland_BondBridge_Text_TishaRematchIntro"] = 0x1C3B30, + ["ThreeIsland_BondBridge_Text_VioletRematchIntro"] = 0x1C3ABF, + ["Trade_Text_AwwOhWell"] = 0x1A59B6, + ["Trade_Text_DoYouHaveMonWantToTradeForMon"] = 0x1A5AD6, + ["Trade_Text_DoYouHaveMonWouldYouTradeForMon"] = 0x1A59FE, + ["Trade_Text_HasTradedMonGrownStronger"] = 0x1A5AA5, + ["Trade_Text_HeyThanks"] = 0x1A59DA, + ["Trade_Text_HowIsMyOldMon"] = 0x1A5B60, + ["Trade_Text_IsntMyOldMonGreat"] = 0x1A59E7, + ["Trade_Text_LookingForMonWannaTradeForMon"] = 0x1A597B, + ["Trade_Text_Thanks"] = 0x1A5A9D, + ["Trade_Text_ThanksYoureAPal"] = 0x1A5B4A, + ["Trade_Text_ThatsTooBad"] = 0x1A5B08, + ["Trade_Text_ThisIsNoMon"] = 0x1A5B18, + ["Trade_Text_ThisIsntMon"] = 0x1A5A6A, + ["Trade_Text_WellIfYouDontWantTo"] = 0x1A5A4E, + ["Trade_Text_WhatThatsNoMon"] = 0x1A59C6, + ["VSSeeker_Text_BatteryNotChargedNeedXSteps"] = 0x1C137C, + ["VSSeeker_Text_NoTrainersWithinRange"] = 0x1C13D6, + ["VSSeeker_Text_TrainersNotReady"] = 0x1C1429, + ["gBattleText_Rose"] = 0x3FCB4A, + ["gControlsGuide_Text_AButton"] = 0x1C58BA, + ["gControlsGuide_Text_BButton"] = 0x1C58F9, + ["gControlsGuide_Text_DPad"] = 0x1C5875, + ["gControlsGuide_Text_Intro"] = 0x1C582D, + ["gControlsGuide_Text_LRButtons"] = 0x1C5981, + ["gControlsGuide_Text_SelectButton"] = 0x1C594F, + ["gControlsGuide_Text_StartButton"] = 0x1C592B, + ["gCreditsString_Akira_Kinashi"] = 0x41DE3C, + ["gCreditsString_Art_Director_Battle_Director"] = 0x41D200, + ["gCreditsString_Atsuko_Nishida_Muneo_Saito_Rena_Yoshikawa_Jun_Okutani"] = 0x41D6B0, + ["gCreditsString_Azusa_Tajima_Akira_Kinashi_Kazuki_Yoshihara_Retsuji_Nomoto"] = 0x41D94C, + ["gCreditsString_Braille_Code_Check"] = 0x41D894, + ["gCreditsString_Braille_Code_Check_2"] = 0x41DC2C, + ["gCreditsString_Braille_Code_Check_3"] = 0x41DCAC, + ["gCreditsString_Braille_Code_Check_4"] = 0x41DDC8, + ["gCreditsString_Coordinators"] = 0x41D938, + ["gCreditsString_Director"] = 0x41D1D8, + ["gCreditsString_English_Version_Coordinators"] = 0x41DAA0, + ["gCreditsString_Environment_Tool_Programmers"] = 0x41DB7C, + ["gCreditsString_Executive_Director"] = 0x41DA08, + ["gCreditsString_Executive_Producer"] = 0x41DA3C, + ["gCreditsString_Executive_Producer_2"] = 0x41DA6C, + ["gCreditsString_Game_Designers"] = 0x41D45C, + ["gCreditsString_Game_Designers_2"] = 0x41D4A4, + ["gCreditsString_Game_Scenario"] = 0x41D4F0, + ["gCreditsString_Go_Ichinose"] = 0x41D448, + ["gCreditsString_Go_Ichinose_Junichi_Masuda"] = 0x41D414, + ["gCreditsString_Graphic_Designer"] = 0x41DE24, + ["gCreditsString_Graphic_Designers"] = 0x41D370, + ["gCreditsString_Graphic_Designers_2"] = 0x41D3C0, + ["gCreditsString_Hiro_Nakamura_Hiroyuki_Uesugi_Teruki_Murakawa_Kazuya_Suyama"] = 0x41D79C, + ["gCreditsString_Hiro_Nakamura_Seth_McMahill"] = 0x41DAC4, + ["gCreditsString_Hiroki_Enomoto_Kazuyuki_Terada_Yuri_Sakurai_Yumi_Funasaka_Naoko_Yanase"] = 0x41D8EC, + ["gCreditsString_Hiroyuki_Jinnai_Takehiro_Izushi_Hiroaki_Tsuru"] = 0x41D9D4, + ["gCreditsString_Hiroyuki_Nakamura_Masao_Taya_Satoshi_Nohara_Miyuki_Iwasawa_Daisuke_Goto"] = 0x41D2C8, + ["gCreditsString_Hitomi_Sato_Satoshi_Tajiri"] = 0x41D504, + ["gCreditsString_Hitomi_Sato_Shigeru_Ohmori_Tadashi_Takahashi"] = 0x41D4BC, + ["gCreditsString_Hitoshi_Yamagami_Gakuji_Nomoto"] = 0x41D9A0, + ["gCreditsString_Information_Supervisors"] = 0x41D8CC, + ["gCreditsString_Japan_Braille_Library"] = 0x41D8B0, + ["gCreditsString_Junichi_Masuda"] = 0x41D1E8, + ["gCreditsString_Junichi_Masuda_Koji_Nishino_Tetsuji_Ohta"] = 0x41D474, + ["gCreditsString_Ken_Sugimori_Hironobu_Yoshida"] = 0x41D3D8, + ["gCreditsString_Ken_Sugimori_Motofumi_Fujiwara_Shigeki_Morimoto_Hironobu_Yoshida"] = 0x41D5FC, + ["gCreditsString_Ken_Sugimori_Shigeki_Morimoto"] = 0x41D224, + ["gCreditsString_Kenji_Matsushima"] = 0x41D5CC, + ["gCreditsString_Kenji_Tominaga_Kenjiro_Ito_Tomotaka_Komura_Michiko_Takizawa"] = 0x41D7F4, + ["gCreditsString_Koji_Nishino_Tetsuji_Ohta_Shigeki_Morimoto"] = 0x41D588, + ["gCreditsString_Makiko_Takada_Mikiko_Ohashi_Shusaku_Egami_Takanao_Kondo_Rui_Kawaguchi"] = 0x41D84C, + ["gCreditsString_Music_Composition"] = 0x41D3FC, + ["gCreditsString_NCL_Product_Testing"] = 0x41D74C, + ["gCreditsString_NCL_Super_Mario_Club"] = 0x41D768, + ["gCreditsString_NOA_Product_Testing"] = 0x41DBDC, + ["gCreditsString_National_Federation_of_the_Blind_Patricia_A_Maurer_Japan_Braille_Library_European_Blind_Union"] = 0x41DC48, + ["gCreditsString_National_Information_Library_Service_Margaret_Campion"] = 0x41DCC8, + ["gCreditsString_Nicola_Pratt_Barlow_Shellie_Dow_Anthony_Howitt_Naoko_Saeki_Kyoko_Onishi"] = 0x41DD7C, + ["gCreditsString_Nob_Ogasawara_Teresa_Lillygren"] = 0x41DB04, + ["gCreditsString_POKeDEX_Text"] = 0x41D5B8, + ["gCreditsString_POKeMON_Designers"] = 0x41D5E4, + ["gCreditsString_POKeMON_Designers_2"] = 0x41D640, + ["gCreditsString_POKeMON_Designers_3"] = 0x41D698, + ["gCreditsString_Parametric_Designers"] = 0x41D56C, + ["gCreditsString_Producers"] = 0x41D9C4, + ["gCreditsString_Program_Leader_Planning_Leader_Graphic_Design_Leader"] = 0x41D248, + ["gCreditsString_Programmers"] = 0x41D2B4, + ["gCreditsString_Programmers_2"] = 0x41DB28, + ["gCreditsString_Satoru_Iwata"] = 0x41DA58, + ["gCreditsString_Satoshi_Nohara_Shigeru_Ohmori"] = 0x41D548, + ["gCreditsString_Satoshi_Ohta_Asuka_Iwashita_Takao_Unno_Kanako_Eo_Aimi_Tomita"] = 0x41D658, + ["gCreditsString_Satoshi_Tajiri"] = 0x41DA24, + ["gCreditsString_Script_Designer_Map_Designer"] = 0x41D524, + ["gCreditsString_Sound_Effects"] = 0x41D434, + ["gCreditsString_Special_Thanks"] = 0x41D784, + ["gCreditsString_Special_Thanks_2"] = 0x41D7DC, + ["gCreditsString_Special_Thanks_3"] = 0x41D834, + ["gCreditsString_Special_Thanks_4"] = 0x41DD08, + ["gCreditsString_Special_Thanks_5"] = 0x41DD64, + ["gCreditsString_Supporting_Programmers"] = 0x41D6EC, + ["gCreditsString_System_Programmers"] = 0x41D314, + ["gCreditsString_Takao_Unno_Asuka_Iwashita_Kanako_Eo_Hiroki_Fuchino"] = 0x41D388, + ["gCreditsString_Takehiro_Izushi_Motoyasu_Tojima_Hitoshi_Yamagami_Hiroyuki_Uesugi"] = 0x41DD20, + ["gCreditsString_Task_Managers"] = 0x41D98C, + ["gCreditsString_Teruki_Murakawa_Souichi_Yamamoto_Kimiko_Nakamichi"] = 0x41DBA4, + ["gCreditsString_Teruki_Murakawa_Souichi_Yamamoto_Yuichiro_Ito_Akira_Kinashi"] = 0x41DB3C, + ["gCreditsString_Teruyuki_Yoshioka_Takao_Nakano_Satoshi_Mitsuhara_Daisuke_Hoshino"] = 0x41D708, + ["gCreditsString_Tetsuya_Watanabe_Akito_Mori_Hisashi_Sogabe_Sousuke_Tamada"] = 0x41D330, + ["gCreditsString_Tetsuya_Watanabe_Koji_Nishino_Takao_Unno"] = 0x41D284, + ["gCreditsString_The_Royal_New_Zealand_Foundation_of_the_Blind_Greg_Moran"] = 0x41DDE4, + ["gCreditsString_Thomas_Hertzog_Kathy_Huguenard_Mika_Kurosawa"] = 0x41DBF8, + ["gCreditsString_Translator_Text_Editor"] = 0x41DAE8, + ["gCreditsString_Tsunekaz_Ishihara"] = 0x41DA88, + ["gDaycareText_DontLikeOther"] = 0x41E37C, + ["gDaycareText_GetAlong"] = 0x41E361, + ["gDaycareText_GetAlongVeryWell"] = 0x41E33C, + ["gDaycareText_PlayOther"] = 0x41E3A8, + ["gEasyChatGroupName_Actions"] = 0x41E037, + ["gEasyChatGroupName_Adjectives"] = 0x41E05C, + ["gEasyChatGroupName_Battle"] = 0x41DFF5, + ["gEasyChatGroupName_Conditions"] = 0x41E02C, + ["gEasyChatGroupName_Endings"] = 0x41E01B, + ["gEasyChatGroupName_Events"] = 0x41E067, + ["gEasyChatGroupName_Feelings"] = 0x41E023, + ["gEasyChatGroupName_Greetings"] = 0x41DFFC, + ["gEasyChatGroupName_Hobbies"] = 0x41E049, + ["gEasyChatGroupName_Lifestyle"] = 0x41E03F, + ["gEasyChatGroupName_Misc"] = 0x41E056, + ["gEasyChatGroupName_Move1"] = 0x41E06E, + ["gEasyChatGroupName_Move2"] = 0x41E075, + ["gEasyChatGroupName_People"] = 0x41E006, + ["gEasyChatGroupName_Pokemon"] = 0x41DFDE, + ["gEasyChatGroupName_Pokemon2"] = 0x41E08A, + ["gEasyChatGroupName_Speech"] = 0x41E014, + ["gEasyChatGroupName_Status"] = 0x41DFEE, + ["gEasyChatGroupName_Time"] = 0x41E051, + ["gEasyChatGroupName_Trainer"] = 0x41DFE6, + ["gEasyChatGroupName_TrendySaying"] = 0x41E07C, + ["gEasyChatGroupName_Voices"] = 0x41E00D, + ["gExpandedPlaceholder_Aqua"] = 0x415A31, + ["gExpandedPlaceholder_Archie"] = 0x415A3C, + ["gExpandedPlaceholder_Chan"] = 0x415A22, + ["gExpandedPlaceholder_Empty"] = 0x415A20, + ["gExpandedPlaceholder_Green"] = 0x415A5C, + ["gExpandedPlaceholder_Groudon"] = 0x415A50, + ["gExpandedPlaceholder_Kun"] = 0x415A21, + ["gExpandedPlaceholder_Kyogre"] = 0x415A49, + ["gExpandedPlaceholder_Magma"] = 0x415A36, + ["gExpandedPlaceholder_Maxie"] = 0x415A43, + ["gExpandedPlaceholder_PokedexDescription"] = 0x444C34, + ["gExpandedPlaceholder_Red"] = 0x415A58, + ["gExpandedPlaceholder_Ruby"] = 0x415A2C, + ["gExpandedPlaceholder_Sapphire"] = 0x415A23, + ["gFameCheckerBillName"] = 0x41E5F3, + ["gFameCheckerDaisyName"] = 0x41E5ED, + ["gFameCheckerFlavorTextOriginLocation_Agatha0"] = 0x1B0FAE, + ["gFameCheckerFlavorTextOriginLocation_Agatha1"] = 0x1B0FBD, + ["gFameCheckerFlavorTextOriginLocation_Agatha2"] = 0x1B0FCC, + ["gFameCheckerFlavorTextOriginLocation_Agatha3"] = 0x1B0FDB, + ["gFameCheckerFlavorTextOriginLocation_Agatha4"] = 0x1B0FE8, + ["gFameCheckerFlavorTextOriginLocation_Agatha5"] = 0x1B0FF7, + ["gFameCheckerFlavorTextOriginLocation_Bill0"] = 0x1B1059, + ["gFameCheckerFlavorTextOriginLocation_Bill1"] = 0x1B1067, + ["gFameCheckerFlavorTextOriginLocation_Bill2"] = 0x1B1075, + ["gFameCheckerFlavorTextOriginLocation_Bill3"] = 0x1B1082, + ["gFameCheckerFlavorTextOriginLocation_Bill4"] = 0x1B108D, + ["gFameCheckerFlavorTextOriginLocation_Bill5"] = 0x1B1098, + ["gFameCheckerFlavorTextOriginLocation_Blaine0"] = 0x1B0EBA, + ["gFameCheckerFlavorTextOriginLocation_Blaine1"] = 0x1B0ECA, + ["gFameCheckerFlavorTextOriginLocation_Blaine2"] = 0x1B0ED7, + ["gFameCheckerFlavorTextOriginLocation_Blaine3"] = 0x1B0EE4, + ["gFameCheckerFlavorTextOriginLocation_Blaine4"] = 0x1B0EF4, + ["gFameCheckerFlavorTextOriginLocation_Blaine5"] = 0x1B0F01, + ["gFameCheckerFlavorTextOriginLocation_Brock0"] = 0x1B0CEB, + ["gFameCheckerFlavorTextOriginLocation_Brock1"] = 0x1B0CF7, + ["gFameCheckerFlavorTextOriginLocation_Brock2"] = 0x1B0D02, + ["gFameCheckerFlavorTextOriginLocation_Brock3"] = 0x1B0D0E, + ["gFameCheckerFlavorTextOriginLocation_Brock4"] = 0x1B0D16, + ["gFameCheckerFlavorTextOriginLocation_Brock5"] = 0x1B0D1F, + ["gFameCheckerFlavorTextOriginLocation_Bruno0"] = 0x1B0F5F, + ["gFameCheckerFlavorTextOriginLocation_Bruno1"] = 0x1B0F6E, + ["gFameCheckerFlavorTextOriginLocation_Bruno2"] = 0x1B0F7D, + ["gFameCheckerFlavorTextOriginLocation_Bruno3"] = 0x1B0F8A, + ["gFameCheckerFlavorTextOriginLocation_Bruno4"] = 0x1B0F94, + ["gFameCheckerFlavorTextOriginLocation_Bruno5"] = 0x1B0F9F, + ["gFameCheckerFlavorTextOriginLocation_Daisy0"] = 0x1B0C95, + ["gFameCheckerFlavorTextOriginLocation_Daisy1"] = 0x1B0CA2, + ["gFameCheckerFlavorTextOriginLocation_Daisy2"] = 0x1B0CB1, + ["gFameCheckerFlavorTextOriginLocation_Daisy3"] = 0x1B0CC1, + ["gFameCheckerFlavorTextOriginLocation_Daisy4"] = 0x1B0CCF, + ["gFameCheckerFlavorTextOriginLocation_Daisy5"] = 0x1B0CDF, + ["gFameCheckerFlavorTextOriginLocation_Erika0"] = 0x1B0DD7, + ["gFameCheckerFlavorTextOriginLocation_Erika1"] = 0x1B0DE4, + ["gFameCheckerFlavorTextOriginLocation_Erika2"] = 0x1B0DF0, + ["gFameCheckerFlavorTextOriginLocation_Erika3"] = 0x1B0DFC, + ["gFameCheckerFlavorTextOriginLocation_Erika4"] = 0x1B0E08, + ["gFameCheckerFlavorTextOriginLocation_Erika5"] = 0x1B0E14, + ["gFameCheckerFlavorTextOriginLocation_Giovanni0"] = 0x1B10F8, + ["gFameCheckerFlavorTextOriginLocation_Giovanni1"] = 0x1B1107, + ["gFameCheckerFlavorTextOriginLocation_Giovanni2"] = 0x1B1111, + ["gFameCheckerFlavorTextOriginLocation_Giovanni3"] = 0x1B111B, + ["gFameCheckerFlavorTextOriginLocation_Giovanni4"] = 0x1B1128, + ["gFameCheckerFlavorTextOriginLocation_Giovanni5"] = 0x1B1135, + ["gFameCheckerFlavorTextOriginLocation_Koga0"] = 0x1B0E24, + ["gFameCheckerFlavorTextOriginLocation_Koga1"] = 0x1B0E31, + ["gFameCheckerFlavorTextOriginLocation_Koga2"] = 0x1B0E3D, + ["gFameCheckerFlavorTextOriginLocation_Koga3"] = 0x1B0E49, + ["gFameCheckerFlavorTextOriginLocation_Koga4"] = 0x1B0E56, + ["gFameCheckerFlavorTextOriginLocation_Koga5"] = 0x1B0E63, + ["gFameCheckerFlavorTextOriginLocation_Lance0"] = 0x1B1002, + ["gFameCheckerFlavorTextOriginLocation_Lance1"] = 0x1B1011, + ["gFameCheckerFlavorTextOriginLocation_Lance2"] = 0x1B1020, + ["gFameCheckerFlavorTextOriginLocation_Lance3"] = 0x1B102D, + ["gFameCheckerFlavorTextOriginLocation_Lance4"] = 0x1B103B, + ["gFameCheckerFlavorTextOriginLocation_Lance5"] = 0x1B104A, + ["gFameCheckerFlavorTextOriginLocation_Lorelei0"] = 0x1B0F11, + ["gFameCheckerFlavorTextOriginLocation_Lorelei1"] = 0x1B0F20, + ["gFameCheckerFlavorTextOriginLocation_Lorelei2"] = 0x1B0F2F, + ["gFameCheckerFlavorTextOriginLocation_Lorelei3"] = 0x1B0F3B, + ["gFameCheckerFlavorTextOriginLocation_Lorelei4"] = 0x1B0F47, + ["gFameCheckerFlavorTextOriginLocation_Lorelei5"] = 0x1B0F53, + ["gFameCheckerFlavorTextOriginLocation_LtSurge0"] = 0x1B0D81, + ["gFameCheckerFlavorTextOriginLocation_LtSurge1"] = 0x1B0D90, + ["gFameCheckerFlavorTextOriginLocation_LtSurge2"] = 0x1B0D9E, + ["gFameCheckerFlavorTextOriginLocation_LtSurge3"] = 0x1B0DAC, + ["gFameCheckerFlavorTextOriginLocation_LtSurge4"] = 0x1B0DBA, + ["gFameCheckerFlavorTextOriginLocation_LtSurge5"] = 0x1B0DC8, + ["gFameCheckerFlavorTextOriginLocation_Misty0"] = 0x1B0D2D, + ["gFameCheckerFlavorTextOriginLocation_Misty1"] = 0x1B0D3B, + ["gFameCheckerFlavorTextOriginLocation_Misty2"] = 0x1B0D48, + ["gFameCheckerFlavorTextOriginLocation_Misty3"] = 0x1B0D55, + ["gFameCheckerFlavorTextOriginLocation_Misty4"] = 0x1B0D65, + ["gFameCheckerFlavorTextOriginLocation_Misty5"] = 0x1B0D73, + ["gFameCheckerFlavorTextOriginLocation_MrFuji0"] = 0x1B10A3, + ["gFameCheckerFlavorTextOriginLocation_MrFuji1"] = 0x1B10B1, + ["gFameCheckerFlavorTextOriginLocation_MrFuji2"] = 0x1B10BF, + ["gFameCheckerFlavorTextOriginLocation_MrFuji3"] = 0x1B10CD, + ["gFameCheckerFlavorTextOriginLocation_MrFuji4"] = 0x1B10DB, + ["gFameCheckerFlavorTextOriginLocation_MrFuji5"] = 0x1B10E8, + ["gFameCheckerFlavorTextOriginLocation_ProfOak1"] = 0x1B0C51, + ["gFameCheckerFlavorTextOriginLocation_ProfOak2"] = 0x1B0C5E, + ["gFameCheckerFlavorTextOriginLocation_ProfOak3"] = 0x1B0C6B, + ["gFameCheckerFlavorTextOriginLocation_ProfOak4"] = 0x1B0C79, + ["gFameCheckerFlavorTextOriginLocation_ProfOak5"] = 0x1B0C88, + ["gFameCheckerFlavorTextOriginLocation_Sabrina0"] = 0x1B0E6F, + ["gFameCheckerFlavorTextOriginLocation_Sabrina1"] = 0x1B0E7C, + ["gFameCheckerFlavorTextOriginLocation_Sabrina2"] = 0x1B0E89, + ["gFameCheckerFlavorTextOriginLocation_Sabrina3"] = 0x1B0E95, + ["gFameCheckerFlavorTextOriginLocation_Sabrina4"] = 0x1B0EA1, + ["gFameCheckerFlavorTextOriginLocation_Sabrina5"] = 0x1B0EAE, + ["gFameCheckerFlavorTextOriginObjectName_Agatha0"] = 0x1B1367, + ["gFameCheckerFlavorTextOriginObjectName_Agatha1"] = 0x1B136E, + ["gFameCheckerFlavorTextOriginObjectName_Agatha2"] = 0x1B1376, + ["gFameCheckerFlavorTextOriginObjectName_Agatha3"] = 0x1B137E, + ["gFameCheckerFlavorTextOriginObjectName_Agatha4"] = 0x1B138E, + ["gFameCheckerFlavorTextOriginObjectName_Agatha5"] = 0x1B1395, + ["gFameCheckerFlavorTextOriginObjectName_Bill0"] = 0x1B13D6, + ["gFameCheckerFlavorTextOriginObjectName_Bill1"] = 0x1B13D9, + ["gFameCheckerFlavorTextOriginObjectName_Bill2"] = 0x1B13E4, + ["gFameCheckerFlavorTextOriginObjectName_Bill3"] = 0x1B13EC, + ["gFameCheckerFlavorTextOriginObjectName_Bill4"] = 0x1B13F2, + ["gFameCheckerFlavorTextOriginObjectName_Bill5"] = 0x1B13F8, + ["gFameCheckerFlavorTextOriginObjectName_Blaine0"] = 0x1B12CA, + ["gFameCheckerFlavorTextOriginObjectName_Blaine1"] = 0x1B12CF, + ["gFameCheckerFlavorTextOriginObjectName_Blaine2"] = 0x1B12D6, + ["gFameCheckerFlavorTextOriginObjectName_Blaine3"] = 0x1B12DC, + ["gFameCheckerFlavorTextOriginObjectName_Blaine4"] = 0x1B12E2, + ["gFameCheckerFlavorTextOriginObjectName_Blaine5"] = 0x1B12E8, + ["gFameCheckerFlavorTextOriginObjectName_Brock0"] = 0x1B11AE, + ["gFameCheckerFlavorTextOriginObjectName_Brock1"] = 0x1B11B3, + ["gFameCheckerFlavorTextOriginObjectName_Brock2"] = 0x1B11B9, + ["gFameCheckerFlavorTextOriginObjectName_Brock3"] = 0x1B11C3, + ["gFameCheckerFlavorTextOriginObjectName_Brock4"] = 0x1B11C7, + ["gFameCheckerFlavorTextOriginObjectName_Brock5"] = 0x1B11CB, + ["gFameCheckerFlavorTextOriginObjectName_Bruno0"] = 0x1B1335, + ["gFameCheckerFlavorTextOriginObjectName_Bruno1"] = 0x1B133B, + ["gFameCheckerFlavorTextOriginObjectName_Bruno2"] = 0x1B1341, + ["gFameCheckerFlavorTextOriginObjectName_Bruno3"] = 0x1B1351, + ["gFameCheckerFlavorTextOriginObjectName_Bruno4"] = 0x1B1359, + ["gFameCheckerFlavorTextOriginObjectName_Bruno5"] = 0x1B135F, + ["gFameCheckerFlavorTextOriginObjectName_Daisy0"] = 0x1B1176, + ["gFameCheckerFlavorTextOriginObjectName_Daisy1"] = 0x1B117B, + ["gFameCheckerFlavorTextOriginObjectName_Daisy2"] = 0x1B1181, + ["gFameCheckerFlavorTextOriginObjectName_Daisy3"] = 0x1B1185, + ["gFameCheckerFlavorTextOriginObjectName_Daisy4"] = 0x1B1195, + ["gFameCheckerFlavorTextOriginObjectName_Daisy5"] = 0x1B119E, + ["gFameCheckerFlavorTextOriginObjectName_Erika0"] = 0x1B123E, + ["gFameCheckerFlavorTextOriginObjectName_Erika1"] = 0x1B1243, + ["gFameCheckerFlavorTextOriginObjectName_Erika2"] = 0x1B1249, + ["gFameCheckerFlavorTextOriginObjectName_Erika3"] = 0x1B124E, + ["gFameCheckerFlavorTextOriginObjectName_Erika4"] = 0x1B1254, + ["gFameCheckerFlavorTextOriginObjectName_Erika5"] = 0x1B125A, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni0"] = 0x1B1439, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni1"] = 0x1B1442, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni2"] = 0x1B144C, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni3"] = 0x1B1456, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni4"] = 0x1B145F, + ["gFameCheckerFlavorTextOriginObjectName_Giovanni5"] = 0x1B1463, + ["gFameCheckerFlavorTextOriginObjectName_Koga0"] = 0x1B126A, + ["gFameCheckerFlavorTextOriginObjectName_Koga1"] = 0x1B126F, + ["gFameCheckerFlavorTextOriginObjectName_Koga2"] = 0x1B1274, + ["gFameCheckerFlavorTextOriginObjectName_Koga3"] = 0x1B1279, + ["gFameCheckerFlavorTextOriginObjectName_Koga4"] = 0x1B1281, + ["gFameCheckerFlavorTextOriginObjectName_Koga5"] = 0x1B1291, + ["gFameCheckerFlavorTextOriginObjectName_Lance0"] = 0x1B139E, + ["gFameCheckerFlavorTextOriginObjectName_Lance1"] = 0x1B13A4, + ["gFameCheckerFlavorTextOriginObjectName_Lance2"] = 0x1B13AA, + ["gFameCheckerFlavorTextOriginObjectName_Lance3"] = 0x1B13B0, + ["gFameCheckerFlavorTextOriginObjectName_Lance4"] = 0x1B13BC, + ["gFameCheckerFlavorTextOriginObjectName_Lance5"] = 0x1B13CC, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei0"] = 0x1B12F8, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei1"] = 0x1B1300, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei2"] = 0x1B1308, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei3"] = 0x1B1310, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei4"] = 0x1B1320, + ["gFameCheckerFlavorTextOriginObjectName_Lorelei5"] = 0x1B132C, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge0"] = 0x1B120A, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge1"] = 0x1B120F, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge2"] = 0x1B1219, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge3"] = 0x1B1220, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge4"] = 0x1B1227, + ["gFameCheckerFlavorTextOriginObjectName_LtSurge5"] = 0x1B122E, + ["gFameCheckerFlavorTextOriginObjectName_Misty0"] = 0x1B11DB, + ["gFameCheckerFlavorTextOriginObjectName_Misty1"] = 0x1B11E0, + ["gFameCheckerFlavorTextOriginObjectName_Misty2"] = 0x1B11E6, + ["gFameCheckerFlavorTextOriginObjectName_Misty3"] = 0x1B11EB, + ["gFameCheckerFlavorTextOriginObjectName_Misty4"] = 0x1B11F5, + ["gFameCheckerFlavorTextOriginObjectName_Misty5"] = 0x1B11FA, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji0"] = 0x1B13FE, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji1"] = 0x1B140A, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji2"] = 0x1B1416, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji3"] = 0x1B141F, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji4"] = 0x1B1423, + ["gFameCheckerFlavorTextOriginObjectName_MrFuji5"] = 0x1B1429, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak0"] = 0x1B1146, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak1"] = 0x1B114B, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak2"] = 0x1B1155, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak3"] = 0x1B115A, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak4"] = 0x1B116A, + ["gFameCheckerFlavorTextOriginObjectName_ProfOak5"] = 0x1B1171, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina0"] = 0x1B1295, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina1"] = 0x1B129A, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina2"] = 0x1B12A4, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina3"] = 0x1B12AC, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina4"] = 0x1B12B4, + ["gFameCheckerFlavorTextOriginObjectName_Sabrina5"] = 0x1B12C4, + ["gFameCheckerMrFujiName"] = 0x41E5F8, + ["gFameCheckerOakName"] = 0x41E5E9, + ["gFameCheckerText_Cancel"] = 0x4161C1, + ["gFameCheckerText_ClearTextbox"] = 0x4181E4, + ["gFameCheckerText_FameCheckerWillBeClosed"] = 0x4181C3, + ["gFameCheckerText_FlavorTextUI"] = 0x41E5D2, + ["gFameCheckerText_MainScreenUI"] = 0x41E5A4, + ["gFameCheckerText_PickScreenUI"] = 0x41E5B9, + ["gJPText_AllowEReaderToLoadCard"] = 0x41DE98, + ["gJPText_CardReadingHasBeenHalted"] = 0x41DE7D, + ["gJPText_Connecting"] = 0x41DE95, + ["gJPText_ConnectionComplete"] = 0x41DE99, + ["gJPText_ConnectionErrorCheckLink"] = 0x41DE96, + ["gJPText_ConnectionErrorTryAgain"] = 0x41DE97, + ["gJPText_DecideStop"] = 0x41DE51, + ["gJPText_LinkIsIncorrect"] = 0x41DE7C, + ["gJPText_MysteryGift"] = 0x41DE50, + ["gJPText_NewTrainerHasComeToSevii"] = 0x41DE9A, + ["gJPText_PleaseWaitAMoment"] = 0x41DE9B, + ["gJPText_ReceiveMysteryGiftWithEReader"] = 0x41DE52, + ["gJPText_SelectConnectAndPressA"] = 0x41DE55, + ["gJPText_SelectConnectFromEReaderMenu"] = 0x41DE53, + ["gJPText_SelectConnectWithGBA"] = 0x41DE54, + ["gJPText_UnableConnectWithEReader"] = 0x41DE7E, + ["gJPText_WriteErrorUnableToSaveData"] = 0x41DE9C, + ["gMenuText_Confirm"] = 0x4161B7, + ["gNameChoice_Amanda"] = 0x1C57D9, + ["gNameChoice_Ash"] = 0x1C577A, + ["gNameChoice_Cassie"] = 0x1C57FF, + ["gNameChoice_Fire"] = 0x1C5767, + ["gNameChoice_Gary"] = 0x1C576C, + ["gNameChoice_Geki"] = 0x1C5783, + ["gNameChoice_Green"] = 0x1C5758, + ["gNameChoice_Hillary"] = 0x1C57E0, + ["gNameChoice_Hiro"] = 0x1C57AF, + ["gNameChoice_Jak"] = 0x1C5788, + ["gNameChoice_Janne"] = 0x1C578C, + ["gNameChoice_Jodi"] = 0x1C57D4, + ["gNameChoice_Jon"] = 0x1C57B8, + ["gNameChoice_Jonn"] = 0x1C5792, + ["gNameChoice_June"] = 0x1C57FA, + ["gNameChoice_Kamon"] = 0x1C5797, + ["gNameChoice_Karl"] = 0x1C579D, + ["gNameChoice_Kay"] = 0x1C57C2, + ["gNameChoice_Kaz"] = 0x1C5771, + ["gNameChoice_Kene"] = 0x1C577E, + ["gNameChoice_Kiko"] = 0x1C580F, + ["gNameChoice_Leaf"] = 0x1C5762, + ["gNameChoice_Makey"] = 0x1C57E8, + ["gNameChoice_Max"] = 0x1C57B4, + ["gNameChoice_Michi"] = 0x1C57EE, + ["gNameChoice_Mina"] = 0x1C5814, + ["gNameChoice_Momo"] = 0x1C5823, + ["gNameChoice_Norie"] = 0x1C5819, + ["gNameChoice_Omi"] = 0x1C57D0, + ["gNameChoice_Oscar"] = 0x1C57A9, + ["gNameChoice_Paula"] = 0x1C57F4, + ["gNameChoice_Ralph"] = 0x1C57BC, + ["gNameChoice_Red"] = 0x1C575E, + ["gNameChoice_Rey"] = 0x1C5806, + ["gNameChoice_Roak"] = 0x1C57CB, + ["gNameChoice_Sai"] = 0x1C581F, + ["gNameChoice_Seda"] = 0x1C580A, + ["gNameChoice_Suzi"] = 0x1C5828, + ["gNameChoice_Taylor"] = 0x1C57A2, + ["gNameChoice_Toru"] = 0x1C5775, + ["gNameChoice_Tosh"] = 0x1C57C6, + ["gOakSpeech_Text_AskPlayerGender"] = 0x1C59D5, + ["gOakSpeech_Text_ConfirmRivalName"] = 0x1C5EB5, + ["gOakSpeech_Text_IStudyPokemon"] = 0x1C5D4B, + ["gOakSpeech_Text_IsInhabitedFarAndWide"] = 0x1C5D12, + ["gOakSpeech_Text_LetsGo"] = 0x1C5EF4, + ["gOakSpeech_Text_RememberRivalsName"] = 0x1C5EC5, + ["gOakSpeech_Text_SoYourNameIsPlayer"] = 0x1C5E13, + ["gOakSpeech_Text_TellMeALittleAboutYourself"] = 0x1C5DBD, + ["gOakSpeech_Text_ThisWorld"] = 0x1C5D06, + ["gOakSpeech_Text_WelcomeToTheWorld"] = 0x1C5C78, + ["gOakSpeech_Text_WhatWasHisName"] = 0x1C5E2E, + ["gOakSpeech_Text_YourNameWhatIsIt"] = 0x1C5DEA, + ["gOakSpeech_Text_YourRivalsNameWhatWasIt"] = 0x1C5E91, + ["gOtherText_Check"] = 0x4161E9, + ["gOtherText_Deselect"] = 0x4161F9, + ["gOtherText_Double"] = 0x417F89, + ["gOtherText_Exit"] = 0x4161C8, + ["gOtherText_Give"] = 0x4161B2, + ["gOtherText_Give2"] = 0x4177E9, + ["gOtherText_Knockout"] = 0x417F90, + ["gOtherText_Mixed"] = 0x417F99, + ["gOtherText_MoveToBag"] = 0x4177DD, + ["gOtherText_NewName"] = 0x1C574F, + ["gOtherText_Open"] = 0x4161F4, + ["gOtherText_Powder"] = 0x416655, + ["gOtherText_Quit"] = 0x417929, + ["gOtherText_Read"] = 0x4177D8, + ["gOtherText_Register"] = 0x4161A9, + ["gOtherText_Single"] = 0x417F82, + ["gOtherText_Toss"] = 0x4161A4, + ["gOtherText_Use"] = 0x4161A0, + ["gOtherText_Walk"] = 0x4161BC, + ["gOtherText_WhereShouldTheStrVar1BePlaced"] = 0x41633F, + ["gPCText_Bag"] = 0x4184C9, + ["gPCText_Beach"] = 0x41852B, + ["gPCText_Cancel"] = 0x418468, + ["gPCText_Cave"] = 0x418526, + ["gPCText_City"] = 0x418500, + ["gPCText_Crag"] = 0x418514, + ["gPCText_Desert"] = 0x418505, + ["gPCText_Etcetera"] = 0x4184F0, + ["gPCText_Forest"] = 0x4184F9, + ["gPCText_Give"] = 0x4184BD, + ["gPCText_Info"] = 0x4184CD, + ["gPCText_Jump"] = 0x4184A9, + ["gPCText_Machine"] = 0x418555, + ["gPCText_Mark"] = 0x41849F, + ["gPCText_Move"] = 0x418484, + ["gPCText_Name"] = 0x4184A4, + ["gPCText_Place"] = 0x418489, + ["gPCText_Pokecenter"] = 0x41854A, + ["gPCText_PolkaDot"] = 0x418544, + ["gPCText_Release"] = 0x418497, + ["gPCText_River"] = 0x41853A, + ["gPCText_Savanna"] = 0x41850C, + ["gPCText_Scenery1"] = 0x4184D2, + ["gPCText_Scenery2"] = 0x4184DC, + ["gPCText_Scenery3"] = 0x4184E6, + ["gPCText_Seafloor"] = 0x418531, + ["gPCText_Shift"] = 0x41847E, + ["gPCText_Simple"] = 0x41855B, + ["gPCText_Sky"] = 0x418540, + ["gPCText_Snow"] = 0x418521, + ["gPCText_Store"] = 0x41846F, + ["gPCText_Summary"] = 0x41848F, + ["gPCText_Switch"] = 0x4184C2, + ["gPCText_Take"] = 0x4184B8, + ["gPCText_Volcano"] = 0x418519, + ["gPCText_Wallpaper"] = 0x4184AE, + ["gPCText_Withdraw"] = 0x418475, + ["gPikachuIntro_Text_Page1"] = 0x1C5A04, + ["gPikachuIntro_Text_Page2"] = 0x1C5AEB, + ["gPikachuIntro_Text_Page3"] = 0x1C5BB9, + ["gPokedudeText_ReadTMDescription"] = 0x41C693, + ["gPokedudeText_TMTypes"] = 0x41C587, + ["gSaveStatName_Badges"] = 0x416181, + ["gSaveStatName_Player"] = 0x41617A, + ["gSaveStatName_Pokedex"] = 0x416188, + ["gSaveStatName_Time"] = 0x416190, + ["gStartMenuDesc_Bag"] = 0x41A001, + ["gStartMenuDesc_Exit"] = 0x41A0F9, + ["gStartMenuDesc_Option"] = 0x41A0B7, + ["gStartMenuDesc_Player"] = 0x41A049, + ["gStartMenuDesc_Pokedex"] = 0x419F76, + ["gStartMenuDesc_Pokemon"] = 0x419FBB, + ["gStartMenuDesc_Retire"] = 0x41A111, + ["gStartMenuDesc_Save"] = 0x41A06F, + ["gStatusConditionString_BurnJpn"] = 0x2500AC, + ["gStatusConditionString_ConfusionJpn"] = 0x2500BC, + ["gStatusConditionString_IceJpn"] = 0x2500B4, + ["gStatusConditionString_LoveJpn"] = 0x2500C4, + ["gStatusConditionString_ParalysisJpn"] = 0x2500A4, + ["gStatusConditionString_PoisonJpn"] = 0x250094, + ["gStatusConditionString_SleepJpn"] = 0x25009C, + ["gString_BattleRecords_4Dashes"] = 0x4181BE, + ["gString_BattleRecords_7Dashes"] = 0x4181B6, + ["gString_BattleRecords_ColumnHeaders"] = 0x4181A4, + ["gString_BattleRecords_PlayersBattleResults"] = 0x418174, + ["gString_BattleRecords_TotalRecord"] = 0x418188, + ["gString_Bill"] = 0x41CB3C, + ["gString_Dummy"] = 0x4161CD, + ["gString_Help"] = 0x41CB49, + ["gString_HelpSystem_ClearTo8"] = 0x41CB4E, + ["gString_OutOfCoins"] = 0x41B747, + ["gString_PokemonFireRed_Staff"] = 0x41D198, + ["gString_PokemonLeafGreen_Staff"] = 0x41D1B8, + ["gString_QuitPlaying"] = 0x41B76B, + ["gString_SlotMachineControls"] = 0x41B779, + ["gString_Someone"] = 0x41CB41, + ["gTeachyTvString_AboutTMs"] = 0x41B807, + ["gTeachyTvString_Cancel"] = 0x41B836, + ["gTeachyTvString_CatchPkmn"] = 0x41B7EE, + ["gTeachyTvString_RegisterItem"] = 0x41B81B, + ["gTeachyTvString_StatusProblems"] = 0x41B7BC, + ["gTeachyTvString_TeachBattle"] = 0x41B7A4, + ["gTeachyTvString_TypeMatchups"] = 0x41B7D6, + ["gTeachyTvText_BattleScript1"] = 0x41B8BF, + ["gTeachyTvText_BattleScript2"] = 0x41BA41, + ["gTeachyTvText_CatchingScript1"] = 0x41C23B, + ["gTeachyTvText_CatchingScript2"] = 0x41C384, + ["gTeachyTvText_MatchupsScript1"] = 0x41BE76, + ["gTeachyTvText_MatchupsScript2"] = 0x41C0AF, + ["gTeachyTvText_PokedudeSaysHello"] = 0x41B83D, + ["gTeachyTvText_RegisterScript1"] = 0x41C82A, + ["gTeachyTvText_RegisterScript2"] = 0x41C994, + ["gTeachyTvText_StatusScript1"] = 0x41BB40, + ["gTeachyTvText_StatusScript2"] = 0x41BD10, + ["gTeachyTvText_TMsScript1"] = 0x41C459, + ["gTeachyTvText_TMsScript2"] = 0x41C7B4, + ["gTextJPDummy_Hiki"] = 0x41B6EB, + ["gTextJPDummy_Ko"] = 0x41B6F3, + ["gTextJP_HowToUsePokedex"] = 0x415EBC, + ["gTextJP_InternalBatteryHasRunDry"] = 0x415C8F, + ["gTextJP_Options"] = 0x415A80, + ["gTextJP_PokemonData"] = 0x415FA5, + ["gTextJP_Type"] = 0x419E42, + ["gText_10F"] = 0x418055, + ["gText_10P30P50P50P"] = 0x41CD25, + ["gText_11F"] = 0x418059, + ["gText_12PoofForgotMove"] = 0x416EC6, + ["gText_1BigMushroom"] = 0x417EA4, + ["gText_1Colon"] = 0x41CD6B, + ["gText_1F"] = 0x41803A, + ["gText_1F_2"] = 0x417AB0, + ["gText_1MSubCircuitBoardNotInstalled"] = 0x415C64, + ["gText_1MinutePlus"] = 0x417FA9, + ["gText_1_2_and_Poof"] = 0x41E493, + ["gText_1_ClrBluShdwLtBlu_Dynamic0"] = 0x41D020, + ["gText_1_Dynamic0"] = 0x41D02C, + ["gText_2Colon"] = 0x41CD6E, + ["gText_2F"] = 0x41803D, + ["gText_2F_2"] = 0x417AB3, + ["gText_2Tinymushrooms"] = 0x417E94, + ["gText_3Colon"] = 0x41CD71, + ["gText_3F"] = 0x418040, + ["gText_3F_2"] = 0x417AB6, + ["gText_4Colon"] = 0x41CD74, + ["gText_4F"] = 0x418043, + ["gText_4F_2"] = 0x417AB9, + ["gText_4Qmark"] = 0x41E09F, + ["gText_500Coins_10000"] = 0x4179D6, + ["gText_50Coins_1000"] = 0x4179C0, + ["gText_5Colon"] = 0x41CD77, + ["gText_5Dashes"] = 0x415F66, + ["gText_5F"] = 0x418046, + ["gText_5F_2"] = 0x417ABC, + ["gText_6F"] = 0x418049, + ["gText_7F"] = 0x41804C, + ["gText_8F"] = 0x41804F, + ["gText_99TimesPlus"] = 0x417F9F, + ["gText_9F"] = 0x418052, + ["gText_ABUTTONExit"] = 0x415D8C, + ["gText_ABUTTONNext"] = 0x415D48, + ["gText_ABUTTONNext_BBUTTONBack"] = 0x415D50, + ["gText_ABtnRegistrationCounter"] = 0x41DF4C, + ["gText_ABtnTitleScreen"] = 0x41DF6B, + ["gText_ABugMove"] = 0x3FE8E6, + ["gText_AButton"] = 0x415CF6, + ["gText_ADarkMove"] = 0x3FE976, + ["gText_ADragonMove"] = 0x3FE968, + ["gText_AFightingMove"] = 0x3FE8A0, + ["gText_AFireMove"] = 0x3FE916, + ["gText_AFlyingMove"] = 0x3FE8B0, + ["gText_AGhostMove"] = 0x3FE8F1, + ["gText_AGrassMove"] = 0x3FE92F, + ["gText_AGroundMove"] = 0x3FE8CC, + ["gText_AMysteryMove"] = 0x3FE90B, + ["gText_ANormalMove"] = 0x3FE892, + ["gText_APoisonMove"] = 0x3FE8BE, + ["gText_APsychicMove"] = 0x3FE94D, + ["gText_ARockMove"] = 0x3FE8DA, + ["gText_ASteelMove"] = 0x3FE8FE, + ["gText_AToZMode"] = 0x415EA4, + ["gText_AWaterMove"] = 0x3FE922, + ["gText_Able"] = 0x417419, + ["gText_Able2"] = 0x417431, + ["gText_Able3"] = 0x41743F, + ["gText_Abra_120Coins"] = 0x417CD1, + ["gText_Abra_180Coins"] = 0x417C55, + ["gText_AllTextBeingEditedWill"] = 0x418937, + ["gText_AlphabetUpperLower"] = 0x418E8A, + ["gText_AlreadyHadCard"] = 0x41E946, + ["gText_AlreadyHadNews"] = 0x41E968, + ["gText_AlreadyHadStamp"] = 0x41E98F, + ["gText_AlreadySaveFile_WouldLikeToOverwrite"] = 0x1C55EA, + ["gText_AlreadySurfing"] = 0x417281, + ["gText_AnElectricMove"] = 0x3FE93C, + ["gText_AnIceMove"] = 0x3FE95C, + ["gText_And"] = 0x41B315, + ["gText_AndFillOutTheQuestionnaire"] = 0x4187EE, + ["gText_AndImproveTheBardsSong"] = 0x4187D1, + ["gText_AndMakeAMessage"] = 0x41879E, + ["gText_AndMakeATrendySaying"] = 0x4188D6, + ["gText_AndMakeYourProfile"] = 0x4186EF, + ["gText_AnnouncingPrizes"] = 0x41CD58, + ["gText_AnnouncingRankings"] = 0x41CD43, + ["gText_AnythingElseICanHelp"] = 0x41689E, + ["gText_Area"] = 0x415FE8, + ["gText_AreaUnknown"] = 0x415FF2, + ["gText_AtTheBattlesStart"] = 0x418A33, + ["gText_AwesomeWonF701F700"] = 0x41CBA9, + ["gText_B1F"] = 0x41805D, + ["gText_B2F"] = 0x418061, + ["gText_B3F"] = 0x418065, + ["gText_B4F"] = 0x418069, + ["gText_BButton"] = 0x415CFB, + ["gText_BButtonCancel"] = 0x41E2EC, + ["gText_BackupMemoryDamaged"] = 0x418CD9, + ["gText_BadEgg"] = 0x3FE868, + ["gText_Badges"] = 0x41B6EC, + ["gText_BadgesUnused"] = 0x415CED, + ["gText_BagFullCouldNotRemoveItem"] = 0x416D78, + ["gText_BagIsFull"] = 0x417858, + ["gText_BagIsFull2"] = 0x418408, + ["gText_Battle"] = 0x41B524, + ["gText_BattleMenu"] = 0x3FE725, + ["gText_BattlePoints"] = 0x419D02, + ["gText_BattleScene"] = 0x419DDE, + ["gText_BattleSceneOff"] = 0x419E28, + ["gText_BattleSceneOn"] = 0x419E25, + ["gText_BattleStyle"] = 0x419DEB, + ["gText_BattleStyleSet"] = 0x419E32, + ["gText_BattleStyleShift"] = 0x419E2C, + ["gText_BattleSwitchWhich"] = 0x3FE7A0, + ["gText_BattleTower"] = 0x417A30, + ["gText_BattleTowerBan_Is1"] = 0x3FE860, + ["gText_BattleTowerBan_Is2"] = 0x3FE864, + ["gText_BattleTowerBan_Newline1"] = 0x3FE85C, + ["gText_BattleTowerBan_Newline2"] = 0x3FE85E, + ["gText_BattleTowerBan_Space"] = 0x3FE859, + ["gText_BattleWallyName"] = 0x3FE870, + ["gText_BattleWasRefused"] = 0x41E572, + ["gText_BattleYesNoChoice"] = 0x3FE791, + ["gText_BeDeletedThatOkay"] = 0x418956, + ["gText_Beauty"] = 0x417AC4, + ["gText_Beauty3"] = 0x418C3F, + ["gText_Beauty_2"] = 0x417AF9, + ["gText_BecomeLeader"] = 0x417F69, + ["gText_Berries"] = 0x417B36, + ["gText_BerriesInRowFivePlayers"] = 0x41CCD8, + ["gText_BerriesPicked"] = 0x41CCBC, + ["gText_Berry"] = 0x4162BD, + ["gText_BerryCrush"] = 0x417E5A, + ["gText_BerryCrush2"] = 0x41D118, + ["gText_BerryCrush_AreYouReady"] = 0x41CE3C, + ["gText_BerryCrush_CommunicationStandby"] = 0x41D008, + ["gText_BerryCrush_GainedXUnitsOfPowder"] = 0x41CEA8, + ["gText_BerryCrush_MemberDroppedOut"] = 0x41CFA0, + ["gText_BerryCrush_NoBerries"] = 0x41CF70, + ["gText_BerryCrush_RecordingGameResults"] = 0x41CF14, + ["gText_BerryCrush_TimeUp"] = 0x41CFD4, + ["gText_BerryCrush_WaitForOthersToChooseBerry"] = 0x41CE78, + ["gText_BerryCrush_WantToPlayAgain"] = 0x41CF50, + ["gText_BerryCrushes"] = 0x419D7D, + ["gText_BerryPickingRecords"] = 0x41CC9F, + ["gText_BerryPickingResults"] = 0x41CD03, + ["gText_BerryPouch"] = 0x41670A, + ["gText_BerryPouch_2"] = 0x417DE1, + ["gText_BestScore"] = 0x41CCCC, + ["gText_BestScore2"] = 0x41CB88, + ["gText_Bicycle_Price"] = 0x417C33, + ["gText_BigGirl"] = 0x417FC3, + ["gText_BigGuy"] = 0x417FBB, + ["gText_BillSPc"] = 0x417BAC, + ["gText_BirchInTrouble"] = 0x41610A, + ["gText_BirthIsland"] = 0x417ECC, + ["gText_Black"] = 0x417AE3, + ["gText_BlueShard"] = 0x417A19, + ["gText_BoostPp"] = 0x4172C2, + ["gText_BootedUpHM"] = 0x416556, + ["gText_BootedUpTM"] = 0x416548, + ["gText_BoulderBadge"] = 0x417FD9, + ["gText_Boulderbadge"] = 0x417B3E, + ["gText_Box"] = 0x4186CD, + ["gText_BoxFull"] = 0x416631, + ["gText_BoxIsFull2"] = 0x4182A7, + ["gText_BoxName"] = 0x418E52, + ["gText_Boy"] = 0x415D93, + ["gText_Brn"] = 0x417918, + ["gText_BronzeCard"] = 0x41DF8B, + ["gText_Burn"] = 0x3FE837, + ["gText_ButtonMode"] = 0x419DFE, + ["gText_ButtonTypeHelp"] = 0x419E46, + ["gText_ButtonTypeLEqualsA"] = 0x419E4B, + ["gText_ButtonTypeLR"] = 0x419E4F, + ["gText_ByeBye"] = 0x41B54B, + ["gText_ByeByePkmn"] = 0x4182DF, + ["gText_ByeByeVar1"] = 0x41E200, + ["gText_Calcium_1000"] = 0x41812F, + ["gText_CanIHelpWithAnythingElse"] = 0x4168BF, + ["gText_Cancel7"] = 0x4161CE, + ["gText_CancelBattle"] = 0x4176CF, + ["gText_CancelParticipation"] = 0x4176B8, + ["gText_CancelPreviousData"] = 0x415FCF, + ["gText_CancelTrade"] = 0x41E0E0, + ["gText_CantAcceptCardFromTrainer"] = 0x41EA0D, + ["gText_CantAcceptNewsFromTrainer"] = 0x41EA3F, + ["gText_CantBeUsedOnPkmn"] = 0x4169F8, + ["gText_CantDismountBike"] = 0x416451, + ["gText_CantHoldAnyMore"] = 0x41CD9F, + ["gText_CantHoldMore"] = 0x41CBE4, + ["gText_CantSendGiftToTrainer"] = 0x41EC6A, + ["gText_CantSurfHere"] = 0x417270, + ["gText_CantSwitchWithAlly"] = 0x416A98, + ["gText_CantTradeWithTrainer"] = 0x417164, + ["gText_CantUseHere"] = 0x417299, + ["gText_CantUseUntilNewBadge"] = 0x416AE2, + ["gText_CantWriteMailHere"] = 0x41630F, + ["gText_Carbos_1000"] = 0x41811D, + ["gText_CascadeBadge"] = 0x417FE6, + ["gText_Cascadebadge"] = 0x417B4B, + ["gText_ChangeJustOneWordOrPhrase"] = 0x4187B2, + ["gText_ChangedToNewItem"] = 0x418443, + ["gText_Charcoal_1000Coins"] = 0x417EF7, + ["gText_CheckCompleted"] = 0x418D99, + ["gText_ChooseBattleLineup"] = 0x41E2F5, + ["gText_ChoosePokemon"] = 0x4171DF, + ["gText_ChoosePokemon2"] = 0x417301, + ["gText_ChoosePokemonCancel"] = 0x417396, + ["gText_ChoosePokemonConfirm"] = 0x4173B0, + ["gText_ClearAllSaveData"] = 0x41B69E, + ["gText_ClearTime"] = 0x3FE98D, + ["gText_ClearingData"] = 0x41B6B9, + ["gText_Clefairy_500Coins"] = 0x417C6A, + ["gText_Clefairy_750Coins"] = 0x417CE6, + ["gText_ClimbAWaterfall"] = 0x4175AE, + ["gText_ClockHasBeenReset"] = 0x419EC3, + ["gText_Close"] = 0x4166DB, + ["gText_CloseBag"] = 0x4162F5, + ["gText_ClosePokedex"] = 0x415EC7, + ["gText_CoinCase"] = 0x416537, + ["gText_Coins"] = 0x4162C4, + ["gText_Coins_2"] = 0x417C2D, + ["gText_Colon"] = 0x41B6E1, + ["gText_Colon2"] = 0x419CF8, + ["gText_Colosseum"] = 0x417D86, + ["gText_Colosseum_2"] = 0x417E50, + ["gText_CombineFourWordsOrPhrases"] = 0x4186D1, + ["gText_CombineNineWordsOrPhrases"] = 0x418780, + ["gText_CombineTwoWordsOrPhrases"] = 0x4188B9, + ["gText_CombineTwoWordsOrPhrases2"] = 0x418915, + ["gText_CombineTwoWordsOrPhrases3"] = 0x4189CB, + ["gText_CommErrorCheckConnections"] = 0x41DE9D, + ["gText_CommErrorEllipsis"] = 0x41DEF0, + ["gText_CommStandbyAwaitingOtherPlayer"] = 0x41E538, + ["gText_Communicating"] = 0x41E794, + ["gText_CommunicationCanceled"] = 0x41E7D1, + ["gText_CommunicationCompleted"] = 0x41E7A3, + ["gText_CommunicationError"] = 0x41E7BC, + ["gText_CommunicationStandby3"] = 0x41CE24, + ["gText_CommunicationStandby4"] = 0x41CC64, + ["gText_CommunicationStandby5"] = 0x41E325, + ["gText_ConfirmStarterChoice"] = 0x416133, + ["gText_Confusion"] = 0x3FE84A, + ["gText_CongratsPkmnEvolved"] = 0x3FE688, + ["gText_Continue"] = 0x415A77, + ["gText_ContinueBoxOperations"] = 0x418379, + ["gText_ControlPad"] = 0x415D1A, + ["gText_Controls"] = 0x415D2C, + ["gText_Cool"] = 0x417ABF, + ["gText_Cool_2"] = 0x417AF4, + ["gText_Coolness"] = 0x418C39, + ["gText_CooperativeRankings"] = 0x41D0E8, + ["gText_CopperCard"] = 0x41DF92, + ["gText_Counting_1"] = 0x41B2ED, + ["gText_Counting_2And"] = 0x41B2F1, + ["gText_CrushingResults"] = 0x41D0C0, + ["gText_Cry"] = 0x415FAD, + ["gText_CurrentIsTooFast"] = 0x417361, + ["gText_CutATreeOrGrass"] = 0x417533, + ["gText_Cute"] = 0x417ACB, + ["gText_Cute_2"] = 0x417B00, + ["gText_Cuteness"] = 0x418C45, + ["gText_DPadChooseAButtonConfirm"] = 0x41E2DF, + ["gText_DataWillBeSaved"] = 0x41ED2F, + ["gText_Daughter"] = 0x417FD0, + ["gText_DecimalPoint"] = 0x417FB9, + ["gText_Decoration"] = 0x4176EB, + ["gText_DelAllCancelOk"] = 0x41EE2B, + ["gText_DepositHowManyStrVars1"] = 0x41638F, + ["gText_DepositInWhichBox"] = 0x41827F, + ["gText_DepositItem"] = 0x4162E8, + ["gText_DepositItem2"] = 0x417706, + ["gText_DepositMonDescription"] = 0x4185E2, + ["gText_DepositPokemon"] = 0x41857D, + ["gText_DepositedStrVar2StrVar1s"] = 0x4163A7, + ["gText_DexCategory_CavePkmn"] = 0x415E39, + ["gText_DexCategory_ForestPkmn"] = 0x415E09, + ["gText_DexCategory_GrasslandPkmn"] = 0x415DF7, + ["gText_DexCategory_MountainPkmn"] = 0x415E46, + ["gText_DexCategory_RarePkmn"] = 0x415E7B, + ["gText_DexCategory_RoughTerrainPkmn"] = 0x415E57, + ["gText_DexCategory_SeaPkmn"] = 0x415E2D, + ["gText_DexCategory_UrbanPkmn"] = 0x415E6D, + ["gText_DexCategory_WatersEdgePkmn"] = 0x415E18, + ["gText_DifferentGameFile"] = 0x1C566A, + ["gText_Diploma_GameFreak"] = 0x41B684, + ["gText_Diploma_Kanto"] = 0x41B698, + ["gText_Diploma_National"] = 0x41B68F, + ["gText_Diploma_Player"] = 0x41B60E, + ["gText_Diploma_ThisDocument"] = 0x41B619, + ["gText_DirectCorner"] = 0x41808E, + ["gText_DiveUnderwater"] = 0x4175C1, + ["gText_DoWhatWithItem"] = 0x4172D5, + ["gText_DoWhatWithMail"] = 0x41737F, + ["gText_DoWhatWithPokemon"] = 0x417242, + ["gText_DodrioBerryPicking"] = 0x417E7F, + ["gText_DomeFossil"] = 0x417D63, + ["gText_DontHaveCardNewOneInput"] = 0x41E6A1, + ["gText_DontHaveNewsNewOneInput"] = 0x41E6DC, + ["gText_DoubleBattle"] = 0x417952, + ["gText_Dratini_2800Coins"] = 0x417C83, + ["gText_Dratini_4600Coins"] = 0x417D18, + ["gText_Draw"] = 0x3FE883, + ["gText_Dynamic0Players"] = 0x41E2B4, + ["gText_Dynamic1Players"] = 0x41E2BF, + ["gText_Dynamic2Players"] = 0x41E2C9, + ["gText_Dynamic3Players"] = 0x41E2D4, + ["gText_EarthBadge"] = 0x41802F, + ["gText_Earthbadge"] = 0x417B94, + ["gText_EasyChatKeyboard_ABCDEFothers"] = 0x47A8D8, + ["gText_EasyChatKeyboard_GHIJKL"] = 0x47A8FA, + ["gText_EasyChatKeyboard_MNOPQRS"] = 0x47A913, + ["gText_EasyChatKeyboard_TUVWXYZ"] = 0x47A934, + ["gText_Eevee"] = 0x417BE2, + ["gText_EggCantBattle"] = 0x416ACB, + ["gText_EggCantBeTradedNow"] = 0x4170E0, + ["gText_EggNickname"] = 0x415A62, + ["gText_Eggs"] = 0x4178EB, + ["gText_Ellipsis"] = 0x45ABE8, + ["gText_EllipsisQuestionMark"] = 0x3FE6D0, + ["gText_Empty"] = 0x4169D8, + ["gText_Empty1"] = 0x41E23A, + ["gText_Empty10"] = 0x41E243, + ["gText_Empty11"] = 0x41E244, + ["gText_Empty12"] = 0x4176CE, + ["gText_Empty2"] = 0x41E23B, + ["gText_Empty3"] = 0x41E23C, + ["gText_Empty4"] = 0x41E23D, + ["gText_Empty5"] = 0x41E23E, + ["gText_Empty6"] = 0x41E23F, + ["gText_Empty7"] = 0x41E240, + ["gText_Empty8"] = 0x41E241, + ["gText_Empty9"] = 0x41E242, + ["gText_EmptyLinkService1"] = 0x417E66, + ["gText_EmptyLinkService2"] = 0x417E67, + ["gText_EmptySpace"] = 0x416707, + ["gText_EmptyString3"] = 0x3FDA4C, + ["gText_EmptyTextInput1"] = 0x47AB39, + ["gText_EmptyTextInput2"] = 0x47AB3A, + ["gText_EmptyTextInput3"] = 0x47AB3B, + ["gText_EmptyTextInput4"] = 0x47AB3C, + ["gText_EmptyTextInput5"] = 0x47AB3D, + ["gText_EmptyTextInput6"] = 0x47AB3E, + ["gText_EmptyTextInput7"] = 0x47AB3F, + ["gText_EmptyTextInput8"] = 0x47AB40, + ["gText_End"] = 0x41B79F, + ["gText_EnergyRoot_80"] = 0x4180BC, + ["gText_Energypowder_50"] = 0x4180A7, + ["gText_EnjoyCycling"] = 0x4173E4, + ["gText_Enter"] = 0x4169A3, + ["gText_EntireTextCantBeDeleted"] = 0x418982, + ["gText_Escape3"] = 0x41622F, + ["gText_EscapeFromHere"] = 0x4175CC, + ["gText_EscapeFromHereAndReturnTo"] = 0x417615, + ["gText_Excellent"] = 0x4179ED, + ["gText_ExcellentsInARow"] = 0x41CB94, + ["gText_Exit"] = 0x41B361, + ["gText_Exit3"] = 0x41EE14, + ["gText_ExitFromBox"] = 0x418208, + ["gText_ExitUnused"] = 0x418204, + ["gText_ExitingTheChat"] = 0x41B3E9, + ["gText_F700JoinedChat"] = 0x41B3AA, + ["gText_F700LeftChat"] = 0x41B3BE, + ["gText_FailedUpdateSwapBackup"] = 0x415BAA, + ["gText_FeelingsRightNow"] = 0x418763, + ["gText_FemaleSymbol"] = 0x416221, + ["gText_FemaleSymbol4"] = 0x261EC3, + ["gText_Ferry"] = 0x418E36, + ["gText_FilledStorageSpace"] = 0x41CDBA, + ["gText_FilledStorageSpace2"] = 0x41CBCA, + ["gText_FindWordsThatDescribeYour"] = 0x418745, + ["gText_FirstPlacePrize"] = 0x41CD7A, + ["gText_First_PM"] = 0x41741E, + ["gText_FiveIsland"] = 0x417E22, + ["gText_FiveMarks"] = 0x416206, + ["gText_Flareon"] = 0x417BE8, + ["gText_FlyToAKnownTown"] = 0x417548, + ["gText_FlyToWhere"] = 0x416195, + ["gText_FontNormal"] = 0x416703, + ["gText_FontSmall"] = 0x4166FF, + ["gText_FourEllipsesExclamation"] = 0x4183BA, + ["gText_FourIsland"] = 0x417E16, + ["gText_Frame"] = 0x419E0A, + ["gText_FrameType"] = 0x419E52, + ["gText_FreshWater"] = 0x417C0F, + ["gText_FreshWater_200"] = 0x417984, + ["gText_Friend2"] = 0x41EE0D, + ["gText_FriendWantsToTrade"] = 0x41E1C5, + ["gText_From"] = 0x41E234, + ["gText_Frz"] = 0x41791C, + ["gText_GameLinkCable"] = 0x417D90, + ["gText_GamePlayCannotBeContinued"] = 0x418D70, + ["gText_GenderlessSymbol"] = 0x261EC5, + ["gText_GetsAPokeBlockQuestion"] = 0x418C2A, + ["gText_Ghost"] = 0x41D148, + ["gText_GiftSentTo"] = 0x41EB8E, + ["gText_Girl"] = 0x415D97, + ["gText_GiveToAPkmn"] = 0x4183DD, + ["gText_GiveToWhichPokemon"] = 0x41722B, + ["gText_GiveUpTryingToTeachNewMove"] = 0x41E4E2, + ["gText_GoBackToThePreviousMenu"] = 0x416244, + ["gText_GoldCard"] = 0x41DFA0, + ["gText_GoodSaying"] = 0x418AAB, + ["gText_GreenShard"] = 0x417A24, + ["gText_HOFCorrupted"] = 0x416090, + ["gText_HOFDexRating"] = 0x416028, + ["gText_HOFDexSaving"] = 0x416068, + ["gText_HOFNumber"] = 0x4160B4, + ["gText_HT"] = 0x415F98, + ["gText_HallOfFame"] = 0x4178F0, + ["gText_HallOfFameDebut"] = 0x419D1A, + ["gText_HallOfFame_2"] = 0x417BBE, + ["gText_HatchedFromEgg"] = 0x41B6FD, + ["gText_HaventReceivedCardsGift"] = 0x41E823, + ["gText_HaventReceivedGiftOkayToDiscard"] = 0x41ECF9, + ["gText_HealPowder_50"] = 0x4180D0, + ["gText_HelixFossil"] = 0x417D56, + ["gText_Hello"] = 0x41B510, + ["gText_HelpSystemControls_A_Next"] = 0x41DFA5, + ["gText_HelpSystemControls_AorBtoCancel"] = 0x41DFBE, + ["gText_HelpSystemControls_PickOkCancel"] = 0x41DFC9, + ["gText_HelpSystemControls_PickOkEnd"] = 0x41DFAC, + ["gText_HereYouGoThankYou"] = 0x4167E7, + ["gText_Hideout"] = 0x418E43, + ["gText_HighlightRed"] = 0x3FE826, + ["gText_HighlightRed_Left"] = 0x3FE81C, + ["gText_HowManyWouldYouLikeToSell"] = 0x416911, + ["gText_HpUp_1000"] = 0x418152, + ["gText_Huh"] = 0x41B329, + ["gText_ICanPayThisMuch_WouldThatBeOkay"] = 0x416936, + ["gText_IDNumber"] = 0x416104, + ["gText_IDNumber2"] = 0x416233, + ["gText_Ice"] = 0x3FE846, + ["gText_IfLeaderLeavesChatWillEnd"] = 0x41B4D9, + ["gText_IfThrowAwayCardEventWontHappen"] = 0x41EC99, + ["gText_InBagVar1"] = 0x416749, + ["gText_InGameClockUsable"] = 0x419F2D, + ["gText_InUseAlready_PM"] = 0x4173F9, + ["gText_InflictingDamageIsKey"] = 0x3FDC58, + ["gText_Info"] = 0x4178E6, + ["gText_Info4"] = 0x41793F, + ["gText_Info_2"] = 0x41797F, + ["gText_InputText"] = 0x41B39E, + ["gText_Interview"] = 0x418AA1, + ["gText_Iron_1000"] = 0x41810D, + ["gText_IsAsShownOkay"] = 0x418902, + ["gText_IsThisTradeOkay"] = 0x41E0A5, + ["gText_ItGotAway"] = 0x41D17E, + ["gText_Item"] = 0x4161D4, + ["gText_ItemCantBeHeld"] = 0x41635E, + ["gText_ItemEffect_Attack"] = 0x417674, + ["gText_ItemEffect_Defense"] = 0x41767B, + ["gText_ItemEffect_HP"] = 0x4169C2, + ["gText_ItemEffect_HP_Unused"] = 0x417693, + ["gText_ItemEffect_SpAtk"] = 0x4169C5, + ["gText_ItemEffect_SpAtk_Unused"] = 0x417683, + ["gText_ItemEffect_SpDef"] = 0x4169CD, + ["gText_ItemEffect_SpDef_Unused"] = 0x417688, + ["gText_ItemEffect_Speed"] = 0x41768D, + ["gText_ItemIsNowHeld"] = 0x418433, + ["gText_ItemStorage"] = 0x4176F1, + ["gText_ItemfinderResponding"] = 0x416476, + ["gText_ItemfinderShakingWildly"] = 0x4164BE, + ["gText_Items"] = 0x417B11, + ["gText_Items2"] = 0x4162CD, + ["gText_ItemsPocket"] = 0x417DA9, + ["gText_JoinGroup"] = 0x417F77, + ["gText_Jolteon"] = 0x417BF0, + ["gText_JumpToWhichBox"] = 0x41826C, + ["gText_JumpsInARow"] = 0x41CB78, + ["gText_JustOnePkmn"] = 0x418690, + ["gText_Kanto"] = 0x415DD1, + ["gText_KeepAnEyeOnHP"] = 0x3FDCD2, + ["gText_KeyItems"] = 0x417B17, + ["gText_KeyItems2"] = 0x4162DE, + ["gText_KeyItemsPocket"] = 0x417DB6, + ["gText_Koga"] = 0x4178DA, + ["gText_LButton"] = 0x415D05, + ["gText_LButtonRButton"] = 0x415D22, + ["gText_Lbs"] = 0x415FA0, + ["gText_LeaderHasLeftEndingChat"] = 0x41B3FB, + ["gText_LeagueChamp"] = 0x4160C8, + ["gText_Learned"] = 0x41744F, + ["gText_Left"] = 0x417A3D, + ["gText_Lemonade"] = 0x417C24, + ["gText_Lemonade_350"] = 0x4179AD, + ["gText_Lets"] = 0x41B52B, + ["gText_Level"] = 0x4160F4, + ["gText_LevelRoseTo"] = 0x41B295, + ["gText_LevelUp_Attack"] = 0x41B2B7, + ["gText_LevelUp_Defense"] = 0x41B2BE, + ["gText_LevelUp_MaxHP"] = 0x41B2A9, + ["gText_LevelUp_Minus"] = 0x41B2E5, + ["gText_LevelUp_Plus"] = 0x41B2DC, + ["gText_LevelUp_SpAtk"] = 0x41B2CC, + ["gText_LevelUp_SpDef"] = 0x41B2D4, + ["gText_LevelUp_Speed"] = 0x41B2C6, + ["gText_LightUpDarkness"] = 0x417583, + ["gText_LightestMode"] = 0x415EDF, + ["gText_LinkBattles"] = 0x419D2F, + ["gText_LinkCableBattles"] = 0x419D3C, + ["gText_LinkStandby"] = 0x3FE714, + ["gText_LinkStandyBButtonCancel"] = 0x41E76B, + ["gText_LinkedGamePlay"] = 0x41807D, + ["gText_ListenToExplanation"] = 0x417E68, + ["gText_LogOff"] = 0x417BCB, + ["gText_Loss"] = 0x3FE87B, + ["gText_Love"] = 0x3FE854, + ["gText_Lower"] = 0x41B343, + ["gText_LoweringStats"] = 0x3FDC95, + ["gText_LtSurge"] = 0x4178D0, + ["gText_LureWildPokemon"] = 0x4175DE, + ["gText_Lv"] = 0x416223, + ["gText_MachineSet"] = 0x41B31B, + ["gText_Mail"] = 0x4161D9, + ["gText_MailCantBeStored"] = 0x418452, + ["gText_MailMessageWillBeLost"] = 0x416BC3, + ["gText_MailReturnedToBagMessageErased"] = 0x41786B, + ["gText_MailSentToPC"] = 0x416B86, + ["gText_MailTakenFromPkmn"] = 0x416CC7, + ["gText_MailTransferredFromMailbox"] = 0x416D4F, + ["gText_Mailbox"] = 0x4176FE, + ["gText_MainMenuTime"] = 0x415CE8, + ["gText_MakeAChallenge"] = 0x41796D, + ["gText_MakeMessageSixPhrases"] = 0x418706, + ["gText_MaleSymbol"] = 0x41621F, + ["gText_MaleSymbol4"] = 0x261EC1, + ["gText_MarkYourPkmn"] = 0x4182EC, + ["gText_MarshBadge"] = 0x418017, + ["gText_Marshbadge"] = 0x417B7C, + ["gText_MaxTwoTwelveLetterPhrases"] = 0x418725, + ["gText_MenuBag"] = 0x416285, + ["gText_MenuExit"] = 0x41629D, + ["gText_MenuOption"] = 0x416296, + ["gText_MenuPlayer"] = 0x41628E, + ["gText_MenuPokedex"] = 0x41627D, + ["gText_MenuPokemon"] = 0x415A66, + ["gText_MenuPokenav"] = 0x416289, + ["gText_MenuRetire"] = 0x4162A2, + ["gText_MenuSafariStats"] = 0x4162A9, + ["gText_MenuSave"] = 0x416291, + ["gText_MessageWillBeLost"] = 0x417830, + ["gText_MiracleSeed_1000Coins"] = 0x417ED9, + ["gText_MonForgotMove"] = 0x41B306, + ["gText_MonForgotOldMoveAndMonLearnedNewMove"] = 0x41E4C0, + ["gText_MonIsTryingToLearnMove"] = 0x41E414, + ["gText_MonLearnedMove"] = 0x41E405, + ["gText_MonLearnedTMHM"] = 0x41B32E, + ["gText_MoveCloserToLinkPartner"] = 0x41DF05, + ["gText_MoveHeavyBoulders"] = 0x41756E, + ["gText_MoveInterfaceDynamicColors"] = 0x3FE770, + ["gText_MoveInterfacePP"] = 0x3FE766, + ["gText_MoveInterfaceType"] = 0x3FE76A, + ["gText_MoveItems"] = 0x41859A, + ["gText_MoveItemsDescription"] = 0x418642, + ["gText_MoveMonDescription"] = 0x418611, + ["gText_MoveNotLearned"] = 0x416E84, + ["gText_MoveOkBack"] = 0x418E77, + ["gText_MovePokemon"] = 0x41858D, + ["gText_MoveToWhere"] = 0x4171F1, + ["gText_Moves"] = 0x41699C, + ["gText_MovesPPIncreased"] = 0x417002, + ["gText_MultiBattle"] = 0x417960, + ["gText_Multichoice_Empty1"] = 0x417F68, + ["gText_Multichoice_Empty2"] = 0x417F67, + ["gText_MysteryGift"] = 0x415A97, + ["gText_MysteryGift2"] = 0x41EDBD, + ["gText_MysteryGiftBerry"] = 0x488C70, + ["gText_MysteryGiftBerryObtained"] = 0x488CCE, + ["gText_MysteryGiftBerryTransform"] = 0x488CA2, + ["gText_MysteryGiftCantBeUsed"] = 0x488DFD, + ["gText_MysteryGiftCantUse"] = 0x415ACB, + ["gText_MysteryGiftFullParty"] = 0x488D8E, + ["gText_MysteryGiftNationalDex"] = 0x488D2A, + ["gText_MysteryGiftNewAdversaryInBattleTower"] = 0x488DE1, + ["gText_MysteryGiftNewTrainer"] = 0x488DBD, + ["gText_MysteryGiftRareWord"] = 0x488D60, + ["gText_MysteryGiftSentOver"] = 0x488D7C, + ["gText_MysteryGiftSpecialRibbon"] = 0x488CF6, + ["gText_MysticWater_1000Coins"] = 0x417F11, + ["gText_Name"] = 0x4160FC, + ["gText_NamingScreenKeyboard_01234"] = 0x47AA5D, + ["gText_NamingScreenKeyboard_56789"] = 0x47AA76, + ["gText_NamingScreenKeyboard_ABCDEF"] = 0x47A9D9, + ["gText_NamingScreenKeyboard_GHIJKL"] = 0x47A9FA, + ["gText_NamingScreenKeyboard_MNOPQRS"] = 0x47AA1B, + ["gText_NamingScreenKeyboard_Symbols1"] = 0x47AA8F, + ["gText_NamingScreenKeyboard_Symbols2"] = 0x47AAA8, + ["gText_NamingScreenKeyboard_TUVWXYZ"] = 0x47AA3C, + ["gText_NamingScreenKeyboard_abcdef"] = 0x47A955, + ["gText_NamingScreenKeyboard_ghijkl"] = 0x47A976, + ["gText_NamingScreenKeyboard_mnopqrs"] = 0x47A997, + ["gText_NamingScreenKeyboard_tuvwxyz"] = 0x47A9B8, + ["gText_National"] = 0x415DD7, + ["gText_NavelRock"] = 0x417EC1, + ["gText_NeatnessRankings"] = 0x41D0D4, + ["gText_NewGame"] = 0x415A6E, + ["gText_NewStampReceived"] = 0x41E90C, + ["gText_NewTrainerReceived"] = 0x41E92B, + ["gText_Next"] = 0x415FC8, + ["gText_NextDataCancel"] = 0x415FB3, + ["gText_NickHatchPrompt"] = 0x41B716, + ["gText_No"] = 0x41793C, + ["gText_NoEntry"] = 0x4169A9, + ["gText_NoIdenticalHoldItems"] = 0x41734D, + ["gText_NoMoreRoomForStamps"] = 0x41E9AB, + ["gText_NoMoreRoomForThis"] = 0x416861, + ["gText_NoMoreRoomInBag"] = 0x41778A, + ["gText_NoMoreThanThreeMayEnter"] = 0x416B16, + ["gText_NoMoreThanTwoMayEnter"] = 0x416B3E, + ["gText_NoPokemonForBattle"] = 0x4172EB, + ["gText_NoRoomToStoreItems"] = 0x4163BB, + ["gText_NoSaveFileCantSetTime"] = 0x419F10, + ["gText_NoThanks"] = 0x417C4B, + ["gText_NoThanks_2"] = 0x417D4C, + ["gText_NoUse"] = 0x417411, + ["gText_NopeTheresNoResponse"] = 0x416513, + ["gText_Normal"] = 0x417ADC, + ["gText_NotAble"] = 0x417436, + ["gText_NotAble2"] = 0x417445, + ["gText_NotEnoughHp"] = 0x417313, + ["gText_NotEvenANibble"] = 0x41D169, + ["gText_NotPkmnOtherTrainerWants"] = 0x41718C, + ["gText_NotSoBad"] = 0x4179F7, + ["gText_NotYet"] = 0x417933, + ["gText_NothingChanged"] = 0x418C62, + ["gText_NothingSentOver"] = 0x41EA6F, + ["gText_NothingToCut"] = 0x417258, + ["gText_NowOn"] = 0x418075, + ["gText_NumPlayerLink"] = 0x41DF82, + ["gText_Number"] = 0x4160EC, + ["gText_NumberClear01"] = 0x416226, + ["gText_NumericalMode"] = 0x415E95, + ["gText_NumericalModeKanto"] = 0x415F0E, + ["gText_NumericalModeNational"] = 0x415F24, + ["gText_OK"] = 0x417926, + ["gText_OakForbidsUseOfItemHere"] = 0x416425, + ["gText_OakImportantToGetToKnowPokemonThroughly"] = 0x417457, + ["gText_OakNoRunningFromATrainer"] = 0x3FDD23, + ["gText_OakThisIsListOfPokemon"] = 0x417494, + ["gText_OhNoICantBuyThat"] = 0x4168F1, + ["gText_Ok"] = 0x41B531, + ["gText_OkayToDiscardNews"] = 0x41ECD3, + ["gText_OldAmber"] = 0x417D6F, + ["gText_OneIsland"] = 0x417DF3, + ["gText_OnlyOnePhrase"] = 0x418998, + ["gText_OnlyPkmnForBattle"] = 0x417094, + ["gText_Option"] = 0x419DCC, + ["gText_OptionMenuCancel"] = 0x419E10, + ["gText_OriginalSongWillBeUsed"] = 0x4189AC, + ["gText_Other"] = 0x417DED, + ["gText_OtherTrainerCanceled"] = 0x41EC40, + ["gText_OtherTrainerCantAcceptPkmn"] = 0x417130, + ["gText_OtherTrainerHasCard"] = 0x41EBAA, + ["gText_OtherTrainerHasNews"] = 0x41EBDE, + ["gText_OtherTrainerHasStamp"] = 0x41EC12, + ["gText_OtherTrainersPkmnCantBeTraded"] = 0x4170FC, + ["gText_Others"] = 0x41B349, + ["gText_Owned"] = 0x415DCA, + ["gText_PCMailboxFull"] = 0x416BA6, + ["gText_PC_WhatWouldYouLikeToDo"] = 0x418562, + ["gText_PPWasRestored"] = 0x416FC7, + ["gText_Page"] = 0x416002, + ["gText_Par"] = 0x417910, + ["gText_Paralysis"] = 0x3FE83C, + ["gText_Participate"] = 0x4178DF, + ["gText_PartyFull"] = 0x4186B0, + ["gText_PartyMenu_OK"] = 0x4169D5, + ["gText_PauseUntilPress"] = 0x4169D9, + ["gText_PeopleBattling"] = 0x41E273, + ["gText_PeopleCommunicating"] = 0x41E29E, + ["gText_PeopleInUnionRoom"] = 0x41E284, + ["gText_PeopleTrading"] = 0x41E263, + ["gText_Period"] = 0x41B293, + ["gText_Period2"] = 0x41B2A7, + ["gText_PickFlipPageCheckCancel"] = 0x415F6C, + ["gText_PickOK"] = 0x415DB8, + ["gText_PickOKCancel"] = 0x41EDCA, + ["gText_PickOKExit"] = 0x415F51, + ["gText_PickOk"] = 0x415D38, + ["gText_PickSwitchCancel"] = 0x419E57, + ["gText_PickTheWallpaper"] = 0x418248, + ["gText_Pink"] = 0x417AE9, + ["gText_Pinsir_2500Coins"] = 0x417CFF, + ["gText_PkmnAlreadyHoldingItemSwitch"] = 0x416C49, + ["gText_PkmnAlreadyInBattle"] = 0x416A3A, + ["gText_PkmnAlreadyKnows"] = 0x416F10, + ["gText_PkmnAlreadySelected"] = 0x416A55, + ["gText_PkmnBaseVar2StatIncreased"] = 0x417032, + ["gText_PkmnBecameHealthy"] = 0x416FED, + ["gText_PkmnBurnHealed"] = 0x416F9A, + ["gText_PkmnCameBack"] = 0x418392, + ["gText_PkmnCantBeTradedNow"] = 0x4170BC, + ["gText_PkmnCantLearnMove"] = 0x416DC2, + ["gText_PkmnCantParticipate"] = 0x417696, + ["gText_PkmnCantSwitchOut"] = 0x416A1E, + ["gText_PkmnCuredOfParalysis"] = 0x416F6F, + ["gText_PkmnCuredOfPoison"] = 0x416F4E, + ["gText_PkmnElevatedToLvVar2"] = 0x417017, + ["gText_PkmnFainted3"] = 0x1A5476, + ["gText_PkmnGotOverInfatuation"] = 0x417075, + ["gText_PkmnHPRestoredByVar2"] = 0x416F27, + ["gText_PkmnHasNoEnergy"] = 0x416A75, + ["gText_PkmnHoldingItemCantHoldMail"] = 0x416D17, + ["gText_PkmnIsEvolving"] = 0x3FE672, + ["gText_PkmnIsSelected"] = 0x41825C, + ["gText_PkmnJumpRecords"] = 0x41CB63, + ["gText_PkmnLearnedMove3"] = 0x416DB3, + ["gText_PkmnNeedsToReplaceMove"] = 0x416DF7, + ["gText_PkmnNotHolding"] = 0x416C8F, + ["gText_PkmnRegainhedHealth"] = 0x416FDA, + ["gText_PkmnSnappedOutOfConfusion"] = 0x417052, + ["gText_PkmnStoppedEvolving"] = 0x3FE6B5, + ["gText_PkmnThawedOut"] = 0x416FB2, + ["gText_PkmnWasDeposited"] = 0x418295, + ["gText_PkmnWasGivenItem"] = 0x416C2A, + ["gText_PkmnWasReleased"] = 0x4182CE, + ["gText_PkmnWokeUp2"] = 0x416F8C, + ["gText_PkmnsNickname"] = 0x418E5C, + ["gText_PlacedItemInBag"] = 0x4183F0, + ["gText_PlayedPokeFlute"] = 0x416690, + ["gText_PlayedPokeFluteCatchy"] = 0x41665C, + ["gText_Player"] = 0x41B6D5, + ["gText_PlayerSavedTheGame"] = 0x1C5647, + ["gText_PlayerScurriedBackHome"] = 0x41B5B6, + ["gText_PlayerScurriedToCenter"] = 0x41B554, + ["gText_PlayerUnused"] = 0x415CD9, + ["gText_PlayerUsedVar2"] = 0x41658C, + ["gText_PlayersXPokemon"] = 0x41B3D0, + ["gText_PlayersXPokmonDoesNotExist"] = 0x41B3DA, + ["gText_PleaseEnterPhraseOrWord"] = 0x418970, + ["gText_PleasePickATheme"] = 0x418233, + ["gText_PleaseRemoveTheMail"] = 0x4183C5, + ["gText_PleaseResetTime"] = 0x419EB0, + ["gText_Plus"] = 0x41622C, + ["gText_Points"] = 0x419CFA, + ["gText_Poison"] = 0x3FE830, + ["gText_PokeBalls"] = 0x417B21, + ["gText_PokeBalls2"] = 0x4162D3, + ["gText_PokeBallsPocket"] = 0x417DC7, + ["gText_PokeFluteAwakenedMon"] = 0x4166A7, + ["gText_PokeSum_ATrade"] = 0x419C0B, + ["gText_PokeSum_ApparentlyFatefulEncounterHatched"] = 0x4199AB, + ["gText_PokeSum_ApparentlyFatefulEncounterHatched_BoldGentleGrammar"] = 0x4199F4, + ["gText_PokeSum_ApparentlyMet"] = 0x419860, + ["gText_PokeSum_ApparentlyMet_BoldGentleGrammar"] = 0x41988A, + ["gText_PokeSum_Controls_Cancel"] = 0x419C72, + ["gText_PokeSum_Controls_Page"] = 0x419C7B, + ["gText_PokeSum_Controls_PageCancel"] = 0x419C62, + ["gText_PokeSum_Controls_PageDetail"] = 0x419C82, + ["gText_PokeSum_Controls_Pick"] = 0x419CA2, + ["gText_PokeSum_Controls_PickDelete"] = 0x419CA9, + ["gText_PokeSum_Controls_PickSwitch"] = 0x419C92, + ["gText_PokeSum_DexNoUnknown"] = 0x416202, + ["gText_PokeSum_EggHatch_AlmostReady"] = 0x419BDB, + ["gText_PokeSum_EggHatch_LongTime"] = 0x419B44, + ["gText_PokeSum_EggHatch_SomeTime"] = 0x419B7B, + ["gText_PokeSum_EggHatch_Soon"] = 0x419BAE, + ["gText_PokeSum_EggOrigin_DayCare"] = 0x419A3D, + ["gText_PokeSum_EggOrigin_NicePlace"] = 0x419AA2, + ["gText_PokeSum_EggOrigin_Spa"] = 0x419ADE, + ["gText_PokeSum_EggOrigin_Trade"] = 0x419B18, + ["gText_PokeSum_EggOrigin_TravelingMan"] = 0x419A6E, + ["gText_PokeSum_ExpPoints"] = 0x419C4D, + ["gText_PokeSum_FatefulEncounterHatched"] = 0x41992F, + ["gText_PokeSum_FatefulEncounterHatched_BoldGentleGrammar"] = 0x41996D, + ["gText_PokeSum_FatefulEncounterMet"] = 0x4197B8, + ["gText_PokeSum_FatefulEncounterMet_BoldGentleGrammar"] = 0x4197ED, + ["gText_PokeSum_Hatched"] = 0x4198B4, + ["gText_PokeSum_Hatched_BoldGentleGrammar"] = 0x4198D5, + ["gText_PokeSum_HmMovesCantBeForgotten"] = 0x419CB9, + ["gText_PokeSum_Item_None"] = 0x4161EF, + ["gText_PokeSum_JP_ApparentlyHatched"] = 0x4198F6, + ["gText_PokeSum_JP_ApparentlyHatched_BoldGentleGrammar"] = 0x419913, + ["gText_PokeSum_Met"] = 0x419822, + ["gText_PokeSum_MetInATrade"] = 0x419782, + ["gText_PokeSum_MetInATrade_BoldGentleGrammar"] = 0x41979D, + ["gText_PokeSum_Met_BoldGentleGrammar"] = 0x419841, + ["gText_PokeSum_NextLv"] = 0x419C59, + ["gText_PokeSum_NoData"] = 0x419C45, + ["gText_PokeSum_OneHyphen"] = 0x41620E, + ["gText_PokeSum_PP"] = 0x416238, + ["gText_PokeSum_PageName_KnownMoves"] = 0x419C39, + ["gText_PokeSum_PageName_PokemonInfo"] = 0x419C1D, + ["gText_PokeSum_PageName_PokemonSkills"] = 0x419C2A, + ["gText_PokeSum_TwoHyphens"] = 0x416210, + ["gText_Pokedex"] = 0x41B6E3, + ["gText_PokedexNo"] = 0x415FFF, + ["gText_PokedexOther"] = 0x415EB0, + ["gText_PokedexPokemon"] = 0x415F8F, + ["gText_PokedexQuotationMark"] = 0x415F9E, + ["gText_PokedexTableOfContents"] = 0x415D9C, + ["gText_PokedexUnused"] = 0x415CE0, + ["gText_PokedollarVar1"] = 0x41697A, + ["gText_Pokemon2"] = 0x41B516, + ["gText_Pokemon4"] = 0x416146, + ["gText_PokemonCantBeSame"] = 0x417337, + ["gText_PokemonCenter"] = 0x418C1B, + ["gText_PokemonHabitats"] = 0x415DE0, + ["gText_PokemonJump"] = 0x417E72, + ["gText_PokemonLeague"] = 0x418C12, + ["gText_PokemonList"] = 0x415EFB, + ["gText_PokemonListNoColor"] = 0x415F3D, + ["gText_PokemonOnHook"] = 0x41D14E, + ["gText_PokemonTrades"] = 0x419D57, + ["gText_Poof"] = 0x41B2FF, + ["gText_Porygon_6500Coins"] = 0x417D32, + ["gText_Porygon_9999Coins"] = 0x417CB7, + ["gText_PowderQty"] = 0x416644, + ["gText_PpUp_3000"] = 0x418163, + ["gText_PresentTime"] = 0x419E90, + ["gText_PressesRankings"] = 0x41D0A8, + ["gText_PressingPowerRankings"] = 0x41D100, + ["gText_PressingSpeed"] = 0x41D058, + ["gText_PressingSpeedRankings"] = 0x41D124, + ["gText_PreviousTime"] = 0x419EA0, + ["gText_ProfOakSPc"] = 0x417BD3, + ["gText_Profile"] = 0x418A2B, + ["gText_Protein_1000"] = 0x4180FA, + ["gText_Psn"] = 0x41790C, + ["gText_PutItemInBag"] = 0x418419, + ["gText_QuestLog_ADifferentSpot"] = 0x41A694, + ["gText_QuestLog_ArrivedInLocation"] = 0x41B064, + ["gText_QuestLog_Barely"] = 0x41AF9F, + ["gText_QuestLog_BattledTrainerEndedInOutcome"] = 0x41A7B0, + ["gText_QuestLog_BikeShop"] = 0x41B0FF, + ["gText_QuestLog_BillsHouse"] = 0x41B109, + ["gText_QuestLog_BoughtItem"] = 0x41A7DD, + ["gText_QuestLog_BoughtItemsIncludingItem"] = 0x41A810, + ["gText_QuestLog_CaughtWildMon"] = 0x41AA01, + ["gText_QuestLog_CaughtWildMons"] = 0x41AA2B, + ["gText_QuestLog_CeladonDeptStore"] = 0x41B1A3, + ["gText_QuestLog_CeladonMansion"] = 0x41B1B7, + ["gText_QuestLog_CeruleanCave"] = 0x41B277, + ["gText_QuestLog_ChattedWithManyTrainers"] = 0x41A732, + ["gText_QuestLog_Coolly"] = 0x41AF98, + ["gText_QuestLog_DayCare"] = 0x41B116, + ["gText_QuestLog_DefeatedWildMon"] = 0x41A9A9, + ["gText_QuestLog_DefeatedWildMonAndCaughtWildMon"] = 0x41AA76, + ["gText_QuestLog_DefeatedWildMonAndCaughtWildMons"] = 0x41AAAA, + ["gText_QuestLog_DefeatedWildMons"] = 0x41A9D4, + ["gText_QuestLog_DefeatedWildMonsAndCaughtWildMon"] = 0x41AAEC, + ["gText_QuestLog_DefeatedWildMonsAndCaughtWildMons"] = 0x41AB29, + ["gText_QuestLog_DepartedFromLocationToNextDestination"] = 0x41B005, + ["gText_QuestLog_DepartedGym"] = 0x41AE8F, + ["gText_QuestLog_DepartedPlaceInTownForNextDestination"] = 0x41A53A, + ["gText_QuestLog_DepartedTheLocationForNextDestination"] = 0x41AFD6, + ["gText_QuestLog_DepositedMonInPC"] = 0x41A64F, + ["gText_QuestLog_DiglettsCave"] = 0x41B14B, + ["gText_QuestLog_DoubleBattleWithPersonResultedInOutcome"] = 0x41A477, + ["gText_QuestLog_Draw"] = 0x41AFD1, + ["gText_QuestLog_FightingDojo"] = 0x41B20E, + ["gText_QuestLog_GaveMonHeldItem"] = 0x41AB74, + ["gText_QuestLog_GaveMonHeldItem2"] = 0x41AB8E, + ["gText_QuestLog_GaveMonHeldItemFromPC"] = 0x41A6A5, + ["gText_QuestLog_Gym"] = 0x41B0B5, + ["gText_QuestLog_GymWasFullOfToughTrainers"] = 0x41AE48, + ["gText_QuestLog_HadGreatTimeInSafariZone"] = 0x41AEA7, + ["gText_QuestLog_Handily"] = 0x41A74E, + ["gText_QuestLog_Home"] = 0x41B09F, + ["gText_QuestLog_JustOne"] = 0x41A8D4, + ["gText_QuestLog_LeftOaksLab"] = 0x41AE1E, + ["gText_QuestLog_LeftTownsLocationForNextDestination"] = 0x41AD9E, + ["gText_QuestLog_Loss"] = 0x41A506, + ["gText_QuestLog_ManagedToGetOutOfLocation"] = 0x41AEDC, + ["gText_QuestLog_MingledInUnionRoom"] = 0x41A50B, + ["gText_QuestLog_MonLearnedMoveFromHM"] = 0x41A938, + ["gText_QuestLog_MonLearnedMoveFromTM"] = 0x41A255, + ["gText_QuestLog_MonReplacedMoveWithHM"] = 0x41A965, + ["gText_QuestLog_MonReplacedMoveWithTM"] = 0x41A277, + ["gText_QuestLog_MonsWereFullyRestoredAtCenter"] = 0x41A2B0, + ["gText_QuestLog_MovedMonToNewBox"] = 0x41A59C, + ["gText_QuestLog_MovedMonWithinBox"] = 0x41A5FA, + ["gText_QuestLog_MtMoon"] = 0x41B0F6, + ["gText_QuestLog_MultiBattleWithPeopleResultedInOutcome"] = 0x41A4C6, + ["gText_QuestLog_NameRatersHouse"] = 0x41B190, + ["gText_QuestLog_Num"] = 0x41A8DD, + ["gText_QuestLog_OakResearchLab"] = 0x41B0A4, + ["gText_QuestLog_ObtainedItemInLocation"] = 0x41B03F, + ["gText_QuestLog_PewterMuseumOfScience"] = 0x41B0DD, + ["gText_QuestLog_PlayedGamesAtGameCorner"] = 0x41ADC8, + ["gText_QuestLog_PlayerBattledChampionRival"] = 0x41A2E1, + ["gText_QuestLog_PlayerSentOutMon1RivalSentOutMon2"] = 0x41A312, + ["gText_QuestLog_PokemonFanClub"] = 0x41B130, + ["gText_QuestLog_PokemonLeague"] = 0x41B268, + ["gText_QuestLog_PokemonLeagueGate"] = 0x41B0B9, + ["gText_QuestLog_PokemonMansion"] = 0x41B236, + ["gText_QuestLog_PokemonResearchLab"] = 0x41B246, + ["gText_QuestLog_PokemonTower"] = 0x41B172, + ["gText_QuestLog_PowerPlant"] = 0x41B166, + ["gText_QuestLog_PreviouslyOnYourQuest"] = 0x41A155, + ["gText_QuestLog_Restaurant"] = 0x41B1DA, + ["gText_QuestLog_RestedAtHome"] = 0x41ADFF, + ["gText_QuestLog_RockTunnel"] = 0x41B15A, + ["gText_QuestLog_RocketGameCorner"] = 0x41B1C7, + ["gText_QuestLog_RocketHideout"] = 0x41B1E5, + ["gText_QuestLog_SSAnne"] = 0x41B141, + ["gText_QuestLog_SafariZone"] = 0x41B1F4, + ["gText_QuestLog_SavedGameAtLocation"] = 0x41B073, + ["gText_QuestLog_SeafoamIslands"] = 0x41B226, + ["gText_QuestLog_SilphCo"] = 0x41B21C, + ["gText_QuestLog_SingleBattleWithPersonResultedInOutcome"] = 0x41A422, + ["gText_QuestLog_SoldItemsIncludingItem"] = 0x41A896, + ["gText_QuestLog_SoldNumOfItem"] = 0x41A858, + ["gText_QuestLog_Somehow"] = 0x41A762, + ["gText_QuestLog_StoredItemInPC"] = 0x41A391, + ["gText_QuestLog_SwappedHeldItemFromPC"] = 0x41A6E1, + ["gText_QuestLog_SwappedHeldItemsOnMon"] = 0x41A193, + ["gText_QuestLog_SwitchMon1WithMon2"] = 0x41A16F, + ["gText_QuestLog_SwitchedMonsBetweenBoxes"] = 0x41A566, + ["gText_QuestLog_SwitchedMonsWithinBox"] = 0x41A5D9, + ["gText_QuestLog_SwitchedMultipleMons"] = 0x41A66E, + ["gText_QuestLog_SwitchedPartyMonForPCMon"] = 0x41A60A, + ["gText_QuestLog_Tenaciously"] = 0x41A756, + ["gText_QuestLog_TookHeldItemFromMon"] = 0x41A1CD, + ["gText_QuestLog_TookOnEliteFoursMonWithMonAndWon"] = 0x41AF3E, + ["gText_QuestLog_TookOnGymLeadersMonWithMonAndWon"] = 0x41AF0C, + ["gText_QuestLog_TookOnTrainersMonWithMonAndWon"] = 0x41AF6D, + ["gText_QuestLog_TradedMon1ForPersonsMon2"] = 0x41A3FF, + ["gText_QuestLog_TradedMon1ForTrainersMon2"] = 0x41A76A, + ["gText_QuestLog_UndergroundPath"] = 0x41B11F, + ["gText_QuestLog_UsedCut"] = 0x41ABAB, + ["gText_QuestLog_UsedDigInLocation"] = 0x41AD1D, + ["gText_QuestLog_UsedDive"] = 0x41ACF9, + ["gText_QuestLog_UsedEscapeRope"] = 0x41AFA6, + ["gText_QuestLog_UsedFlash"] = 0x41AC51, + ["gText_QuestLog_UsedFly"] = 0x41ABCD, + ["gText_QuestLog_UsedItemOnMonAtThisLocation"] = 0x41A1E7, + ["gText_QuestLog_UsedMilkDrink"] = 0x41A90C, + ["gText_QuestLog_UsedRockSmash"] = 0x41AC93, + ["gText_QuestLog_UsedSoftboiled"] = 0x41A8E0, + ["gText_QuestLog_UsedStrength"] = 0x41AC2A, + ["gText_QuestLog_UsedSurf"] = 0x41ABF9, + ["gText_QuestLog_UsedSweetScent"] = 0x41AD3C, + ["gText_QuestLog_UsedTeleportToLocation"] = 0x41AD69, + ["gText_QuestLog_UsedTheItem"] = 0x41A210, + ["gText_QuestLog_UsedTheKeyItem"] = 0x41A220, + ["gText_QuestLog_UsedWaterfall"] = 0x41ACBC, + ["gText_QuestLog_VictoryRoad"] = 0x41B25B, + ["gText_QuestLog_ViridianForest"] = 0x41B0CD, + ["gText_QuestLog_VolunteerHouse"] = 0x41B180, + ["gText_QuestLog_WardensHome"] = 0x41B200, + ["gText_QuestLog_Win"] = 0x41A502, + ["gText_QuestLog_WithdrewItemFromPC"] = 0x41A3DA, + ["gText_QuestLog_WithdrewMonFromPC"] = 0x41A632, + ["gText_QuestLog_WonTheMatchAsAResult"] = 0x41A349, + ["gText_Questionnaire"] = 0x418AB2, + ["gText_Quit"] = 0x417907, + ["gText_QuitChatting"] = 0x41B366, + ["gText_QuitEditing"] = 0x4189E0, + ["gText_QuitLooking"] = 0x417C01, + ["gText_QuitShopping"] = 0x416757, + ["gText_RButton"] = 0x415D00, + ["gText_RainbowBadge"] = 0x418000, + ["gText_Rainbowbadge"] = 0x417B65, + ["gText_Read2"] = 0x4169B7, + ["gText_ReadNewsThatArrived"] = 0x41E669, + ["gText_Receive"] = 0x41EE19, + ["gText_ReceivedItemFromPkmn"] = 0x416CAC, + ["gText_RecordCorner"] = 0x417E71, + ["gText_RecordUploadedViaWireless"] = 0x41E9D3, + ["gText_RedShard"] = 0x417A02, + ["gText_RefusedBattle"] = 0x41E58D, + ["gText_RegionMap_AButtonCancel"] = 0x418E95, + ["gText_RegionMap_AButtonCancel2"] = 0x418E9E, + ["gText_RegionMap_AButtonGuide"] = 0x418E8D, + ["gText_RegionMap_AButtonOK"] = 0x418EB0, + ["gText_RegionMap_AButtonSwitch"] = 0x418EA7, + ["gText_RegionMap_AreaDesc_AlteringCave"] = 0x419629, + ["gText_RegionMap_AreaDesc_BerryForest"] = 0x419444, + ["gText_RegionMap_AreaDesc_CeruleanCave"] = 0x4192E2, + ["gText_RegionMap_AreaDesc_DiglettsCave"] = 0x418FB8, + ["gText_RegionMap_AreaDesc_DottedHole"] = 0x41971A, + ["gText_RegionMap_AreaDesc_IcefallCave"] = 0x4194C9, + ["gText_RegionMap_AreaDesc_LostCave"] = 0x41953F, + ["gText_RegionMap_AreaDesc_MtEmber"] = 0x4193D5, + ["gText_RegionMap_AreaDesc_MtMoon"] = 0x418F3A, + ["gText_RegionMap_AreaDesc_PatternBush"] = 0x41969D, + ["gText_RegionMap_AreaDesc_PokemonMansion"] = 0x41908E, + ["gText_RegionMap_AreaDesc_PokemonTower"] = 0x41926D, + ["gText_RegionMap_AreaDesc_PowerPlant"] = 0x41935B, + ["gText_RegionMap_AreaDesc_RockTunnel"] = 0x41917D, + ["gText_RegionMap_AreaDesc_SafariZone"] = 0x419103, + ["gText_RegionMap_AreaDesc_SeafoamIslands"] = 0x419201, + ["gText_RegionMap_AreaDesc_TanobyRuins"] = 0x4195AC, + ["gText_RegionMap_AreaDesc_VictoryRoad"] = 0x41901C, + ["gText_RegionMap_AreaDesc_ViridianForest"] = 0x418ECB, + ["gText_RegionMap_DPadMove"] = 0x418EB5, + ["gText_RegionMap_NoData"] = 0x418EC3, + ["gText_RegionMap_Space"] = 0x418E8B, + ["gText_RegionMap_UpDownPick"] = 0x418EBC, + ["gText_Register"] = 0x41B6F4, + ["gText_Register2"] = 0x41B358, + ["gText_RegisterTextHere"] = 0x41B38A, + ["gText_RegisterTextWhere"] = 0x41B375, + ["gText_RegisteredTextChanged_AlreadySavedFile"] = 0x41B469, + ["gText_RegisteredTextChanged_OKtoSave"] = 0x41B426, + ["gText_RegisteredTextChanged_SavedTheGame"] = 0x41B4C6, + ["gText_RegisteredTextChanged_SavingDontTurnOff"] = 0x41B4A4, + ["gText_ReleaseThisPokemon"] = 0x4182B8, + ["gText_RemoveMailBeforeItem"] = 0x416BFB, + ["gText_RepelEffectsLingered"] = 0x41659E, + ["gText_ResetRTCConfirmCancel"] = 0x419E72, + ["gText_RestoreWhichMove"] = 0x4172AE, + ["gText_ReturnToAHealingSpot"] = 0x4175FB, + ["gText_ReturnToHealingSpot"] = 0x417640, + ["gText_ReturnToPC"] = 0x4178BE, + ["gText_ReturnToTitle"] = 0x41E685, + ["gText_RevivalHerb_300"] = 0x4180E4, + ["gText_Right"] = 0x417A37, + ["gText_RivalsName"] = 0x418E69, + ["gText_Rooftop"] = 0x41806D, + ["gText_Rooftop2"] = 0x41D18D, + ["gText_SPc"] = 0x417BB6, + ["gText_SafariBalls"] = 0x3FE80C, + ["gText_SafariZoneMenu"] = 0x3FE747, + ["gText_SaveCompleteGameCannotContinue"] = 0x418DC9, + ["gText_SaveCompletePressA"] = 0x418E09, + ["gText_SaveCompleted"] = 0x419EEA, + ["gText_SaveCompletedPressA"] = 0x41ED50, + ["gText_SaveError_PleaseExchangeBackupMemory"] = 0x41614B, + ["gText_SaveFailed"] = 0x419EF9, + ["gText_SaveFailed2"] = 0x1C565A, + ["gText_SaveFailedCheckingBackup"] = 0x418C83, + ["gText_SaveFileCorrupted"] = 0x415BFF, + ["gText_SaveFileHasBeenDeleted"] = 0x415C42, + ["gText_SaveFileUpdated"] = 0x415B33, + ["gText_SaveFileUpdatedSwapBackup"] = 0x415B44, + ["gText_SavingDontTurnOffPower"] = 0x41CC42, + ["gText_SavingDontTurnOffPower2"] = 0x41CC7B, + ["gText_SavingDontTurnOffThePower"] = 0x1C5625, + ["gText_SavingDontTurnOffThePower2"] = 0x419F54, + ["gText_SawIt"] = 0x41792E, + ["gText_Scyther_5500Coins"] = 0x417C9D, + ["gText_Search"] = 0x415E88, + ["gText_SearchNoColor"] = 0x415F4A, + ["gText_Second_PM"] = 0x417424, + ["gText_SecretBase"] = 0x418E3D, + ["gText_SeeYa"] = 0x4185A5, + ["gText_SeeYaDescription"] = 0x418681, + ["gText_Seen"] = 0x415DC4, + ["gText_Select"] = 0x415D12, + ["gText_SelectorArrow2"] = 0x41623B, + ["gText_Send"] = 0x41EE21, + ["gText_SendMailToPC"] = 0x416B64, + ["gText_SendOut"] = 0x416984, + ["gText_SendingWonderCard"] = 0x41EAE7, + ["gText_SendingWonderNews"] = 0x41EB01, + ["gText_Sepia"] = 0x417AEE, + ["gText_SevenHyphens"] = 0x416217, + ["gText_SevenIsland"] = 0x417E39, + ["gText_SeviiIslands"] = 0x417EB3, + ["gText_ShareHp"] = 0x4175F1, + ["gText_ShatterACrackedRock"] = 0x417596, + ["gText_Shift"] = 0x41697E, + ["gText_ShopBuy"] = 0x416738, + ["gText_ShopQuit"] = 0x416741, + ["gText_ShopSell"] = 0x41673C, + ["gText_Silkiness"] = 0x41D068, + ["gText_SilverCard"] = 0x41DF99, + ["gText_SingleBattle"] = 0x417944, + ["gText_SixIsland"] = 0x417E2E, + ["gText_Size"] = 0x415FED, + ["gText_Slash"] = 0x41620C, + ["gText_Sleep"] = 0x3FE82A, + ["gText_Slp"] = 0x417914, + ["gText_SmallestMode"] = 0x415EED, + ["gText_Smart"] = 0x417AD0, + ["gText_Smart_2"] = 0x417B05, + ["gText_Smartness"] = 0x418C4A, + ["gText_SmokeBall_800Coins"] = 0x417F4D, + ["gText_SodaPop"] = 0x417C1B, + ["gText_SodaPop_300"] = 0x41799A, + ["gText_SomeoneDroppedOut"] = 0x41CDEB, + ["gText_SomeoneDroppedOut2"] = 0x41CC11, + ["gText_SomeoneSPc"] = 0x417B9F, + ["gText_Somewhere"] = 0x419C13, + ["gText_Son"] = 0x417FCC, + ["gText_Sorry"] = 0x41B535, + ["gText_SoulBadge"] = 0x41800D, + ["gText_Soulbadge"] = 0x417B72, + ["gText_Sound"] = 0x419DF8, + ["gText_SoundMono"] = 0x419E36, + ["gText_SoundStereo"] = 0x419E3B, + ["gText_SpaceForVar1Full"] = 0x416888, + ["gText_SpaceMin"] = 0x41D078, + ["gText_SpacePoints"] = 0x41CE1C, + ["gText_SpacePoints2"] = 0x41CB52, + ["gText_SpaceSec"] = 0x41D088, + ["gText_SpaceSeconds"] = 0x417FB2, + ["gText_SpaceTimes"] = 0x417FB6, + ["gText_SpaceTimes2"] = 0x41D034, + ["gText_SpaceTimes3"] = 0x41CB5A, + ["gText_StampSentTo"] = 0x41EB71, + ["gText_Start"] = 0x415D0A, + ["gText_StopGivingPkmnMail"] = 0x4189EE, + ["gText_StopLearningMove"] = 0x41E481, + ["gText_StopLearningMove2"] = 0x416E6B, + ["gText_Store"] = 0x4161E3, + ["gText_StoreItemsInThePC"] = 0x41772A, + ["gText_StrVar1"] = 0x41D074, + ["gText_StrVar1Berry"] = 0x41D044, + ["gText_Summary5"] = 0x416994, + ["gText_Switch2"] = 0x41698D, + ["gText_SwitchedPkmnItem"] = 0x416CEA, + ["gText_Symbols"] = 0x41B350, + ["gText_TMCase"] = 0x4166D3, + ["gText_TMCaseWillBePutAway"] = 0x4166E1, + ["gText_TMHMContainedVar1"] = 0x416565, + ["gText_TMsAndHMs"] = 0x417B2C, + ["gText_Take"] = 0x4161DE, + ["gText_Take2"] = 0x4169B2, + ["gText_TakeGoodCareOfX"] = 0x41E21E, + ["gText_TakeOutItemsFromThePC"] = 0x417741, + ["gText_TeachMoveQues"] = 0x41E3FB, + ["gText_TeachWhichMoveToMon"] = 0x41E3E3, + ["gText_TeachWhichPokemon"] = 0x417200, + ["gText_TeachyTV"] = 0x41B798, + ["gText_TenDashes"] = 0x41E52D, + ["gText_TextSpeed"] = 0x419DD3, + ["gText_TextSpeedFast"] = 0x419E20, + ["gText_TextSpeedMid"] = 0x419E1C, + ["gText_TextSpeedSlow"] = 0x419E17, + ["gText_ThankYou"] = 0x41B541, + ["gText_ThankYouIllSendItHome"] = 0x416800, + ["gText_ThanksIllSendItHome"] = 0x416822, + ["gText_ThatIsntAnEgg"] = 0x4171CC, + ["gText_ThatsTrendyAlready"] = 0x4189BB, + ["gText_ThatsYourLastPkmn"] = 0x4182FF, + ["gText_TheAnswer"] = 0x418881, + ["gText_TheBardsSong"] = 0x418A77, + ["gText_TheBerryPouchWillBePutAway"] = 0x416716, + ["gText_TheEditedWords"] = 0x418A0C, + ["gText_TheMailMessage"] = 0x41888C, + ["gText_TheMailSalutation"] = 0x41889D, + ["gText_TheNewSong"] = 0x4188AC, + ["gText_TheStrVar1CantBeHeldHere"] = 0x416374, + ["gText_TheTrainerThat"] = 0x3FDB92, + ["gText_TheTrendySaying"] = 0x4188F0, + ["gText_ThereAreNoItems"] = 0x417774, + ["gText_ThereIsNoPokemon"] = 0x41632A, + ["gText_TheresNoMailHere"] = 0x4177EE, + ["gText_Third_PM"] = 0x41742B, + ["gText_ThreeHyphens"] = 0x416213, + ["gText_ThreeIsland"] = 0x417E09, + ["gText_ThreePkmnAreNeeded"] = 0x417322, + ["gText_ThreeQuestionMarks"] = 0x41E093, + ["gText_ThrewAwayStrVar2StrVar1s"] = 0x4163F4, + ["gText_ThrowAwayItemsInPC"] = 0x41775D, + ["gText_ThrowAwayStrVar2OfThisItemQM"] = 0x416409, + ["gText_ThrowAwayWonderCard"] = 0x41E7F2, + ["gText_ThrowInPremierBall"] = 0x4168D7, + ["gText_ThunderBadge"] = 0x417FF3, + ["gText_Thunderbadge"] = 0x417B58, + ["gText_Time"] = 0x41B6DC, + ["gText_TimeBoard"] = 0x3FE982, + ["gText_TimeColon"] = 0x41D050, + ["gText_TimesPerSec"] = 0x41D098, + ["gText_TimesStrVar1"] = 0x4162B9, + ["gText_Tm13_4000Coins"] = 0x417A42, + ["gText_Tm23_3500Coins"] = 0x417A58, + ["gText_Tm24_4000Coins"] = 0x417A6E, + ["gText_Tm30_4500Coins"] = 0x417A84, + ["gText_Tm35_4000Coins"] = 0x417A9A, + ["gText_TmCase"] = 0x417DD9, + ["gText_ToTeachHerAGoodSaying"] = 0x418925, + ["gText_Toss"] = 0x41EE26, + ["gText_TossItem"] = 0x417721, + ["gText_TossOutHowManyStrVar1s"] = 0x4163DB, + ["gText_Tough"] = 0x417AD6, + ["gText_Tough_2"] = 0x417B0B, + ["gText_Toughness"] = 0x418C4F, + ["gText_Toxic"] = 0x417920, + ["gText_Trade"] = 0x41B51E, + ["gText_Trade4"] = 0x4169BC, + ["gText_TradeAction_Summary"] = 0x41E10A, + ["gText_TradeAction_Trade"] = 0x41E112, + ["gText_TradeCenter"] = 0x417D79, + ["gText_TradeHasBeenCanceled"] = 0x41E145, + ["gText_Trade_CommunicationStandby"] = 0x41E118, + ["gText_Trade_OnlyPkmnForBattle"] = 0x41E16B, + ["gText_TrainerCardIDNo"] = 0x419CE1, + ["gText_TrainerCardMoney"] = 0x419CE7, + ["gText_TrainerCardName"] = 0x419CDA, + ["gText_TrainerCardNull"] = 0x419CF7, + ["gText_TrainerCardPokedex"] = 0x419CEF, + ["gText_TrainerCardTime"] = 0x419CFD, + ["gText_TrainerCardYen"] = 0x419CED, + ["gText_TravelOnWater"] = 0x41755D, + ["gText_TryBattling"] = 0x3FDBEF, + ["gText_TurnOff"] = 0x4176E2, + ["gText_TurnedOverItemsWorthYen"] = 0x416959, + ["gText_TwoIsland"] = 0x417DFE, + ["gText_TwoPokemonAreNeeded"] = 0x4173CC, + ["gText_TypeMode"] = 0x415ED5, + ["gText_UPDOWNPick_ABUTTONBBUTTONCancel"] = 0x415D78, + ["gText_UPDOWNPick_ABUTTONNext_BBUTTONBack"] = 0x415D60, + ["gText_UR_AnOKWasSentToPlayer"] = 0x457554, + ["gText_UR_AreTheseMembersOK"] = 0x457514, + ["gText_UR_AskTrainerToMakeTrade"] = 0x458E70, + ["gText_UR_AwaitingCommunication"] = 0x457234, + ["gText_UR_AwaitingLinkPressStart"] = 0x457264, + ["gText_UR_AwaitingOtherMembers"] = 0x457700, + ["gText_UR_AwaitingPlayersResponse"] = 0x459238, + ["gText_UR_AwaitingPlayersResponseAboutTrade"] = 0x4578BC, + ["gText_UR_AwaitingResponseFromWirelessSystem"] = 0x45928C, + ["gText_UR_BButtonCancel"] = 0x45747C, + ["gText_UR_Battle"] = 0x459344, + ["gText_UR_BattleChallenge"] = 0x457CA4, + ["gText_UR_BerryCrush"] = 0x459410, + ["gText_UR_BerryPicking"] = 0x45941C, + ["gText_UR_CancelModeWithTheseMembers"] = 0x457530, + ["gText_UR_CancelRegistrationOfEgg"] = 0x458DE8, + ["gText_UR_CancelRegistrationOfMon"] = 0x458DBC, + ["gText_UR_Cards"] = 0x4593DC, + ["gText_UR_Chat"] = 0x4593D4, + ["gText_UR_Chat2"] = 0x45934C, + ["gText_UR_ChatDropped"] = 0x457E0C, + ["gText_UR_ChatEnded"] = 0x457E60, + ["gText_UR_ChatInvitation"] = 0x457CF8, + ["gText_UR_ChooseJoinCancel"] = 0x458FC8, + ["gText_UR_ChooseRequestedMonType"] = 0x458CD4, + ["gText_UR_ChooseTrainer"] = 0x458FE4, + ["gText_UR_Colon"] = 0x4571B0, + ["gText_UR_DontHaveEggTrainerWants"] = 0x458F04, + ["gText_UR_DontHaveTypeTrainerWants"] = 0x458ED0, + ["gText_UR_DoubleBattle"] = 0x4593A4, + ["gText_UR_EggTrade"] = 0x458FBC, + ["gText_UR_EmptyString"] = 0x4571AC, + ["gText_UR_Exit"] = 0x459360, + ["gText_UR_Exit2"] = 0x459368, + ["gText_UR_FinishedCheckingPlayersTrainerCard"] = 0x459588, + ["gText_UR_Greetings"] = 0x459354, + ["gText_UR_ID"] = 0x4571B4, + ["gText_UR_Info"] = 0x459370, + ["gText_UR_ItemTrade"] = 0x459440, + ["gText_UR_LinkWithFriendDropped"] = 0x4577F8, + ["gText_UR_ModeWithTheseMembersWillBeCanceled"] = 0x457610, + ["gText_UR_MultiBattle"] = 0x4593B4, + ["gText_UR_NameWantedOfferLv"] = 0x459378, + ["gText_UR_NeedTwoMonsOfLevel30OrLower1"] = 0x45847C, + ["gText_UR_NeedTwoMonsOfLevel30OrLower2"] = 0x4584C0, + ["gText_UR_OfferDeclined1"] = 0x457E28, + ["gText_UR_OfferDeclined2"] = 0x457E44, + ["gText_UR_OfferToTradeEgg"] = 0x457DB8, + ["gText_UR_OfferToTradeMon"] = 0x457D44, + ["gText_UR_PlayerContactedYouAddToMembers"] = 0x4574EC, + ["gText_UR_PlayerContactedYouForXAccept"] = 0x4574A0, + ["gText_UR_PlayerContactedYouShareX"] = 0x4574C4, + ["gText_UR_PlayerHasBeenAskedToRegisterYouPleaseWait"] = 0x459250, + ["gText_UR_PlayerOKdRegistration"] = 0x4576C4, + ["gText_UR_PlayerSentBackOK"] = 0x4576AC, + ["gText_UR_PleaseStartOver"] = 0x4571B8, + ["gText_UR_PokemonJump"] = 0x459400, + ["gText_UR_PokemonTrades"] = 0x4593C4, + ["gText_UR_QuitBeingMember"] = 0x45771C, + ["gText_UR_RegisterMonAtTradingBoard"] = 0x458AB8, + ["gText_UR_RegistraionCompleted"] = 0x458D78, + ["gText_UR_RegistrationCanceled"] = 0x458D54, + ["gText_UR_RegistrationCanceled2"] = 0x458E10, + ["gText_UR_Search"] = 0x45942C, + ["gText_UR_SearchingForWirelessSystemWait"] = 0x4591DC, + ["gText_UR_ShowTrainerCard"] = 0x457C48, + ["gText_UR_SingleBattle"] = 0x459394, + ["gText_UR_SpinTrade"] = 0x459434, + ["gText_UR_TradeCanceled"] = 0x458D9C, + ["gText_UR_TradeOfferRejected"] = 0x458F9C, + ["gText_UR_TradingBoardInfo"] = 0x458B44, + ["gText_UR_TrainerAppearsBusy"] = 0x457F90, + ["gText_UR_TrainerBattleBusy"] = 0x458434, + ["gText_UR_TrainerCardInfoPage1"] = 0x4594C4, + ["gText_UR_TrainerCardInfoPage2"] = 0x459504, + ["gText_UR_WhichMonWillYouOffer"] = 0x458D1C, + ["gText_UR_WirelessLinkDropped"] = 0x4577BC, + ["gText_UR_WirelessLinkEstablished"] = 0x45777C, + ["gText_UR_WirelessSearchCanceled"] = 0x4571E0, + ["gText_UR_WonderCards"] = 0x4593E4, + ["gText_UR_WonderNews"] = 0x4593F4, + ["gText_UR_XCheckedTradingBoard"] = 0x458A98, + ["gText_UnionRoom"] = 0x41809C, + ["gText_UnionRoomChatKeyboard_01234Lower"] = 0x47AB21, + ["gText_UnionRoomChatKeyboard_01234Upper"] = 0x47AAE5, + ["gText_UnionRoomChatKeyboard_56789Lower"] = 0x47AB27, + ["gText_UnionRoomChatKeyboard_56789Upper"] = 0x47AAEB, + ["gText_UnionRoomChatKeyboard_ABCDE"] = 0x47AAC1, + ["gText_UnionRoomChatKeyboard_Emoji1"] = 0x47AB41, + ["gText_UnionRoomChatKeyboard_Emoji10"] = 0x47ABA2, + ["gText_UnionRoomChatKeyboard_Emoji2"] = 0x47AB4C, + ["gText_UnionRoomChatKeyboard_Emoji3"] = 0x47AB57, + ["gText_UnionRoomChatKeyboard_Emoji4"] = 0x47AB62, + ["gText_UnionRoomChatKeyboard_Emoji5"] = 0x47AB6B, + ["gText_UnionRoomChatKeyboard_Emoji6"] = 0x47AB76, + ["gText_UnionRoomChatKeyboard_Emoji7"] = 0x47AB81, + ["gText_UnionRoomChatKeyboard_Emoji8"] = 0x47AB8C, + ["gText_UnionRoomChatKeyboard_Emoji9"] = 0x47AB97, + ["gText_UnionRoomChatKeyboard_FGHIJ"] = 0x47AAC7, + ["gText_UnionRoomChatKeyboard_KLMNO"] = 0x47AACD, + ["gText_UnionRoomChatKeyboard_PQRST"] = 0x47AAD3, + ["gText_UnionRoomChatKeyboard_PunctuationLower"] = 0x47AB2D, + ["gText_UnionRoomChatKeyboard_PunctuationUpper"] = 0x47AAF1, + ["gText_UnionRoomChatKeyboard_SymbolsLower"] = 0x47AB33, + ["gText_UnionRoomChatKeyboard_SymbolsUpper"] = 0x47AAF7, + ["gText_UnionRoomChatKeyboard_UVWXY"] = 0x47AAD9, + ["gText_UnionRoomChatKeyboard_Z"] = 0x47AADF, + ["gText_UnionRoomChatKeyboard_abcde"] = 0x47AAFD, + ["gText_UnionRoomChatKeyboard_fghij"] = 0x47AB03, + ["gText_UnionRoomChatKeyboard_klmno"] = 0x47AB09, + ["gText_UnionRoomChatKeyboard_pqrst"] = 0x47AB0F, + ["gText_UnionRoomChatKeyboard_uvwxy"] = 0x47AB15, + ["gText_UnionRoomChatKeyboard_z"] = 0x47AB1B, + ["gText_UnionRoomTradesBattles"] = 0x419D66, + ["gText_UnusedDashes"] = 0x41E09B, + ["gText_UnusedEmpty"] = 0x41E097, + ["gText_UnusedEmpty2"] = 0x41E0A4, + ["gText_UnusedId"] = 0x41E322, + ["gText_UnusedLv"] = 0x41E098, + ["gText_Unused_1F"] = 0x3FE9A9, + ["gText_Unused_2F"] = 0x3FE9AC, + ["gText_Unused_3F"] = 0x3FE9AF, + ["gText_Unused_4F"] = 0x3FE9B2, + ["gText_Unused_5F"] = 0x3FE9B5, + ["gText_Unused_6F"] = 0x3FE9B8, + ["gText_Unused_7F"] = 0x3FE9BB, + ["gText_Unused_8F"] = 0x3FE9BE, + ["gText_UpdatingSaveExternalData"] = 0x415B0E, + ["gText_UponLosingABattle"] = 0x418A61, + ["gText_UponWinningABattle"] = 0x418A4A, + ["gText_Upper"] = 0x41B33D, + ["gText_UseOnWhichPokemon"] = 0x417215, + ["gText_UsedVar2WildLured"] = 0x4165D2, + ["gText_UsedVar2WildRepelled"] = 0x416600, + ["gText_Vaporeon"] = 0x417BF8, + ["gText_Var1AndYouWantedVar2"] = 0x41678E, + ["gText_Var1CertainlyHowMany"] = 0x416766, + ["gText_Var1IsItThatllBeVar2"] = 0x4167BB, + ["gText_Var1IsSelected"] = 0x4162FF, + ["gText_Var1Percent"] = 0x41D0A4, + ["gText_Var1Players"] = 0x41D13C, + ["gText_Var1sTrainerCard"] = 0x419D0A, + ["gText_VarietyOfEventsImportedWireless"] = 0x41E5FD, + ["gText_Vermilion"] = 0x417E46, + ["gText_Victories"] = 0x4178FD, + ["gText_VolcanoBadge"] = 0x418022, + ["gText_Volcanobadge"] = 0x417B87, + ["gText_WT"] = 0x415F9B, + ["gText_WaitingForFriendToFinish"] = 0x41E199, + ["gText_WaitingTrainerFinishReading"] = 0x419D89, + ["gText_WantToPlayAgain"] = 0x41CDD7, + ["gText_WantToPlayAgain2"] = 0x41CBFD, + ["gText_WasEnhanced"] = 0x418C55, + ["gText_WasItWorriedAboutYou"] = 0x4183A0, + ["gText_WasUsedOn"] = 0x41B285, + ["gText_WelcomeToHOF"] = 0x416008, + ["gText_WhatDoYouWantToDo"] = 0x41821B, + ["gText_WhatToDoWithCards"] = 0x41EA86, + ["gText_WhatToDoWithNews"] = 0x41EAB7, + ["gText_WhatWillOldManDo"] = 0x3FE6FA, + ["gText_WhatWouldYouLikeToDo"] = 0x416262, + ["gText_WhatWouldYouLikeToDoWithPlayersMail"] = 0x417806, + ["gText_WhatYouSayIfYouLose"] = 0x41885F, + ["gText_WhatYouSayIfYouWin"] = 0x41883E, + ["gText_WhatsHipAndHappening"] = 0x418A87, + ["gText_WhereShouldCardBeAccessed"] = 0x41E717, + ["gText_WhereShouldNewsBeAccessed"] = 0x41E741, + ["gText_WhichMoveShouldBeForgotten"] = 0x41E50C, + ["gText_WhichMoveToForget"] = 0x416EA4, + ["gText_WhichMoveToForget_Unused"] = 0x3FE779, + ["gText_WhichOneWillYouTake"] = 0x418346, + ["gText_WillNotBeSaved"] = 0x418A1D, + ["gText_Win"] = 0x3FE874, + ["gText_WinEarnsPrizeMoney"] = 0x3FDD64, + ["gText_WinLossRatio"] = 0x419D4F, + ["gText_Wireless"] = 0x417DA0, + ["gText_WirelessCommunication"] = 0x41EDF6, + ["gText_WirelessCommunicationStatus"] = 0x41E245, + ["gText_WirelessNotConnected"] = 0x415AA4, + ["gText_WithFourPhrases"] = 0x418777, + ["gText_Withdraw"] = 0x4178B5, + ["gText_WithdrawHowMany"] = 0x4177AC, + ["gText_WithdrawItem"] = 0x4178A7, + ["gText_WithdrawItem2"] = 0x417713, + ["gText_WithdrawMonDescription"] = 0x4185AD, + ["gText_WithdrawPokemon"] = 0x41856C, + ["gText_WithdrewQuantItem"] = 0x4177C5, + ["gText_WonderCardReceived"] = 0x41E8BD, + ["gText_WonderCardReceivedFrom"] = 0x41E866, + ["gText_WonderCardSentTo"] = 0x41EB20, + ["gText_WonderCardThrownAway"] = 0x41ED7B, + ["gText_WonderCards"] = 0x41EDDD, + ["gText_WonderCardsInPossession"] = 0x41E63F, + ["gText_WonderNews"] = 0x41EDEA, + ["gText_WonderNewsReceived"] = 0x41E8E2, + ["gText_WonderNewsReceivedFrom"] = 0x41E88F, + ["gText_WonderNewsSentTo"] = 0x41EB46, + ["gText_WonderNewsThrownAway"] = 0x41ED9C, + ["gText_WontEatAnymore"] = 0x418C6F, + ["gText_WontHaveEffect"] = 0x4169DC, + ["gText_WouldYouLikeToSaveTheGame"] = 0x1C55C9, + ["gText_XDotY"] = 0x41D03C, + ["gText_XDotY2"] = 0x41D080, + ["gText_XDotY3"] = 0x41D090, + ["gText_XMinYZSec"] = 0x3FE998, + ["gText_XSentOverY"] = 0x41E20D, + ["gText_XWillBeSentToY"] = 0x41E1E9, + ["gText_YaySmileEmoji"] = 0x41B53B, + ["gText_YellowFlute_1600Coins"] = 0x417F2F, + ["gText_YellowShard"] = 0x417A0C, + ["gText_Yes"] = 0x417938, + ["gText_YesNo"] = 0x41623D, + ["gText_YouCantReleaseAnEgg"] = 0x41835F, + ["gText_YouDontHaveMoney"] = 0x416842, + ["gText_YouWantedVar1ThatllBeVar2"] = 0x4167D0, + ["gText_YourFeelingAtTheBattlesStart"] = 0x41881B, + ["gText_YourName"] = 0x418E47, + ["gText_YourPartysFull"] = 0x418319, + ["gText_YourProfile"] = 0x41880E, + ["gText_YoureHoldingAPkmn"] = 0x41832C, + ["gText_Zinc_1000"] = 0x418142, + ["gTradeText_Cancel"] = 0x41E0B9, + ["gTradeText_ChooseAPokemon"] = 0x41E0C0, + ["gTradeText_PressBButtonToExit"] = 0x41E0EE, + ["gTradeText_Summary"] = 0x41E0D2, + ["gTradeText_Trade"] = 0x41E0DA, + ["gUnusedText_MysteryGift"] = 0x415A8A, + ["sDmaOverErrorMsg"] = 0x231C48, + ["sDotSeparatedValues"] = 0x4570D1, + ["sJPText_Confirm"] = 0x3EE828, + ["sJPText_Iryoku"] = 0x3FF994, + ["sJPText_Meichuu"] = 0x3FF99A, + ["sJPText_PP"] = 0x3FF990, + ["sJPText_Taipu"] = 0x3FF98B, + ["sJPText_TatakauWaza"] = 0x3FF984, + ["sJPText_Turn"] = 0x250898, + ["sJPText_Vowels"] = 0x475630, + ["sJapaneseEggNickname"] = 0x25F83E, + ["sListItemTextColor_RegularItem"] = 0x452F60, + ["sListItemTextColor_TmCase_BerryPouch"] = 0x452F66, + ["sNewLineText"] = 0x25F83C, + ["sPluralTxt"] = 0x46D94C, + ["sString_Newline"] = 0x234615, + ["sText_100"] = 0x3FFABF, + ["sText_123Poof"] = 0x3FCC07, + ["sText_1PlayerNeeded"] = 0x4573B4, + ["sText_2PlayerMode"] = 0x4573F4, + ["sText_2PlayersNeeded"] = 0x4573C5, + ["sText_3PlayerMode"] = 0x457402, + ["sText_3PlayersNeeded"] = 0x4573D7, + ["sText_4PlayerMode"] = 0x457410, + ["sText_4PlayersNeeded"] = 0x4573E9, + ["sText_5PlayerMode"] = 0x45741E, + ["sText_ABoosted"] = 0x3FB283, + ["sText_AarghAlmostHadIt"] = 0x3FD757, + ["sText_Accuracy"] = 0x3FD5B8, + ["sText_Akito"] = 0x43EDF4, + ["sText_AllyPkmnPrefix"] = 0x3FD564, + ["sText_AllyPkmnPrefix2"] = 0x3FD56D, + ["sText_AllyPkmnPrefix3"] = 0x3FD576, + ["sText_AndEllipsis"] = 0x3FCC33, + ["sText_ApostropheS"] = 0x3FE88F, + ["sText_Attack"] = 0x3FE7D4, + ["sText_Attack2"] = 0x3FD593, + ["sText_AuroraTicket1"] = 0x489526, + ["sText_AuroraTicket2"] = 0x489583, + ["sText_AuroraTicketGot"] = 0x4895E8, + ["sText_AuroraTicketNoPlace"] = 0x489615, + ["sText_AwaitingCommunucation2"] = 0x45721C, + ["sText_AwaitingResponseCancelBButton"] = 0x457C28, + ["sText_AwaitingResponseFromPlayer"] = 0x457C00, + ["sText_AwaitingResponseFromTrainer"] = 0x457BD4, + ["sText_AwaitingResponseFromTrainer2"] = 0x458E9C, + ["sText_BattleDeclinedFemale"] = 0x4582C0, + ["sText_BattleDeclinedMale"] = 0x458260, + ["sText_BattleSurpriseFemale"] = 0x4586F8, + ["sText_BattleSurpriseMale"] = 0x458644, + ["sText_BattleWillBeStarted"] = 0x4581F8, + ["sText_BellChimed"] = 0x3FD068, + ["sText_BerrySuffix"] = 0x3FD8AF, + ["sText_Bills"] = 0x3FD824, + ["sText_BoxIsFull"] = 0x3FD876, + ["sText_ButItFailed"] = 0x3FCE08, + ["sText_ButNoEffect"] = 0x3FC65A, + ["sText_ButNoPPLeft"] = 0x3FD0CA, + ["sText_ButNothingHappened"] = 0x3FCDF2, + ["sText_CanceledReadingCard"] = 0x4595B0, + ["sText_CantEscape"] = 0x3FCDE2, + ["sText_CantEscape2"] = 0x3FB525, + ["sText_CantTradeForPartnersMon"] = 0x458F60, + ["sText_CantTransmitTrainerTooFar"] = 0x4575A4, + ["sText_CertifiesPokedexComplete"] = 0x4159B3, + ["sText_Chat"] = 0x4572E0, + ["sText_ChatDeclinedFemale"] = 0x458598, + ["sText_ChatDeclinedMale"] = 0x458550, + ["sText_ChooseLeaderBerryCrush"] = 0x459160, + ["sText_ChooseLeaderBerryPicking"] = 0x459188, + ["sText_ChooseLeaderMultiBattle"] = 0x459060, + ["sText_ChooseLeaderPokemonJump"] = 0x45912C, + ["sText_ChooseTrainerDoubleBattle"] = 0x459030, + ["sText_ChooseTrainerSingleBattle"] = 0x459000, + ["sText_ChooseTrainerToShareWonderCards"] = 0x4590BC, + ["sText_ChooseTrainerToShareWonderNews"] = 0x4590F4, + ["sText_ChooseTrainerToTradeWith"] = 0x459090, + ["sText_ChooseTrainerToTradeWith2"] = 0x458E54, + ["sText_Clear17"] = 0x43F8DB, + ["sText_ClearTo18"] = 0x463178, + ["sText_ClrWhtHltTranspShdwDrkGry"] = 0x261EB7, + ["sText_CoinsScattered"] = 0x3FD006, + ["sText_CollectedAllStamps"] = 0x4687A0, + ["sText_Communicating"] = 0x4578E4, + ["sText_CommunicatingPleaseWait"] = 0x4578A0, + ["sText_CommunicatingWithPlayer"] = 0x457950, + ["sText_CriticalHit"] = 0x3FCBE4, + ["sText_Dash"] = 0x3FE7C5, + ["sText_DeclineChatMale"] = 0x4584FC, + ["sText_Defense"] = 0x3FE7DC, + ["sText_Defense2"] = 0x3FD59A, + ["sText_Digits"] = 0x475640, + ["sText_DistMysteryEvent"] = 0x45730C, + ["sText_DistWonderCard"] = 0x4572F0, + ["sText_DistWonderNews"] = 0x4572FC, + ["sText_DoSomethingAgainFemale"] = 0x457B70, + ["sText_DoSomethingAgainMale"] = 0x457B4C, + ["sText_DoSomethingFemale"] = 0x457B2C, + ["sText_DoSomethingMale"] = 0x457B14, + ["sText_DoYouWantXMode"] = 0x457860, + ["sText_DoYouWantXMode2"] = 0x45787C, + ["sText_DoneWaitingBattleFemale"] = 0x458180, + ["sText_DoneWaitingBattleMale"] = 0x45812C, + ["sText_DoneWaitingChatFemale"] = 0x4581B0, + ["sText_DoneWaitingChatMale"] = 0x458164, + ["sText_DontBeAThief"] = 0x3FD6B5, + ["sText_DontLeaveBirch"] = 0x3FCDF1, + ["sText_DoubleBattle"] = 0x4572AC, + ["sText_DownpourContinues"] = 0x3FCE90, + ["sText_DownpourStarted"] = 0x3FCE64, + ["sText_Dummy"] = 0x261EB6, + ["sText_Dummy2"] = 0x261EC6, + ["sText_ElectricityWeakened"] = 0x3FC554, + ["sText_Empty"] = 0x4159FB, + ["sText_Empty1"] = 0x3FB218, + ["sText_EmptyString4"] = 0x3FB282, + ["sText_Enigma"] = 0x3FD8B6, + ["sText_EnigmaBerry"] = 0x3FD8A2, + ["sText_EnteringChat"] = 0x458218, + ["sText_EonTicket"] = 0x466EF7, + ["sText_Evasiveness"] = 0x3FD5C1, + ["sText_ExclamationMark"] = 0x3FD586, + ["sText_ExclamationMark2"] = 0x3FD588, + ["sText_ExclamationMark3"] = 0x3FD58A, + ["sText_ExclamationMark4"] = 0x3FD58C, + ["sText_ExclamationMark5"] = 0x3FD58E, + ["sText_FailedToSpitUp"] = 0x3FCF7E, + ["sText_FailedToSwallow"] = 0x3FCFA0, + ["sText_FaintInThree"] = 0x3FD077, + ["sText_FemaleTraded1"] = 0x458A24, + ["sText_FemaleTraded2"] = 0x458A44, + ["sText_FireWeakened"] = 0x3FC576, + ["sText_FoePkmnPrefix"] = 0x3FD55B, + ["sText_FoePkmnPrefix2"] = 0x3FD560, + ["sText_FoePkmnPrefix3"] = 0x3FD569, + ["sText_FoePkmnPrefix4"] = 0x3FD572, + ["sText_FullParty"] = 0x488FE3, + ["sText_GetOutGetOut"] = 0x3FCB26, + ["sText_GhostAppearedCantId"] = 0x3FD2D9, + ["sText_GladToMeetYouFemale"] = 0x459564, + ["sText_GladToMeetYouMale"] = 0x459544, + ["sText_GotAwaySafely"] = 0x3FCC8A, + ["sText_HMMovesCantBeForgotten"] = 0x3FCC39, + ["sText_HP2"] = 0x3FD590, + ["sText_HailContinues"] = 0x3FCF54, + ["sText_HailStopped"] = 0x3FCF6C, + ["sText_HealthboxNickname"] = 0x260556, + ["sText_HiDoSomethingAgainFemale"] = 0x457AD0, + ["sText_HiDoSomethingAgainMale"] = 0x457A98, + ["sText_HiDoSomethingFemale"] = 0x457A70, + ["sText_HiDoSomethingMale"] = 0x457A40, + ["sText_Hoenn"] = 0x4159A3, + ["sText_HoldBerryCrush"] = 0x457330, + ["sText_HoldBerryPicking"] = 0x457344, + ["sText_HoldPokemonJump"] = 0x457320, + ["sText_HoldSpinShop"] = 0x457368, + ["sText_HoldSpinTrade"] = 0x457354, + ["sText_IES"] = 0x3A72A2, + ["sText_IfYouWantToDoSomethingFemale"] = 0x4583F8, + ["sText_IfYouWantToDoSomethingMale"] = 0x4583BC, + ["sText_ItAppearedCaught"] = 0x3FD738, + ["sText_ItDodgedBall"] = 0x3FD6C7, + ["sText_ItDodgedBall2"] = 0x3FD78A, + ["sText_ItHurtConfusion"] = 0x3FCE17, + ["sText_ItIsRaining"] = 0x3FD850, + ["sText_ItSuckedLiquidOoze"] = 0x3FC9E5, + ["sText_ItemsCantBeUsedNow"] = 0x3FC5CC, + ["sText_ItsBronzeCard"] = 0x459460, + ["sText_ItsCopperCard"] = 0x459474, + ["sText_ItsGoldCard"] = 0x45949C, + ["sText_ItsNormalCard"] = 0x45944C, + ["sText_ItsSilverCard"] = 0x459488, + ["sText_JoinChatFemale"] = 0x457F00, + ["sText_JoinChatMale"] = 0x457E88, + ["sText_LearnedSomethingFemale"] = 0x458828, + ["sText_LearnedSomethingMale"] = 0x458778, + ["sText_Letters"] = 0x475638, + ["sText_MaleTraded1"] = 0x4589BC, + ["sText_MaleTraded2"] = 0x4589E4, + ["sText_MaxHP"] = 0x3FE7C7, + ["sText_MemberNoLongerAvailable"] = 0x457644, + ["sText_MirrorMoveFailed"] = 0x3FCE38, + ["sText_MultiBattle"] = 0x4572BC, + ["sText_MustHaveTwoMonsForDoubleBattle"] = 0x459214, + ["sText_MysteryGiftAlteringCave"] = 0x489887, + ["sText_MysteryGiftBattleCountCard"] = 0x489301, + ["sText_MysteryGiftBattleCountCard_2"] = 0x489419, + ["sText_MysteryGiftEgg"] = 0x488F56, + ["sText_MysteryGiftStampCard"] = 0x488E55, + ["sText_MysteryGiftVisitingTrainer"] = 0x489063, + ["sText_MysteryGiftVisitingTrainer_2"] = 0x4891B0, + ["sText_MysticTicket1"] = 0x48975C, + ["sText_MysticTicket2"] = 0x4896FF, + ["sText_MysticTicketGot"] = 0x4897C1, + ["sText_MysticTicketNoPlace"] = 0x4897EE, + ["sText_National"] = 0x4159AB, + ["sText_Newline"] = 0x261EC7, + ["sText_NoCardsSharedRightNow"] = 0x4592E8, + ["sText_NoNewsSharedRightNow"] = 0x459314, + ["sText_NoPPLeft"] = 0x3FD0A7, + ["sText_NoRunningFromTrainers"] = 0x3FCDB3, + ["sText_NobodyHasRegistered"] = 0x458CB4, + ["sText_NotRegisteredAMonForTrade"] = 0x458EB0, + ["sText_NotVeryEffective"] = 0x3FCC5B, + ["sText_OneHitKO"] = 0x3FCBF4, + ["sText_OtherTrainerUnavailableNow"] = 0x45756C, + ["sText_OutOfSafariBalls"] = 0x3FD24D, + ["sText_PkmnBrokeFree"] = 0x3FD718, + ["sText_PkmnHearingFluteAwoke"] = 0x3FDA8C, + ["sText_Player"] = 0x415994, + ["sText_PlayerCantTradeForYourMon"] = 0x458F2C, + ["sText_PlayerHasContactedYou"] = 0x457BB0, + ["sText_PlayerJoinChatFemale"] = 0x457F3C, + ["sText_PlayerJoinChatMale"] = 0x457EC0, + ["sText_PlayerRepliedNo"] = 0x4576EC, + ["sText_PlayerRepliedNo2"] = 0x457824, + ["sText_PleaseWaitAWhile"] = 0x4579BC, + ["sText_PleaseWaitForOtherTrainersToGather"] = 0x4592C8, + ["sText_Plus"] = 0x3FE7C2, + ["sText_PokeblockWasTooBitter"] = 0x3FD61B, + ["sText_PokeblockWasTooDry"] = 0x3FD5FF, + ["sText_PokeblockWasTooSour"] = 0x3FD62B, + ["sText_PokeblockWasTooSpicy"] = 0x3FD5F0, + ["sText_PokeblockWasTooSweet"] = 0x3FD60C, + ["sText_RainContinues"] = 0x3FCE78, + ["sText_RainStopped"] = 0x3FCEA8, + ["sText_RandomChatFemale1"] = 0x458868, + ["sText_RandomChatFemale2"] = 0x458898, + ["sText_RandomChatMale1"] = 0x4587D8, + ["sText_RandomChatMale2"] = 0x4587FC, + ["sText_RightArrow2"] = 0x3FE7BF, + ["sText_S"] = 0x3A72A0, + ["sText_Sama"] = 0x41599D, + ["sText_SandstormBrewed"] = 0x3FCEBA, + ["sText_SandstormIsRaging"] = 0x3FD85F, + ["sText_SandstormRages"] = 0x3FCECE, + ["sText_SandstormSubsided"] = 0x3FCEE3, + ["sText_SearchingForParticipants"] = 0x457488, + ["sText_SharedPain"] = 0x3FD048, + ["sText_ShedinjaJP"] = 0x262034, + ["sText_ShedinjaJapaneseName"] = 0x3F8440, + ["sText_ShedinjaJpnName"] = 0x24F008, + ["sText_ShootSoClose"] = 0x3FD76D, + ["sText_ShowTrainerCardDeclinedFemale"] = 0x45836C, + ["sText_ShowTrainerCardDeclinedMale"] = 0x45831C, + ["sText_ShowTrainerCardFemale"] = 0x4580C0, + ["sText_ShowTrainerCardMale"] = 0x45801C, + ["sText_ShowedTrainerCardFemale1"] = 0x45893C, + ["sText_ShowedTrainerCardFemale2"] = 0x45897C, + ["sText_ShowedTrainerCardMale1"] = 0x4588DC, + ["sText_ShowedTrainerCardMale2"] = 0x458918, + ["sText_SilphScopeUnveil"] = 0x3FD322, + ["sText_SingleBattle"] = 0x45729C, + ["sText_SingleSpace"] = 0x46317C, + ["sText_SomebodyHasContactedYou"] = 0x457B90, + ["sText_Someones"] = 0x3FD81A, + ["sText_SoothingAroma"] = 0x3FC5A2, + ["sText_SpAtk"] = 0x3FE7E4, + ["sText_SpAtk2"] = 0x3FD5A8, + ["sText_SpDef"] = 0x3FE7EC, + ["sText_SpDef2"] = 0x3FD5B0, + ["sText_Space"] = 0x4643B4, + ["sText_SpaceIs"] = 0x3FE88B, + ["sText_Speed"] = 0x3FD5A2, + ["sText_SpikesScattered"] = 0x3FC090, + ["sText_StartedHail"] = 0x3FCF40, + ["sText_StartedToRain"] = 0x3FCE50, + ["sText_StatChangesGone"] = 0x3FCFE4, + ["sText_StatFell"] = 0x3FCB59, + ["sText_StatHarshly"] = 0x3FCB50, + ["sText_StatSharply"] = 0x3FCB41, + ["sText_StoppedBeingMember"] = 0x457734, + ["sText_SunlightFaded"] = 0x3FCF2C, + ["sText_SunlightGotBright"] = 0x3FCEFB, + ["sText_SunlightStrong"] = 0x3FCF14, + ["sText_SuperEffective"] = 0x3FCC74, + ["sText_SwitchedMonsFemale"] = 0x458724, + ["sText_SwitchedMonsMale"] = 0x458668, + ["sText_TaskOver"] = 0x3BF52C, + ["sText_Test"] = 0x466EF3, + ["sText_ThankYouForRegistering"] = 0x458C80, + ["sText_ThatsFunnyFemale"] = 0x45884C, + ["sText_ThatsFunnyMale"] = 0x45879C, + ["sText_TheGhostAppeared"] = 0x3FD30D, + ["sText_TheGhostWas"] = 0x3FD34D, + ["sText_TheWallShattered"] = 0x3FC646, + ["sText_TooWeakForSubstitute"] = 0x3FD022, + ["sText_TradePokemon"] = 0x4572CC, + ["sText_TradeTrainersWillBeListed"] = 0x458E38, + ["sText_TradeWillBeStarted"] = 0x4581D8, + ["sText_TrainerAppearsUnavailable"] = 0x457684, + ["sText_TrainerBlockedBall"] = 0x3FD697, + ["sText_TrainersNotReadyYet"] = 0x4575DC, + ["sText_Underscore"] = 0x43F8D8, + ["sText_UseNextPkmn"] = 0x3FB359, + ["sText_UsedGoodMoveFemale"] = 0x4586D0, + ["sText_UsedGoodMoveMale"] = 0x458618, + ["sText_WaitForBattleFemale"] = 0x458054, + ["sText_WaitForBattleMale"] = 0x457FB4, + ["sText_WaitForChatFemale"] = 0x458088, + ["sText_WaitForChatMale"] = 0x457FE8, + ["sText_WaitForChatMale2"] = 0x458114, + ["sText_WildPkmnPrefix"] = 0x3FD555, + ["sText_WindBecameHeatWave"] = 0x3FCFC2, + ["sText_YouMissedPkmn"] = 0x3FD700, + ["sText_YouThrowABallNowRight"] = 0x3FDAB1, + ["sText_YoureToughFemale"] = 0x4586A0, + ["sText_YoureToughMale"] = 0x4585F0, + ["stext_DeclineChatFemale"] = 0x458528, + }, + battle = { + ["gBattleText_GetPumped"] = 0x3FBBE9, + ["gBattleText_MistShroud"] = 0x3FBBB4, + ["gText_DefendersStatRose"] = 0x3FCB6A, + ["gText_ForPetesSake"] = 0x3FDAE2, + ["gText_HowDissapointing"] = 0x3FDDEB, + ["gText_PkmnsXPreventsSwitching"] = 0x3FCA2C, + ["gText_WhatWillPkmnDo"] = 0x3FE6D5, + ["gText_WhatWillPlayerThrow"] = 0x3FE6E6, + ["sText_AttackMissed"] = 0x3FB36B, + ["sText_AttackerCantEscape"] = 0x3FB534, + ["sText_AttackerFainted"] = 0x3FB400, + ["sText_AttackerUsedX"] = 0x3FD57B, + ["sText_AttackersStatFell"] = 0x3FCB8F, + ["sText_AttackersStatRose"] = 0x3FCB5F, + ["sText_AvoidedDamage"] = 0x3FB394, + ["sText_DefendersStatFell"] = 0x3FCB9A, + ["sText_DidNotLearnMove"] = 0x3FB343, + ["sText_DoItPkmn"] = 0x3FD47D, + ["sText_EnemyAboutToSwitchPkmn"] = 0x3FD186, + ["sText_FoeStoppedWorking"] = 0x3FB826, + ["sText_ForXCommaYZ"] = 0x3FC5E8, + ["sText_GiveNicknameCaptured"] = 0x3FD7E0, + ["sText_GoForItPkmn"] = 0x3FD488, + ["sText_GoPkmn"] = 0x3FD45E, + ["sText_GoPkmn2"] = 0x3FD475, + ["sText_GoTwoPkmn"] = 0x3FD466, + ["sText_GotchaPkmnCaught"] = 0x3FD7A2, + ["sText_GotchaPkmnCaught2"] = 0x3FD7C0, + ["sText_HitXTimes"] = 0x3FB545, + ["sText_ItDoesntAffect"] = 0x3FB3EA, + ["sText_ItemAllowsOnlyYMove"] = 0x3FDA0B, + ["sText_LinkPartnerSentOutPkmnGoPkmn"] = 0x3FD4B5, + ["sText_LinkTrainer1WithdrewPkmn"] = 0x3FD535, + ["sText_LinkTrainer2WithdrewPkmn"] = 0x3FD545, + ["sText_LinkTrainerMultiSentOutPkmn"] = 0x3FD44E, + ["sText_LinkTrainerSentOutPkmn"] = 0x3FD3F7, + ["sText_LinkTrainerSentOutPkmn2"] = 0x3FD43E, + ["sText_LinkTrainerSentOutTwoPkmn"] = 0x3FD407, + ["sText_LinkTrainerWantsToBattle"] = 0x3FD383, + ["sText_MagnitudeStrength"] = 0x3FC132, + ["sText_NaturePowerTurnedInto"] = 0x3FC26D, + ["sText_OldManUsedItem"] = 0x3FD65C, + ["sText_PkmnAbsorbedNutrients"] = 0x3FC3C5, + ["sText_PkmnAfflictedByCurse"] = 0x3FC072, + ["sText_PkmnAlreadyAsleep"] = 0x3FB57C, + ["sText_PkmnAlreadyAsleep2"] = 0x3FB592, + ["sText_PkmnAlreadyConfused"] = 0x3FB885, + ["sText_PkmnAlreadyHasBurn"] = 0x3FB67B, + ["sText_PkmnAlreadyPoisoned"] = 0x3FB5F8, + ["sText_PkmnAnchoredItself"] = 0x3FC3EB, + ["sText_PkmnAnchorsItselfWith"] = 0x3FC860, + ["sText_PkmnAttack"] = 0x3FC224, + ["sText_PkmnAvoidedAttack"] = 0x3FB3D3, + ["sText_PkmnBadlyPoisoned"] = 0x3FB610, + ["sText_PkmnBeganToNap"] = 0x3FD124, + ["sText_PkmnBlewAwaySpikes"] = 0x3FC1B1, + ["sText_PkmnBlownAway"] = 0x3FB8E0, + ["sText_PkmnBracedItself"] = 0x3FC10C, + ["sText_PkmnBuffetedBySandstorm"] = 0x3FBC2B, + ["sText_PkmnBurnedBy"] = 0x3FB650, + ["sText_PkmnCalmedDown"] = 0x3FBD43, + ["sText_PkmnCantSleepInUproar"] = 0x3FBD53, + ["sText_PkmnCantSleepInUproar2"] = 0x3FBDA3, + ["sText_PkmnCantStockpile"] = 0x3FBD86, + ["sText_PkmnCantUseMoveSealed"] = 0x3FC6F8, + ["sText_PkmnCantUseMoveTaunt"] = 0x3FC6D6, + ["sText_PkmnCantUseMoveTorment"] = 0x3FC69D, + ["sText_PkmnCausedUproar"] = 0x3FBD16, + ["sText_PkmnCenterAttention"] = 0x3FC231, + ["sText_PkmnChangedType"] = 0x3FB8F3, + ["sText_PkmnChangedTypeWith"] = 0x3FC7B6, + ["sText_PkmnChargingPower"] = 0x3FC254, + ["sText_PkmnChoseXAsDestiny"] = 0x3FC208, + ["sText_PkmnClamped"] = 0x3FBB62, + ["sText_PkmnComeBack"] = 0x3FD4EB, + ["sText_PkmnCopiedFoe"] = 0x3FC377, + ["sText_PkmnCopiedStatChanges"] = 0x3FC168, + ["sText_PkmnCoveredByVeil"] = 0x3FB9B8, + ["sText_PkmnCrashed"] = 0x3FBB99, + ["sText_PkmnCutHPMaxedAttack"] = 0x3FC140, + ["sText_PkmnCutsAttackWith"] = 0x3FC87B, + ["sText_PkmnDataAddedToDex"] = 0x3FD82B, + ["sText_PkmnDreamEaten"] = 0x3FB7BF, + ["sText_PkmnDugHole"] = 0x3FBAC3, + ["sText_PkmnEncoreEnded"] = 0x3FBF63, + ["sText_PkmnEnduredHit"] = 0x3FC11E, + ["sText_PkmnEnergyDrained"] = 0x3FB626, + ["sText_PkmnEvadedAttack"] = 0x3FBC83, + ["sText_PkmnFastAsleep"] = 0x3FBCBE, + ["sText_PkmnFatigueConfusion"] = 0x3FBE2B, + ["sText_PkmnFellAsleep"] = 0x3FB555, + ["sText_PkmnFellForTaunt"] = 0x3FC2E3, + ["sText_PkmnFellInLove"] = 0x3FB89D, + ["sText_PkmnFellIntoNightmare"] = 0x3FC011, + ["sText_PkmnFledFromBattle"] = 0x3FC1C6, + ["sText_PkmnFledUsing"] = 0x3FCCBB, + ["sText_PkmnFledUsingIts"] = 0x3FCCA0, + ["sText_PkmnFlewHigh"] = 0x3FBAB2, + ["sText_PkmnFlinched"] = 0x3FB914, + ["sText_PkmnForesawAttack"] = 0x3FC1DB, + ["sText_PkmnForgotMove"] = 0x3FB31F, + ["sText_PkmnFreedFrom"] = 0x3FBB83, + ["sText_PkmnFrozenBy"] = 0x3FB6A7, + ["sText_PkmnGainedEXP"] = 0x3FB265, + ["sText_PkmnGoodComeBack"] = 0x3FD50D, + ["sText_PkmnGotEncore"] = 0x3FBF51, + ["sText_PkmnGotFree"] = 0x3FC185, + ["sText_PkmnGrewToLv"] = 0x3FB28E, + ["sText_PkmnHPFull"] = 0x3FB935, + ["sText_PkmnHasNoMovesLeft"] = 0x3FC670, + ["sText_PkmnHasSubstitute"] = 0x3FBEA1, + ["sText_PkmnHealedConfusion"] = 0x3FB854, + ["sText_PkmnHealedParalysis"] = 0x3FB7A3, + ["sText_PkmnHidUnderwater"] = 0x3FBAD2, + ["sText_PkmnHitWithRecoil"] = 0x3FBBFF, + ["sText_PkmnHungOnWithX"] = 0x3FDA33, + ["sText_PkmnHurtBy"] = 0x3FBB71, + ["sText_PkmnHurtByBurn"] = 0x3FB663, + ["sText_PkmnHurtByPoison"] = 0x3FB5E2, + ["sText_PkmnHurtBySpikes"] = 0x3FC0C6, + ["sText_PkmnHurtsWith"] = 0x3FC8B1, + ["sText_PkmnIdentified"] = 0x3FC0DC, + ["sText_PkmnIgnoredOrders"] = 0x3FD111, + ["sText_PkmnIgnoresAsleep"] = 0x3FD0F1, + ["sText_PkmnImmobilizedByLove"] = 0x3FB8C5, + ["sText_PkmnInLove"] = 0x3FB8AE, + ["sText_PkmnIsAlreadyParalyzed"] = 0x3FB78A, + ["sText_PkmnIsAngry"] = 0x3FD232, + ["sText_PkmnIsConfused"] = 0x3FB844, + ["sText_PkmnIsEating"] = 0x3FD23F, + ["sText_PkmnIsFrozen"] = 0x3FB6BF, + ["sText_PkmnIsGlowing"] = 0x3FBAA3, + ["sText_PkmnIsParalyzed"] = 0x3FB76A, + ["sText_PkmnKnockedOff"] = 0x3FC421, + ["sText_PkmnLaidCurse"] = 0x3FC048, + ["sText_PkmnLearnedMove"] = 0x3FB2A4, + ["sText_PkmnLearnedMove2"] = 0x3FD1B8, + ["sText_PkmnLoafing"] = 0x3FD135, + ["sText_PkmnLockedInNightmare"] = 0x3FC02B, + ["sText_PkmnLostFocus"] = 0x3FC60C, + ["sText_PkmnLostPPGrudge"] = 0x3FC4AB, + ["sText_PkmnLoweredHead"] = 0x3FBA8E, + ["sText_PkmnMadeItRain"] = 0x3FC715, + ["sText_PkmnMadeSleep"] = 0x3FB565, + ["sText_PkmnMadeSubstitute"] = 0x3FBE8B, + ["sText_PkmnMadeWish"] = 0x3FC38A, + ["sText_PkmnMakesGroundMiss"] = 0x3FB3AF, + ["sText_PkmnMakingUproar"] = 0x3FBD2B, + ["sText_PkmnMoveBounced"] = 0x3FC4F0, + ["sText_PkmnMoveDisabledNoMore"] = 0x3FBF39, + ["sText_PkmnMoveIsDisabled"] = 0x3FC687, + ["sText_PkmnMoveWasDisabled"] = 0x3FBF23, + ["sText_PkmnMustRecharge"] = 0x3FBEFA, + ["sText_PkmnObtainedX"] = 0x3FC337, + ["sText_PkmnObtainedX2"] = 0x3FC347, + ["sText_PkmnObtainedXYObtainedZ"] = 0x3FC357, + ["sText_PkmnOkComeBack"] = 0x3FD4FA, + ["sText_PkmnPeltedByHail"] = 0x3FBC4C, + ["sText_PkmnPerishCountFell"] = 0x3FC0EE, + ["sText_PkmnPickedUpItem"] = 0x3FBE4E, + ["sText_PkmnPlantedRoots"] = 0x3FC3AF, + ["sText_PkmnPoisonedBy"] = 0x3FB5CD, + ["sText_PkmnPretendNotNotice"] = 0x3FD16A, + ["sText_PkmnPreventsConfusionWith"] = 0x3FC825, + ["sText_PkmnPreventsParalysisWith"] = 0x3FC7D3, + ["sText_PkmnPreventsPoisoningWith"] = 0x3FC809, + ["sText_PkmnPreventsRomanceWith"] = 0x3FC7EF, + ["sText_PkmnPreventsStatLossWith"] = 0x3FC895, + ["sText_PkmnPreventsUsage"] = 0x3FC75D, + ["sText_PkmnProtectedBy"] = 0x3FC745, + ["sText_PkmnProtectedByMist"] = 0x3FBBD0, + ["sText_PkmnProtectedItself"] = 0x3FB37F, + ["sText_PkmnProtectedItself2"] = 0x3FBC16, + ["sText_PkmnRageBuilding"] = 0x3FBF0C, + ["sText_PkmnRaisedDef"] = 0x3FB97F, + ["sText_PkmnRaisedDefALittle"] = 0x3FB997, + ["sText_PkmnRaisedFirePowerWith"] = 0x3FC841, + ["sText_PkmnRaisedSpDef"] = 0x3FB946, + ["sText_PkmnRaisedSpDefALittle"] = 0x3FB95E, + ["sText_PkmnRaisedSpeed"] = 0x3FC72B, + ["sText_PkmnReadyToHelp"] = 0x3FC2FA, + ["sText_PkmnReducedPP"] = 0x3FBFD3, + ["sText_PkmnRegainedHealth"] = 0x3FB921, + ["sText_PkmnRestoredHPUsing"] = 0x3FC780, + ["sText_PkmnSafeguardExpired"] = 0x3FB9FF, + ["sText_PkmnSappedByLeechSeed"] = 0x3FBC99, + ["sText_PkmnSealedOpponentMove"] = 0x3FC461, + ["sText_PkmnSeeded"] = 0x3FBC74, + ["sText_PkmnSentToPC"] = 0x3FD804, + ["sText_PkmnShedLeechSeed"] = 0x3FC19D, + ["sText_PkmnShroudedItself"] = 0x3FC4D6, + ["sText_PkmnSketchedMove"] = 0x3FBF89, + ["sText_PkmnSleptHealthy"] = 0x3FBA41, + ["sText_PkmnSnatchedMove"] = 0x3FC53D, + ["sText_PkmnSprangUp"] = 0x3FBAE5, + ["sText_PkmnSqueezedByBind"] = 0x3FBAF3, + ["sText_PkmnStatusNormal"] = 0x3FC28A, + ["sText_PkmnStayedAwakeUsing"] = 0x3FBDE2, + ["sText_PkmnStockpiled"] = 0x3FBD74, + ["sText_PkmnStoleItem"] = 0x3FBFEA, + ["sText_PkmnStoringEnergy"] = 0x3FBE00, + ["sText_PkmnSubjectedToTorment"] = 0x3FC2AA, + ["sText_PkmnSubstituteFaded"] = 0x3FBEE2, + ["sText_PkmnSwappedAbilities"] = 0x3FC439, + ["sText_PkmnSwitchedItems"] = 0x3FC312, + ["sText_PkmnThatsEnough"] = 0x3FD4CD, + ["sText_PkmnTighteningFocus"] = 0x3FC2C7, + ["sText_PkmnTookAim"] = 0x3FBF76, + ["sText_PkmnTookAttack"] = 0x3FC1F1, + ["sText_PkmnTookFoe"] = 0x3FBFBF, + ["sText_PkmnTookSunlight"] = 0x3FBA79, + ["sText_PkmnTraced"] = 0x3FC8C2, + ["sText_PkmnTransformed"] = 0x3FCA03, + ["sText_PkmnTransformedInto"] = 0x3FBE73, + ["sText_PkmnTrappedBySandTomb"] = 0x3FBB2F, + ["sText_PkmnTrappedInVortex"] = 0x3FBB11, + ["sText_PkmnTryingToTakeFoe"] = 0x3FBF99, + ["sText_PkmnTurnedAway"] = 0x3FD15A, + ["sText_PkmnUnaffected"] = 0x3FBE61, + ["sText_PkmnUnleashedEnergy"] = 0x3FBE16, + ["sText_PkmnUproarKeptAwake"] = 0x3FBCDD, + ["sText_PkmnUsedSafeguard"] = 0x3FB9D9, + ["sText_PkmnUsedXToGetPumped"] = 0x3FC5F6, + ["sText_PkmnWaitsForTarget"] = 0x3FC518, + ["sText_PkmnWantsGrudge"] = 0x3FC483, + ["sText_PkmnWasBurned"] = 0x3FB641, + ["sText_PkmnWasConfused"] = 0x3FB871, + ["sText_PkmnWasDefrosted"] = 0x3FB6D3, + ["sText_PkmnWasDefrosted2"] = 0x3FB6E5, + ["sText_PkmnWasDefrostedBy"] = 0x3FB6F7, + ["sText_PkmnWasDraggedOut"] = 0x3FC631, + ["sText_PkmnWasFrozen"] = 0x3FB692, + ["sText_PkmnWasMadeDrowsy"] = 0x3FC40E, + ["sText_PkmnWasParalyzed"] = 0x3FB70F, + ["sText_PkmnWasParalyzedBy"] = 0x3FB73A, + ["sText_PkmnWasPoisoned"] = 0x3FB5BC, + ["sText_PkmnWasntAffected"] = 0x3FB5A8, + ["sText_PkmnWatchingCarefully"] = 0x3FD218, + ["sText_PkmnWentToSleep"] = 0x3FBA2F, + ["sText_PkmnWhippedWhirlwind"] = 0x3FBA5E, + ["sText_PkmnWishCameTrue"] = 0x3FC39A, + ["sText_PkmnWokeUp"] = 0x3FBCD1, + ["sText_PkmnWokeUpInUproar"] = 0x3FBCFC, + ["sText_PkmnWontObey"] = 0x3FD14B, + ["sText_PkmnWrappedBy"] = 0x3FBB4C, + ["sText_PkmnsItemCuredParalysis"] = 0x3FD8B9, + ["sText_PkmnsItemCuredPoison"] = 0x3FD8D2, + ["sText_PkmnsItemCuredProblem"] = 0x3FD95C, + ["sText_PkmnsItemDefrostedIt"] = 0x3FD901, + ["sText_PkmnsItemHealedBurn"] = 0x3FD8E8, + ["sText_PkmnsItemNormalizedStatus"] = 0x3FD97A, + ["sText_PkmnsItemRestoredHPALittle"] = 0x3FD9E9, + ["sText_PkmnsItemRestoredHealth"] = 0x3FD999, + ["sText_PkmnsItemRestoredPP"] = 0x3FD9B2, + ["sText_PkmnsItemRestoredStatus"] = 0x3FD9CC, + ["sText_PkmnsItemSnappedOut"] = 0x3FD937, + ["sText_PkmnsItemWokeIt"] = 0x3FD917, + ["sText_PkmnsXBlocksY"] = 0x3FC8ED, + ["sText_PkmnsXBlocksY2"] = 0x3FC900, + ["sText_PkmnsXCuredItsYProblem"] = 0x3FCAD1, + ["sText_PkmnsXCuredYProblem"] = 0x3FC9C7, + ["sText_PkmnsXHadNoEffectOnY"] = 0x3FCAEF, + ["sText_PkmnsXInfatuatedY"] = 0x3FC993, + ["sText_PkmnsXIntensifiedSun"] = 0x3FC955, + ["sText_PkmnsXMadeItIneffective"] = 0x3FCA71, + ["sText_PkmnsXMadeYIneffective"] = 0x3FC9AA, + ["sText_PkmnsXMadeYUseless"] = 0x3FC79D, + ["sText_PkmnsXPreventsBurns"] = 0x3FC8D5, + ["sText_PkmnsXPreventsFlinching"] = 0x3FCA8E, + ["sText_PkmnsXPreventsYLoss"] = 0x3FC979, + ["sText_PkmnsXPreventsYsZ"] = 0x3FCAAA, + ["sText_PkmnsXRestoredHPALittle2"] = 0x3FC913, + ["sText_PkmnsXTookAttack"] = 0x3FCA13, + ["sText_PkmnsXWhippedUpSandstorm"] = 0x3FC935, + ["sText_PkmnsXWoreOff"] = 0x3FBC62, + ["sText_PlayedFluteCatchyTune"] = 0x3FDA4D, + ["sText_PlayedThe"] = 0x3FDA7A, + ["sText_PlayerBattledToDrawLinkTrainer"] = 0x3FCD41, + ["sText_PlayerBattledToDrawTrainer1"] = 0x3FE9FF, + ["sText_PlayerBattledToDrawVsTwo"] = 0x3FCD66, + ["sText_PlayerDefeatedLinkTrainer"] = 0x3FCCE4, + ["sText_PlayerDefeatedLinkTrainerTrainer1"] = 0x3FD1C7, + ["sText_PlayerGotMoney"] = 0x3FB41A, + ["sText_PlayerLostAgainstLinkTrainer"] = 0x3FCD0F, + ["sText_PlayerLostAgainstTrainer1"] = 0x3FE9E4, + ["sText_PlayerLostToTwo"] = 0x3FCD27, + ["sText_PlayerPaidAsPrizeMoney"] = 0x3FB4BE, + ["sText_PlayerPanicked"] = 0x3FB451, + ["sText_PlayerUsedItem"] = 0x3FD650, + ["sText_PlayerWhiteout"] = 0x3FB433, + ["sText_PlayerWhiteout2"] = 0x3FB4F7, + ["sText_PlayerWhiteoutAgainstTrainer"] = 0x3FB484, + ["sText_PokedudeUsedItem"] = 0x3FD671, + ["sText_PreventedFromWorking"] = 0x3FCA49, + ["sText_PreventsEscape"] = 0x3FB508, + ["sText_StatsWontDecrease"] = 0x3FB7EE, + ["sText_StatsWontDecrease2"] = 0x3FCBC5, + ["sText_StatsWontIncrease"] = 0x3FB7D5, + ["sText_StatsWontIncrease2"] = 0x3FCBA5, + ["sText_StopLearningMove"] = 0x3FB32E, + ["sText_SubstituteDamaged"] = 0x3FBEBE, + ["sText_TargetCantEscapeNow"] = 0x3FBFFC, + ["sText_TargetFainted"] = 0x3FB40D, + ["sText_TeamStoppedWorking"] = 0x3FB806, + ["sText_ThrewARock"] = 0x3FD1DF, + ["sText_ThrewSomeBait"] = 0x3FD1FA, + ["sText_TooScaredToMove"] = 0x3FCB0C, + ["sText_Trainer1Fled"] = 0x3FE9D4, + ["sText_Trainer1LoseText"] = 0x3FB219, + ["sText_Trainer1RecallBoth"] = 0x3FB248, + ["sText_Trainer1RecallPkmn1"] = 0x3FB21F, + ["sText_Trainer1RecallPkmn2"] = 0x3FB235, + ["sText_Trainer1SentOutPkmn"] = 0x3FD3B1, + ["sText_Trainer1SentOutPkmn2"] = 0x3FD3E4, + ["sText_Trainer1SentOutTwoPkmn"] = 0x3FD3C7, + ["sText_Trainer1UsedItem"] = 0x3FD688, + ["sText_Trainer1WantsToBattle"] = 0x3FD366, + ["sText_Trainer1WinText"] = 0x3FB232, + ["sText_Trainer1WithdrewPkmn"] = 0x3FD522, + ["sText_Trainer2LoseText"] = 0x3FB21C, + ["sText_Trainer2WinText"] = 0x3FB262, + ["sText_TryToLearnMove1"] = 0x3FB2B6, + ["sText_TryToLearnMove2"] = 0x3FB2D1, + ["sText_TryToLearnMove3"] = 0x3FB2FC, + ["sText_TwoLinkTrainersDefeated"] = 0x3FCCF8, + ["sText_TwoLinkTrainersSentOutPkmn"] = 0x3FD41E, + ["sText_TwoLinkTrainersWantToBattle"] = 0x3FD397, + ["sText_TwoWildFled"] = 0x3FCD9F, + ["sText_TwoWildPkmnAppeared"] = 0x3FD2BF, + ["sText_UproarKeptPkmnAwake"] = 0x3FBDC4, + ["sText_UsingItemTheStatOfPkmnRose"] = 0x3FCB75, + ["sText_WildFled"] = 0x3FCD92, + ["sText_WildPkmnAppeared"] = 0x3FD284, + ["sText_WildPkmnAppeared2"] = 0x3FD297, + ["sText_WildPkmnAppearedPause"] = 0x3FD2AA, + ["sText_WildPkmnFled"] = 0x3FCCD2, + ["sText_XFoundOneY"] = 0x3FC591, + ["sText_YourFoesWeakGetEmPkmn"] = 0x3FD497, + }, +} diff --git a/src/mods/rom_text_labels/gold.lua b/src/mods/rom_text_labels/gold.lua new file mode 100644 index 000000000..42626f815 --- /dev/null +++ b/src/mods/rom_text_labels/gold.lua @@ -0,0 +1,894 @@ +-- Generated label/address facts for gold; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + ["AlreadyAsleepText"] = true, + ["AlreadyConfusedText"] = true, + ["AlreadyParalyzedText"] = true, + ["AlreadyPoisonedText"] = true, + ["AskNumber1FText"] = true, + ["AskNumber1MText"] = true, + ["AskNumber2FText"] = true, + ["AskNumber2MText"] = true, + ["AttackMissed2Text"] = true, + ["AttackMissedText"] = true, + ["BadlyPoisonedText"] = true, + ["BattleText_AnEGGCantBattle"] = true, + ["BattleText_CantEscape"] = true, + ["BattleText_CantEscape2"] = true, + ["BattleText_EnemyFled"] = true, + ["BattleText_EnemyIsAboutToUseWillPlayerChangeMon"] = true, + ["BattleText_EnemyMonFainted"] = true, + ["BattleText_EnemySentOut"] = true, + ["BattleText_EnemyWasDefeated"] = true, + ["BattleText_GotAwaySafely"] = true, + ["BattleText_ItemHealedConfusion"] = true, + ["BattleText_ItemsCantBeUsedHere"] = true, + ["BattleText_MonCantBeRecalled"] = true, + ["BattleText_MonFainted"] = true, + ["BattleText_MonHasNoMovesLeft"] = true, + ["BattleText_MonIsAlreadyOut"] = true, + ["BattleText_MonsLightScreenFell"] = true, + ["BattleText_MonsReflectFaded"] = true, + ["BattleText_PlayerPickedUpPayDayMoney"] = true, + ["BattleText_RainContinuesToFall"] = true, + ["BattleText_SafeguardFaded"] = true, + ["BattleText_StringBuffer1GrewToLevel"] = true, + ["BattleText_TargetRecoveredWithItem"] = true, + ["BattleText_TargetWasHitByFutureSight"] = true, + ["BattleText_TargetsEncoreEnded"] = true, + ["BattleText_TheMoveIsDisabled"] = true, + ["BattleText_TheRainStopped"] = true, + ["BattleText_TheSandstormRages"] = true, + ["BattleText_TheSandstormSubsided"] = true, + ["BattleText_TheSunlightFaded"] = true, + ["BattleText_TheSunlightIsStrong"] = true, + ["BattleText_TheresNoEscapeFromTrainerBattle"] = true, + ["BattleText_TheresNoPPLeftForThisMove"] = true, + ["BattleText_TheresNoWillToBattle"] = true, + ["BattleText_UseNextMon"] = true, + ["BattleText_UserFledUsingAStringBuffer1"] = true, + ["BattleText_UserHurtBySpikes"] = true, + ["BattleText_UserRecoveredPPUsing"] = true, + ["BattleText_UserWasReleasedFromStringBuffer1"] = true, + ["BattleText_UsersHurtByStringBuffer1"] = true, + ["BattleText_UsersStringBuffer1Activated"] = true, + ["BattleText_WildFled"] = true, + ["BattleText_WildMonIsAngry"] = true, + ["BattleText_WildMonIsEating"] = true, + ["BeatUpAttackText"] = true, + ["BecameConfusedText"] = true, + ["BeganToNapText"] = true, + ["BellChimedText"] = true, + ["BellyDrumText"] = true, + ["BlewSpikesText"] = true, + ["BlownAwayText"] = true, + ["BracedItselfText"] = true, + ["BugContestPrizeNoRoomText"] = true, + ["ButItFailedText"] = true, + ["CantEscapeNowText"] = true, + ["ClampedByText"] = true, + ["CoinVendor_Buy500CoinsText"] = true, + ["CoinVendor_Buy50CoinsText"] = true, + ["CoinVendor_CancelText"] = true, + ["CoinVendor_CoinCaseFullText"] = true, + ["CoinVendor_IntroText"] = true, + ["CoinVendor_NoCoinCaseText"] = true, + ["CoinVendor_NotEnoughMoneyText"] = true, + ["CoinVendor_WelcomeText"] = true, + ["CoinsScatteredText"] = true, + ["ConfusedNoMoreText"] = true, + ["ContestResults_ConsolationPrizeText"] = true, + ["ContestResults_DidNotWinText"] = true, + ["ContestResults_JoinUsNextTimeText"] = true, + ["ContestResults_PartyFullText"] = true, + ["ContestResults_PlayerWonAPrizeText"] = true, + ["ContestResults_ReadyToJudgeText"] = true, + ["ContestResults_ReturnPartyText"] = true, + ["CopiedStatsText"] = true, + ["CoveredByVeilText"] = true, + ["CrashedText"] = true, + ["CriticalHitText"] = true, + ["DefrostedOpponentText"] = true, + ["DestinyBondEffectText"] = true, + ["DidntAffect1Text"] = true, + ["DidntAffect2Text"] = true, + ["DifficultBookshelfText"] = true, + ["DisabledMoveText"] = true, + ["DisabledNoMoreText"] = true, + ["DoesntAffectText"] = true, + ["DownpourText"] = true, + ["DraggedOutText"] = true, + ["DreamEatenText"] = true, + ["EliminatedStatsText"] = true, + ["EnduredText"] = true, + ["EnemyHitTimesText"] = true, + ["EvadedText"] = true, + ["FastAsleepText"] = true, + ["FellAsleepText"] = true, + ["FellInLoveText"] = true, + ["FledFromBattleText"] = true, + ["FledInFearText"] = true, + ["FlinchedText"] = true, + ["ForesawAttackText"] = true, + ["FrozenSolidText"] = true, + ["FullyParalyzedText"] = true, + ["GettingPumpedText"] = true, + ["GotAnEncoreText"] = true, + ["GotMoneyForWinningText"] = true, + ["GymStatue_CityGymText"] = true, + ["GymStatue_WinningTrainersText"] = true, + ["HPIsFullText"] = true, + ["HappinessText1"] = true, + ["HappinessText2"] = true, + ["HappinessText3"] = true, + ["HasANightmareText"] = true, + ["HasNoPPLeftText"] = true, + ["HasSubstituteText"] = true, + ["HomepageText"] = true, + ["HookedPokemonAttackedText"] = true, + ["HungOnText"] = true, + ["HurtByBurnText"] = true, + ["HurtByCurseText"] = true, + ["HurtByPoisonText"] = true, + ["HurtItselfText"] = true, + ["IdentifiedText"] = true, + ["IgnoredOrders2Text"] = true, + ["IgnoredOrdersText"] = true, + ["IgnoredSleepingText"] = true, + ["InLoveWithText"] = true, + ["IncenseBurnerText"] = true, + ["InfatuationText"] = true, + ["IsConfusedText"] = true, + ["ItFailedText"] = true, + ["LeechSeedSapsText"] = true, + ["LightScreenEffectText"] = true, + ["LoafingAroundText"] = true, + ["LookTownMapText"] = true, + ["LostAgainstText"] = true, + ["MadeSubstituteText"] = true, + ["MagazineBookshelfText"] = true, + ["MagnitudeText"] = true, + ["MartSignText"] = true, + ["MerchandiseShelfText"] = true, + ["MimicLearnedMoveText"] = true, + ["MirrorMoveFailedText"] = true, + ["MistText"] = true, + ["MustRechargeText"] = true, + ["NoPPLeftText"] = true, + ["NotVeryEffectiveText"] = true, + ["NothingHappenedText"] = true, + ["NumberAcceptedFText"] = true, + ["NumberAcceptedMText"] = true, + ["NumberDeclinedFText"] = true, + ["NumberDeclinedMText"] = true, + ["NurseAskHealText"] = true, + ["NurseDayText"] = true, + ["NurseGoodbyeText"] = true, + ["NurseMornText"] = true, + ["NurseNiteText"] = true, + ["NursePokerusText"] = true, + ["NurseReturnPokemonText"] = true, + ["NurseTakePokemonText"] = true, + ["OneHitKOText"] = true, + ["ParalyzedText"] = true, + ["PerishCountText"] = true, + ["PhoneFullFText"] = true, + ["PhoneFullMText"] = true, + ["PictureBookshelfText"] = true, + ["PlayerHitTimesText"] = true, + ["PokecenterSignText"] = true, + ["PokemonFellFromTreeText"] = true, + ["PresentFailedText"] = true, + ["ProtectedByMistText"] = true, + ["ProtectedByText"] = true, + ["ProtectedItselfText"] = true, + ["ProtectingItselfText"] = true, + ["PutACurseText"] = true, + ["RageBuildingText"] = true, + ["ReceivedItemText"] = true, + ["RecoilText"] = true, + ["RecoveredUsingText"] = true, + ["ReflectEffectText"] = true, + ["RegainedHealthText"] = true, + ["RegisteredNumberFText"] = true, + ["RegisteredNumberMText"] = true, + ["ReleasedByText"] = true, + ["RematchFText"] = true, + ["RematchMText"] = true, + ["RestedText"] = true, + ["SafeguardProtectText"] = true, + ["SandstormBrewedText"] = true, + ["SandstormHitsText"] = true, + ["SentAllToMomText"] = true, + ["SentHalfToMomText"] = true, + ["SentSomeToMomText"] = true, + ["SharedPainText"] = true, + ["ShedLeechSeedText"] = true, + ["SketchedText"] = true, + ["SpikesText"] = true, + ["SpiteEffectText"] = true, + ["StartPerishText"] = true, + ["StartedNightmareText"] = true, + ["StoleText"] = true, + ["StoringEnergyText"] = true, + ["SubFadedText"] = true, + ["SubTookDamageText"] = true, + ["SuckedHealthText"] = true, + ["SunGotBrightText"] = true, + ["SuperEffectiveText"] = true, + ["TVText"] = true, + ["TeamRocketOathText"] = true, + ["Text_BallCaught"] = true, + ["Text_BattleEffectActivate"] = true, + ["Text_BattleFoeEffectActivate"] = true, + ["Text_BattleUser"] = true, + ["Text_BreedHuh"] = true, + ["Text_Gained"] = true, + ["Text_MoveForgetCount"] = true, + ["Text_NPCTraded"] = true, + ["Text_PlayedPokeFlute"] = true, + ["TiedAgainstText"] = true, + ["TooWeakSubText"] = true, + ["TookAimText"] = true, + ["TookDownWithItText"] = true, + ["TransformedText"] = true, + ["TransformedTypeText"] = true, + ["TrashCanText"] = true, + ["TurnedAwayText"] = true, + ["UnaffectedText"] = true, + ["UnleashedEnergyText"] = true, + ["UnusedRivalLossText"] = true, + ["UnusedRivalWinText"] = true, + ["UsedBindText"] = true, + ["WantsToBattleText"] = true, + ["WasBurnedText"] = true, + ["WasDefrostedText"] = true, + ["WasDisabledText"] = true, + ["WasFrozenText"] = true, + ["WasPoisonedText"] = true, + ["WasSeededText"] = true, + ["WasTrappedText"] = true, + ["WentToSleepText"] = true, + ["WildPokemonAppearedText"] = true, + ["WindowText"] = true, + ["WokeUpText"] = true, + ["WontDropAnymoreText"] = true, + ["WontObeyText"] = true, + ["WontRiseAnymoreText"] = true, + ["WrappedByText"] = true, + ["_ActorNameText"] = true, + ["_AlreadyASaveFileText"] = true, + ["_AlreadySetUpText"] = true, + ["_AlreadySurfingText"] = true, + ["_AlreadyUsingStrengthText"] = true, + ["_AnEggCantHoldAnItemText"] = true, + ["_AnotherSaveFileText"] = true, + ["_AreWeGeniusesText"] = true, + ["_AskCutText"] = true, + ["_AskDeleteMoveText"] = true, + ["_AskFloorElevatorText"] = true, + ["_AskForgetMoveText"] = true, + ["_AskGiveNicknameText"] = true, + ["_AskHeadbuttText"] = true, + ["_AskItemMoveText"] = true, + ["_AskQuantityThrowAwayText"] = true, + ["_AskRockSmashText"] = true, + ["_AskStrengthText"] = true, + ["_AskSurfText"] = true, + ["_AskThrowAwayText"] = true, + ["_AskWaterfallText"] = true, + ["_AskWhirlpoolText"] = true, + ["_BGEventText"] = true, + ["_BackAlreadyText"] = true, + ["_BadgeRequiredText"] = true, + ["_BallAlmostHadItText"] = true, + ["_BallAppearedCaughtText"] = true, + ["_BallBlockedText"] = true, + ["_BallBoxFullText"] = true, + ["_BallBrokeFreeText"] = true, + ["_BallDodgedText"] = true, + ["_BallDontBeAThiefText"] = true, + ["_BallMissedText"] = true, + ["_BallSentToPCText"] = true, + ["_BallSoCloseText"] = true, + ["_BargainShopComeAgainText"] = true, + ["_BargainShopFinalPriceText"] = true, + ["_BargainShopIntroText"] = true, + ["_BargainShopNoFundsText"] = true, + ["_BargainShopPackFullText"] = true, + ["_BargainShopSoldOutText"] = true, + ["_BargainShopThanksText"] = true, + ["_BattleDugText"] = true, + ["_BattleFlewText"] = true, + ["_BattleGlowingText"] = true, + ["_BattleLoweredHeadText"] = true, + ["_BattleMadeWhirlwindText"] = true, + ["_BattleMonNickCommaText"] = true, + ["_BattleStatFellText"] = true, + ["_BattleStatSharplyFellText"] = true, + ["_BattleStatWentUpText"] = true, + ["_BattleStatWentWayUpText"] = true, + ["_BattleTookSunlightText"] = true, + ["_BenFernText1"] = true, + ["_BenFernText2A"] = true, + ["_BenFernText2B"] = true, + ["_BenFernText3A"] = true, + ["_BenFernText3B"] = true, + ["_BenIntroText1"] = true, + ["_BenIntroText2"] = true, + ["_BenIntroText3"] = true, + ["_BidsFarewellToMonText"] = true, + ["_BlindingFlashText"] = true, + ["_BoostedExpPointsText"] = true, + ["_BootedHMText"] = true, + ["_BootedTMText"] = true, + ["_BouldersMayMoveText"] = true, + ["_BouldersMoveText"] = true, + ["_BreedAppearsToCareForText"] = true, + ["_BreedAskNicknameText"] = true, + ["_BreedBrimmingWithEnergyText"] = true, + ["_BreedClearboxText"] = true, + ["_BreedEggHatchText"] = true, + ["_BreedFriendlyText"] = true, + ["_BreedNoInterestText"] = true, + ["_BreedShowsInterestText"] = true, + ["_BreedingIsNotPossibleText"] = true, + ["_BugCatchingContestIsOverText"] = true, + ["_BugCatchingContestTimeUpText"] = true, + ["_BurnWasHealedText"] = true, + ["_ButNoSpaceText"] = true, + ["_CameToItsSensesText"] = true, + ["_CanCutText"] = true, + ["_CantAcceptEggText"] = true, + ["_CantCarryItemText"] = true, + ["_CantGetOffBikeText"] = true, + ["_CantRegisterText"] = true, + ["_CantSurfText"] = true, + ["_CantUseDigText"] = true, + ["_CantUseItemText"] = true, + ["_CantUseTeleportText"] = true, + ["_CardFlipChooseACardText"] = true, + ["_CardFlipDarnText"] = true, + ["_CardFlipNotEnoughCoinsText"] = true, + ["_CardFlipPlaceYourBetText"] = true, + ["_CardFlipPlayAgainText"] = true, + ["_CardFlipPlayWithThreeCoinsText"] = true, + ["_CardFlipShuffledText"] = true, + ["_CardFlipYeahText"] = true, + ["_CaughtAskNicknameText"] = true, + ["_ChangeBoxSaveText"] = true, + ["_ChangeWhichNumberText"] = true, + ["_ClearAllSaveDataText"] = true, + ["_ClockHasResetText"] = true, + ["_ClockIsThisOKText"] = true, + ["_ClockSetWithControlPadText"] = true, + ["_ClockTimeMayBeWrongText"] = true, + ["_CoinCaseCountText"] = true, + ["_ComeAgainText"] = true, + ["_ComeBackLaterText"] = true, + ["_ComeBackText"] = true, + ["_CompatibilityShouldTheyBreedText"] = true, + ["_CongratulationsYourPokemonText"] = true, + ["_ContainedMoveText"] = true, + ["_ContestAlreadyCaughtText"] = true, + ["_ContestAskSwitchText"] = true, + ["_ContestCaughtMonText"] = true, + ["_ContestJudging_FirstPlaceScoreText"] = true, + ["_ContestJudging_FirstPlaceText"] = true, + ["_ContestJudging_SecondPlaceScoreText"] = true, + ["_ContestJudging_SecondPlaceText"] = true, + ["_ContestJudging_ThirdPlaceScoreText"] = true, + ["_ContestJudging_ThirdPlaceText"] = true, + ["_CoordinatesEventText"] = true, + ["_CorruptedEventText"] = true, + ["_CuredOfPoisonText"] = true, + ["_CutNothingText"] = true, + ["_DSTIsThatOKText"] = true, + ["_DayCareLadyIntroEggText"] = true, + ["_DayCareLadyIntroText"] = true, + ["_DayCareManIntroEggText"] = true, + ["_DayCareManIntroText"] = true, + ["_DaycareDummyText"] = true, + ["_DeleterAskWhichMonText"] = true, + ["_DeleterAskWhichMoveText"] = true, + ["_DeleterEggText"] = true, + ["_DeleterForgotMoveText"] = true, + ["_DeleterIntroText"] = true, + ["_DeleterNoComeAgainText"] = true, + ["_DidNotLearnMoveText"] = true, + ["_DoItMonText"] = true, + ["_EggPhotoText"] = true, + ["_EmptyMailboxText"] = true, + ["_EndUsedMove1Text"] = true, + ["_EndUsedMove2Text"] = true, + ["_EndUsedMove3Text"] = true, + ["_EndUsedMove4Text"] = true, + ["_EndUsedMove5Text"] = true, + ["_EnemyUsedOnText"] = true, + ["_EnemyWithdrewText"] = true, + ["_EnterTheAmountText"] = true, + ["_EnterTheIDNoText"] = true, + ["_EvolvedIntoText"] = true, + ["_EvolvingText"] = true, + ["_ExpPointsText"] = true, + ["_FernIntroText1"] = true, + ["_FernIntroText2"] = true, + ["_FluteWakeUpText"] = true, + ["_ForYourMonSendsText"] = true, + ["_ForYourMonWillTradeText"] = true, + ["_FoundAnEggText"] = true, + ["_FoundItemText"] = true, + ["_FruitBearingTreeText"] = true, + ["_FruitPackIsFullText"] = true, + ["_GearEllipseText"] = true, + ["_GearOutOfServiceText"] = true, + ["_GearTodayText"] = true, + ["_GoForItMonText"] = true, + ["_GoMonText"] = true, + ["_GoodComeBackText"] = true, + ["_GotBackMonText"] = true, + ["_GotOffBikeText"] = true, + ["_GotOnBikeText"] = true, + ["_GrewToLevelText"] = true, + ["_HaveNoRoomText"] = true, + ["_HeadbuttNothingText"] = true, + ["_HealthReturnedText"] = true, + ["_HerbShopLadyIntroText"] = true, + ["_HerbalLadyComeAgainText"] = true, + ["_HerbalLadyFinalPriceText"] = true, + ["_HerbalLadyHowManyText"] = true, + ["_HerbalLadyNoMoneyText"] = true, + ["_HerbalLadyPackFullText"] = true, + ["_HerbalLadyThanksText"] = true, + ["_HeyItsFruitText"] = true, + ["_HoldStillText"] = true, + ["_HugeWaterfallText"] = true, + ["_IllKeepItThanksText"] = true, + ["_IllRaiseYourMonText"] = true, + ["_IsThisOKText"] = true, + ["_ItemBelongsToSomeoneElseText"] = true, + ["_ItemCantGetOnText"] = true, + ["_ItemCantHeldText"] = true, + ["_ItemCantUseOnEggText"] = true, + ["_ItemCantUseOnMonText"] = true, + ["_ItemGotOffText"] = true, + ["_ItemGotOnText"] = true, + ["_ItemLooksBitterText"] = true, + ["_ItemOakWarningText"] = true, + ["_ItemStatRoseText"] = true, + ["_ItemStorageFullText"] = true, + ["_ItemUsedText"] = true, + ["_ItemWontHaveEffectText"] = true, + ["_ItemfinderItemNearbyText"] = true, + ["_ItemfinderNopeText"] = true, + ["_ItemsDiscardedText"] = true, + ["_ItemsOakWarningText"] = true, + ["_ItemsThrowAwayText"] = true, + ["_ItemsTooImportantText"] = true, + ["_ItemsTossOutHowManyText"] = true, + ["_ItsGoingToHatchText"] = true, + ["_JustSawSomeRareMonText"] = true, + ["_KarpGuruRecordText"] = true, + ["_KnowsMoveText"] = true, + ["_LC_DragText1"] = true, + ["_LC_DragText2"] = true, + ["_LC_Text1"] = true, + ["_LC_Text10"] = true, + ["_LC_Text11"] = true, + ["_LC_Text2"] = true, + ["_LC_Text3"] = true, + ["_LC_Text4"] = true, + ["_LC_Text5"] = true, + ["_LC_Text6"] = true, + ["_LC_Text7"] = true, + ["_LC_Text8"] = true, + ["_LC_Text9"] = true, + ["_LastHealthyMonText"] = true, + ["_LearnedMoveText"] = true, + ["_LeftWithDayCareLadyText"] = true, + ["_LeftWithDayCareManText"] = true, + ["_LinkAbnormalMonText"] = true, + ["_LinkAskTradeForText"] = true, + ["_LinkTimeoutText"] = true, + ["_LinkTradeCantBattleText"] = true, + ["_LookAdorableDecoText"] = true, + ["_LookClefairyPosterText"] = true, + ["_LookGiantDecoText"] = true, + ["_LookJigglypuffPosterText"] = true, + ["_LookPikachuPosterText"] = true, + ["_LookTownMapText"] = true, + ["_LuckyNumberMatchPCText"] = true, + ["_LuckyNumberMatchPartyText"] = true, + ["_MagikarpGuruMeasureText"] = true, + ["_MailAlreadyHoldingItemText"] = true, + ["_MailAskSendToPCText"] = true, + ["_MailClearedPutAwayText"] = true, + ["_MailDetachedText"] = true, + ["_MailEggText"] = true, + ["_MailLoseMessageText"] = true, + ["_MailMessageLostText"] = true, + ["_MailMovedFromBoxText"] = true, + ["_MailNoSpaceText"] = true, + ["_MailPackFullText"] = true, + ["_MailSentToPCText"] = true, + ["_MailboxFullText"] = true, + ["_MainMenuTimeUnknownText"] = true, + ["_MartAskMoreText"] = true, + ["_MartBoughtText"] = true, + ["_MartCantBuyText"] = true, + ["_MartComeAgainText"] = true, + ["_MartFinalPriceText"] = true, + ["_MartHowManyText"] = true, + ["_MartNoMoneyText"] = true, + ["_MartPackFullText"] = true, + ["_MartSellHowManyText"] = true, + ["_MartSellPriceText"] = true, + ["_MartThanksText"] = true, + ["_MartWelcomeText"] = true, + ["_MayPassWhirlpoolText"] = true, + ["_MayRegisterItemText"] = true, + ["_MaySmashText"] = true, + ["_MemoryGameDarnText"] = true, + ["_MemoryGameYeahText"] = true, + ["_MomBankWhatDoYouWantToDoText"] = true, + ["_MomBoughtWithYourMoneyText"] = true, + ["_MomFoundADollText"] = true, + ["_MomFoundAnItemText"] = true, + ["_MomHaventSavedThatMuchText"] = true, + ["_MomHiHowAreYouText"] = true, + ["_MomInsufficientFundsInWalletText"] = true, + ["_MomIsThisAboutYourMoneyText"] = true, + ["_MomItsInPCText"] = true, + ["_MomItsInYourRoomText"] = true, + ["_MomJustDoWhatYouCanText"] = true, + ["_MomLeavingText1"] = true, + ["_MomLeavingText2"] = true, + ["_MomLeavingText3"] = true, + ["_MomLostGearBookletText"] = true, + ["_MomNotEnoughRoomInBankText"] = true, + ["_MomNotEnoughRoomInWalletText"] = true, + ["_MomSaveMoneyText"] = true, + ["_MomStartSavingMoneyText"] = true, + ["_MomStoreMoneyText"] = true, + ["_MomStoredMoneyText"] = true, + ["_MomTakeMoneyText"] = true, + ["_MomTakenMoneyText"] = true, + ["_MonNameBidsFarewellText"] = true, + ["_MonNameSentToText"] = true, + ["_MonWasSentToText"] = true, + ["_MoveAskForgetText"] = true, + ["_MoveBoulderText"] = true, + ["_MoveCantForgetHMText"] = true, + ["_MoveForgotText"] = true, + ["_MoveKnowsOneText"] = true, + ["_MoveMonWOMailSaveText"] = true, + ["_MoveNameText"] = true, + ["_MysteryGiftCanceledText"] = true, + ["_MysteryGiftCommErrorText"] = true, + ["_MysteryGiftFiveADayText"] = true, + ["_MysteryGiftOneADayText"] = true, + ["_MysteryGiftSentHomeText"] = true, + ["_MysteryGiftSentText"] = true, + ["_NPCTradeAfterText1"] = true, + ["_NPCTradeAfterText2"] = true, + ["_NPCTradeAfterText3"] = true, + ["_NPCTradeCableText"] = true, + ["_NPCTradeCancelText1"] = true, + ["_NPCTradeCancelText2"] = true, + ["_NPCTradeCancelText3"] = true, + ["_NPCTradeCompleteText1"] = true, + ["_NPCTradeCompleteText2"] = true, + ["_NPCTradeCompleteText3"] = true, + ["_NPCTradeFanfareText"] = true, + ["_NPCTradeIntroText1"] = true, + ["_NPCTradeIntroText2"] = true, + ["_NPCTradeIntroText3"] = true, + ["_NPCTradeWrongText1"] = true, + ["_NPCTradeWrongText2"] = true, + ["_NPCTradeWrongText3"] = true, + ["_NameRaterBetterNameText"] = true, + ["_NameRaterComeAgainText"] = true, + ["_NameRaterEggText"] = true, + ["_NameRaterFinishedText"] = true, + ["_NameRaterHelloText"] = true, + ["_NameRaterNamedText"] = true, + ["_NameRaterPerfectNameText"] = true, + ["_NameRaterSameNameText"] = true, + ["_NameRaterWhatNameText"] = true, + ["_NameRaterWhichMonText"] = true, + ["_NewDexDataText"] = true, + ["_NoCoinCaseText"] = true, + ["_NoCoinsText"] = true, + ["_NoCyclingText"] = true, + ["_NoPhotoText"] = true, + ["_NoRoomForEggText"] = true, + ["_NoRoomTMHMText"] = true, + ["_NotEnoughMoneyText"] = true, + ["_NotYetText"] = true, + ["_NothingHereText"] = true, + ["_NothingToChooseText"] = true, + ["_NothingToPutAwayText"] = true, + ["_NothingToSellText"] = true, + ["_OKComeBackText"] = true, + ["_OPT_AddictivelyText"] = true, + ["_OPT_AlmostPoisonouslyText"] = true, + ["_OPT_AptlyNamedText"] = true, + ["_OPT_BoldSortOfText"] = true, + ["_OPT_CuteText"] = true, + ["_OPT_EvolutionMustBeText"] = true, + ["_OPT_ExcitingText"] = true, + ["_OPT_FlippedOutText"] = true, + ["_OPT_FriendlyText"] = true, + ["_OPT_FrighteningText"] = true, + ["_OPT_GuardedText"] = true, + ["_OPT_HeartMeltinglyText"] = true, + ["_OPT_HotHotHotText"] = true, + ["_OPT_InspiringText"] = true, + ["_OPT_IntroText1"] = true, + ["_OPT_IntroText2"] = true, + ["_OPT_IntroText3"] = true, + ["_OPT_LooksInWaterText"] = true, + ["_OPT_LovelyText"] = true, + ["_OPT_MaryText1"] = true, + ["_OPT_MischievouslyText"] = true, + ["_OPT_NowText"] = true, + ["_OPT_OakText1"] = true, + ["_OPT_OakText2"] = true, + ["_OPT_OakText3"] = true, + ["_OPT_PleasantText"] = true, + ["_OPT_PokemonChannelText"] = true, + ["_OPT_PowerfulText"] = true, + ["_OPT_ProvocativelyText"] = true, + ["_OPT_SensuallyText"] = true, + ["_OPT_SpeedyText"] = true, + ["_OPT_StimulatingText"] = true, + ["_OPT_SuaveDebonairText"] = true, + ["_OPT_SweetAdorablyText"] = true, + ["_OPT_TopicallyText"] = true, + ["_OPT_UnbearablyText"] = true, + ["_OPT_UndeniablyKindOfText"] = true, + ["_OPT_WeirdText"] = true, + ["_OPT_WigglySlicklyText"] = true, + ["_OPT_WowImpressivelyText"] = true, + ["_OTSendsText"] = true, + ["_OakPCText1"] = true, + ["_OakPCText2"] = true, + ["_OakPCText3"] = true, + ["_OakPCText4"] = true, + ["_OakRating01"] = true, + ["_OakRating02"] = true, + ["_OakRating03"] = true, + ["_OakRating04"] = true, + ["_OakRating05"] = true, + ["_OakRating06"] = true, + ["_OakRating07"] = true, + ["_OakRating08"] = true, + ["_OakRating09"] = true, + ["_OakRating10"] = true, + ["_OakRating11"] = true, + ["_OakRating12"] = true, + ["_OakRating13"] = true, + ["_OakRating14"] = true, + ["_OakRating15"] = true, + ["_OakRating16"] = true, + ["_OakRating17"] = true, + ["_OakRating18"] = true, + ["_OakRating19"] = true, + ["_OakText1"] = true, + ["_OakText2"] = true, + ["_OakText3"] = true, + ["_OakText4"] = true, + ["_OakText5"] = true, + ["_OakText6"] = true, + ["_OakText7"] = true, + ["_OakThisIsntTheTimeText"] = true, + ["_OakTimeHoursQuestionMarkText"] = true, + ["_OakTimeHowManyMinutesText"] = true, + ["_OakTimeIsItText"] = true, + ["_OakTimeMinutesQuestionMarkText"] = true, + ["_OakTimeOversleptText"] = true, + ["_OakTimeSoDarkText"] = true, + ["_OakTimeWhatDayIsItText"] = true, + ["_OakTimeWhatHoursText"] = true, + ["_OakTimeWhatTimeIsItText"] = true, + ["_OakTimeWhoaMinutesText"] = true, + ["_OakTimeWokeUpText"] = true, + ["_OakTimeYikesText"] = true, + ["_ObjectEventText"] = true, + ["_ObtainedFruitText"] = true, + ["_ObtainedTheVoltorbBadgeText"] = true, + ["_OhFineThenText"] = true, + ["_OnlyOneMonText"] = true, + ["_PCCantDepositLastMonText"] = true, + ["_PCCantTakeText"] = true, + ["_PCGottaHavePokemonText"] = true, + ["_PCMonHoldingMailText"] = true, + ["_PCNoSingleMonText"] = true, + ["_PCWhatText"] = true, + ["_PPIsMaxedOutText"] = true, + ["_PPRestoredText"] = true, + ["_PPsIncreasedText"] = true, + ["_PackEmptyText"] = true, + ["_PackNoItemText"] = true, + ["_PasswordAskEnterText"] = true, + ["_PasswordAskResetClockText"] = true, + ["_PasswordAskResetText"] = true, + ["_PasswordWrongText"] = true, + ["_PerfectHeresYourMonText"] = true, + ["_PharmacyComeAgainText"] = true, + ["_PharmacyFinalPriceText"] = true, + ["_PharmacyHowManyText"] = true, + ["_PharmacyIntroText"] = true, + ["_PharmacyNoMoneyText"] = true, + ["_PharmacyPackFullText"] = true, + ["_PharmacyThanksText"] = true, + ["_PhoneClickText"] = true, + ["_PhoneEllipseText"] = true, + ["_PhoneJustTalkToThemText"] = true, + ["_PhoneOutOfAreaText"] = true, + ["_PhoneThankYouText"] = true, + ["_PhoneWrongNumberText"] = true, + ["_PlayedFluteText"] = true, + ["_PlayerFoundItemText"] = true, + ["_PlayerPickedUpPayDayMoney"] = true, + ["_PlayersPCAskWhatDoText"] = true, + ["_PlayersPCDepositItemsText"] = true, + ["_PlayersPCHowManyDepositText"] = true, + ["_PlayersPCHowManyWithdrawText"] = true, + ["_PlayersPCNoItemsText"] = true, + ["_PlayersPCNoRoomDepositText"] = true, + ["_PlayersPCNoRoomWithdrawText"] = true, + ["_PlayersPCTurnOnText"] = true, + ["_PlayersPCWithdrewItemsText"] = true, + ["_PnP_BoldText"] = true, + ["_PnP_CoolText"] = true, + ["_PnP_CuteText"] = true, + ["_PnP_GreatText"] = true, + ["_PnP_HappyText"] = true, + ["_PnP_InspiringText"] = true, + ["_PnP_LazyText"] = true, + ["_PnP_MyTypeText"] = true, + ["_PnP_NoisyText"] = true, + ["_PnP_OddText"] = true, + ["_PnP_PickyText"] = true, + ["_PnP_PrecociousText"] = true, + ["_PnP_RightForMeText"] = true, + ["_PnP_SoSoText"] = true, + ["_PnP_SortOfOKText"] = true, + ["_PnP_Text1"] = true, + ["_PnP_Text2"] = true, + ["_PnP_Text3"] = true, + ["_PnP_Text4"] = true, + ["_PnP_Text5"] = true, + ["_PnP_WeirdText"] = true, + ["_PocketIsFullText"] = true, + ["_PoisonFaintText"] = true, + ["_PoisonWhiteoutText"] = true, + ["_PokecenterBillsPCText"] = true, + ["_PokecenterOaksPCText"] = true, + ["_PokecenterPCCantUseText"] = true, + ["_PokecenterPCOaksClosedText"] = true, + ["_PokecenterPCTurnOnText"] = true, + ["_PokecenterPCWhoseText"] = true, + ["_PokecenterPlayersPCText"] = true, + ["_PokedexShowText"] = true, + ["_PokegearAskDeleteText"] = true, + ["_PokegearAskWhoCallText"] = true, + ["_PokegearPressButtonText"] = true, + ["_PokemonAskSwapItemText"] = true, + ["_PokemonHoldItemText"] = true, + ["_PokemonNotEnoughHPText"] = true, + ["_PokemonNotHoldingText"] = true, + ["_PokemonRemoveMailText"] = true, + ["_PokemonSwapItemText"] = true, + ["_PokemonTookItemText"] = true, + ["_PrestoAllDoneText"] = true, + ["_PutAwayAndSetUpText"] = true, + ["_PutAwayTheDecoText"] = true, + ["_PutItemInPocketText"] = true, + ["_RaiseThePPOfWhichMoveText"] = true, + ["_ReceiveItemText"] = true, + ["_ReceivedEggText"] = true, + ["_ReceivedItemText"] = true, + ["_ReceivedTMHMText"] = true, + ["_RecoveredSomeHPText"] = true, + ["_RegisteredItemText"] = true, + ["_RemainingTimeText"] = true, + ["_RemoveMailText"] = true, + ["_RepelUsedEarlierIsStillInEffectText"] = true, + ["_RepelWoreOffText"] = true, + ["_RestoreThePPOfWhichMoveText"] = true, + ["_RetrieveMysteryGiftText"] = true, + ["_RevitalizedText"] = true, + ["_RidOfParalysisText"] = true, + ["_RocketRadioText1"] = true, + ["_RocketRadioText10"] = true, + ["_RocketRadioText2"] = true, + ["_RocketRadioText3"] = true, + ["_RocketRadioText4"] = true, + ["_RocketRadioText5"] = true, + ["_RocketRadioText6"] = true, + ["_RocketRadioText7"] = true, + ["_RocketRadioText8"] = true, + ["_RocketRadioText9"] = true, + ["_RodBiteText"] = true, + ["_RodNothingText"] = true, + ["_SaveFileCorruptedText"] = true, + ["_SavedTheGameText"] = true, + ["_SavingDontTurnOffThePowerText"] = true, + ["_SavingRecordText"] = true, + ["_SentTrophyHomeText"] = true, + ["_SetUpTheDecoText"] = true, + ["_SlotsBetHowManyCoinsText"] = true, + ["_SlotsDarnText"] = true, + ["_SlotsLinedUpText"] = true, + ["_SlotsNotEnoughCoinsText"] = true, + ["_SlotsPlayAgainText"] = true, + ["_SlotsRanOutOfCoinsText"] = true, + ["_SlotsStartText"] = true, + ["_SpaceSpaceColonText"] = true, + ["_SquirtbottleNothingText"] = true, + ["_StartMenuContestEndText"] = true, + ["_StopLearningMoveText"] = true, + ["_StoppedEvolvingText"] = true, + ["_SweetScentNothingText"] = true, + ["_TMHMNotCompatibleText"] = true, + ["_TakeGoodCareOfEggText"] = true, + ["_TakeGoodCareOfMonText"] = true, + ["_TeleportReturnText"] = true, + ["_TestEventText"] = true, + ["_ThatCantBeUsedRightNowText"] = true, + ["_ThatItemCantBePutInThePackText"] = true, + ["_ThatsEnoughComeBackText"] = true, + ["_TheBoxIsFullText"] = true, + ["_TheItemWasPutInThePackText"] = true, + ["_ThePasswordIsText"] = true, + ["_ThereIsNoEggText"] = true, + ["_ThrewAwayText"] = true, + ["_TimeAskOkayText"] = true, + ["_TimesetAskAdjustDSTText"] = true, + ["_TimesetAskDSTText"] = true, + ["_TimesetAskNotDSTText"] = true, + ["_TimesetDSTText"] = true, + ["_TimesetNotDSTText"] = true, + ["_UnusedNothingHereText"] = true, + ["_UseCutText"] = true, + ["_UseDigText"] = true, + ["_UseEscapeRopeText"] = true, + ["_UseHeadbuttText"] = true, + ["_UseRockSmashText"] = true, + ["_UseSacredAshText"] = true, + ["_UseStrengthText"] = true, + ["_UseSweetScentText"] = true, + ["_UseWaterfallText"] = true, + ["_UseWhirlpoolText"] = true, + ["_UsedInsteadText"] = true, + ["_UsedMove1Text"] = true, + ["_UsedMove2Text"] = true, + ["_UsedSurfText"] = true, + ["_WaitButtonText"] = true, + ["_WarpingText"] = true, + ["_WasDefrostedText"] = true, + ["_WasSentToBillsPCText"] = true, + ["_WhatDoYouWantToPlayWithText"] = true, + ["_WhatShouldIRaiseText"] = true, + ["_WhichMonPhotoText"] = true, + ["_WhichSidePutAwayText"] = true, + ["_WhichSidePutOnText"] = true, + ["_WhitedOutText"] = true, + ["_WillTradeText"] = true, + ["_WillYouPlayWithMonText"] = true, + ["_WindowAreaExceededErrorText"] = true, + ["_WindowPoppingErrorText"] = true, + ["_WokeUpText"] = true, + ["_WouldYouLikeToSaveTheGameText"] = true, + ["_YouCanHaveThisText"] = true, + ["_YouCantUseItInABattleText"] = true, + ["_YouDontHaveAMonText"] = true, + ["_YouNeedTwoMonForBreedingText"] = true, + ["_YourFoesWeakGetmMonText"] = true, + ["_YourFriendIsNotReadyText"] = true, + ["_YourMonHasGrownText"] = true, + ["_YourMonsHPWasHealedText"] = true, +} diff --git a/src/mods/rom_text_labels/silver.lua b/src/mods/rom_text_labels/silver.lua new file mode 100644 index 000000000..11f08afa6 --- /dev/null +++ b/src/mods/rom_text_labels/silver.lua @@ -0,0 +1,894 @@ +-- Generated label/address facts for silver; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + ["AlreadyAsleepText"] = true, + ["AlreadyConfusedText"] = true, + ["AlreadyParalyzedText"] = true, + ["AlreadyPoisonedText"] = true, + ["AskNumber1FText"] = true, + ["AskNumber1MText"] = true, + ["AskNumber2FText"] = true, + ["AskNumber2MText"] = true, + ["AttackMissed2Text"] = true, + ["AttackMissedText"] = true, + ["BadlyPoisonedText"] = true, + ["BattleText_AnEGGCantBattle"] = true, + ["BattleText_CantEscape"] = true, + ["BattleText_CantEscape2"] = true, + ["BattleText_EnemyFled"] = true, + ["BattleText_EnemyIsAboutToUseWillPlayerChangeMon"] = true, + ["BattleText_EnemyMonFainted"] = true, + ["BattleText_EnemySentOut"] = true, + ["BattleText_EnemyWasDefeated"] = true, + ["BattleText_GotAwaySafely"] = true, + ["BattleText_ItemHealedConfusion"] = true, + ["BattleText_ItemsCantBeUsedHere"] = true, + ["BattleText_MonCantBeRecalled"] = true, + ["BattleText_MonFainted"] = true, + ["BattleText_MonHasNoMovesLeft"] = true, + ["BattleText_MonIsAlreadyOut"] = true, + ["BattleText_MonsLightScreenFell"] = true, + ["BattleText_MonsReflectFaded"] = true, + ["BattleText_PlayerPickedUpPayDayMoney"] = true, + ["BattleText_RainContinuesToFall"] = true, + ["BattleText_SafeguardFaded"] = true, + ["BattleText_StringBuffer1GrewToLevel"] = true, + ["BattleText_TargetRecoveredWithItem"] = true, + ["BattleText_TargetWasHitByFutureSight"] = true, + ["BattleText_TargetsEncoreEnded"] = true, + ["BattleText_TheMoveIsDisabled"] = true, + ["BattleText_TheRainStopped"] = true, + ["BattleText_TheSandstormRages"] = true, + ["BattleText_TheSandstormSubsided"] = true, + ["BattleText_TheSunlightFaded"] = true, + ["BattleText_TheSunlightIsStrong"] = true, + ["BattleText_TheresNoEscapeFromTrainerBattle"] = true, + ["BattleText_TheresNoPPLeftForThisMove"] = true, + ["BattleText_TheresNoWillToBattle"] = true, + ["BattleText_UseNextMon"] = true, + ["BattleText_UserFledUsingAStringBuffer1"] = true, + ["BattleText_UserHurtBySpikes"] = true, + ["BattleText_UserRecoveredPPUsing"] = true, + ["BattleText_UserWasReleasedFromStringBuffer1"] = true, + ["BattleText_UsersHurtByStringBuffer1"] = true, + ["BattleText_UsersStringBuffer1Activated"] = true, + ["BattleText_WildFled"] = true, + ["BattleText_WildMonIsAngry"] = true, + ["BattleText_WildMonIsEating"] = true, + ["BeatUpAttackText"] = true, + ["BecameConfusedText"] = true, + ["BeganToNapText"] = true, + ["BellChimedText"] = true, + ["BellyDrumText"] = true, + ["BlewSpikesText"] = true, + ["BlownAwayText"] = true, + ["BracedItselfText"] = true, + ["BugContestPrizeNoRoomText"] = true, + ["ButItFailedText"] = true, + ["CantEscapeNowText"] = true, + ["ClampedByText"] = true, + ["CoinVendor_Buy500CoinsText"] = true, + ["CoinVendor_Buy50CoinsText"] = true, + ["CoinVendor_CancelText"] = true, + ["CoinVendor_CoinCaseFullText"] = true, + ["CoinVendor_IntroText"] = true, + ["CoinVendor_NoCoinCaseText"] = true, + ["CoinVendor_NotEnoughMoneyText"] = true, + ["CoinVendor_WelcomeText"] = true, + ["CoinsScatteredText"] = true, + ["ConfusedNoMoreText"] = true, + ["ContestResults_ConsolationPrizeText"] = true, + ["ContestResults_DidNotWinText"] = true, + ["ContestResults_JoinUsNextTimeText"] = true, + ["ContestResults_PartyFullText"] = true, + ["ContestResults_PlayerWonAPrizeText"] = true, + ["ContestResults_ReadyToJudgeText"] = true, + ["ContestResults_ReturnPartyText"] = true, + ["CopiedStatsText"] = true, + ["CoveredByVeilText"] = true, + ["CrashedText"] = true, + ["CriticalHitText"] = true, + ["DefrostedOpponentText"] = true, + ["DestinyBondEffectText"] = true, + ["DidntAffect1Text"] = true, + ["DidntAffect2Text"] = true, + ["DifficultBookshelfText"] = true, + ["DisabledMoveText"] = true, + ["DisabledNoMoreText"] = true, + ["DoesntAffectText"] = true, + ["DownpourText"] = true, + ["DraggedOutText"] = true, + ["DreamEatenText"] = true, + ["EliminatedStatsText"] = true, + ["EnduredText"] = true, + ["EnemyHitTimesText"] = true, + ["EvadedText"] = true, + ["FastAsleepText"] = true, + ["FellAsleepText"] = true, + ["FellInLoveText"] = true, + ["FledFromBattleText"] = true, + ["FledInFearText"] = true, + ["FlinchedText"] = true, + ["ForesawAttackText"] = true, + ["FrozenSolidText"] = true, + ["FullyParalyzedText"] = true, + ["GettingPumpedText"] = true, + ["GotAnEncoreText"] = true, + ["GotMoneyForWinningText"] = true, + ["GymStatue_CityGymText"] = true, + ["GymStatue_WinningTrainersText"] = true, + ["HPIsFullText"] = true, + ["HappinessText1"] = true, + ["HappinessText2"] = true, + ["HappinessText3"] = true, + ["HasANightmareText"] = true, + ["HasNoPPLeftText"] = true, + ["HasSubstituteText"] = true, + ["HomepageText"] = true, + ["HookedPokemonAttackedText"] = true, + ["HungOnText"] = true, + ["HurtByBurnText"] = true, + ["HurtByCurseText"] = true, + ["HurtByPoisonText"] = true, + ["HurtItselfText"] = true, + ["IdentifiedText"] = true, + ["IgnoredOrders2Text"] = true, + ["IgnoredOrdersText"] = true, + ["IgnoredSleepingText"] = true, + ["InLoveWithText"] = true, + ["IncenseBurnerText"] = true, + ["InfatuationText"] = true, + ["IsConfusedText"] = true, + ["ItFailedText"] = true, + ["LeechSeedSapsText"] = true, + ["LightScreenEffectText"] = true, + ["LoafingAroundText"] = true, + ["LookTownMapText"] = true, + ["LostAgainstText"] = true, + ["MadeSubstituteText"] = true, + ["MagazineBookshelfText"] = true, + ["MagnitudeText"] = true, + ["MartSignText"] = true, + ["MerchandiseShelfText"] = true, + ["MimicLearnedMoveText"] = true, + ["MirrorMoveFailedText"] = true, + ["MistText"] = true, + ["MustRechargeText"] = true, + ["NoPPLeftText"] = true, + ["NotVeryEffectiveText"] = true, + ["NothingHappenedText"] = true, + ["NumberAcceptedFText"] = true, + ["NumberAcceptedMText"] = true, + ["NumberDeclinedFText"] = true, + ["NumberDeclinedMText"] = true, + ["NurseAskHealText"] = true, + ["NurseDayText"] = true, + ["NurseGoodbyeText"] = true, + ["NurseMornText"] = true, + ["NurseNiteText"] = true, + ["NursePokerusText"] = true, + ["NurseReturnPokemonText"] = true, + ["NurseTakePokemonText"] = true, + ["OneHitKOText"] = true, + ["ParalyzedText"] = true, + ["PerishCountText"] = true, + ["PhoneFullFText"] = true, + ["PhoneFullMText"] = true, + ["PictureBookshelfText"] = true, + ["PlayerHitTimesText"] = true, + ["PokecenterSignText"] = true, + ["PokemonFellFromTreeText"] = true, + ["PresentFailedText"] = true, + ["ProtectedByMistText"] = true, + ["ProtectedByText"] = true, + ["ProtectedItselfText"] = true, + ["ProtectingItselfText"] = true, + ["PutACurseText"] = true, + ["RageBuildingText"] = true, + ["ReceivedItemText"] = true, + ["RecoilText"] = true, + ["RecoveredUsingText"] = true, + ["ReflectEffectText"] = true, + ["RegainedHealthText"] = true, + ["RegisteredNumberFText"] = true, + ["RegisteredNumberMText"] = true, + ["ReleasedByText"] = true, + ["RematchFText"] = true, + ["RematchMText"] = true, + ["RestedText"] = true, + ["SafeguardProtectText"] = true, + ["SandstormBrewedText"] = true, + ["SandstormHitsText"] = true, + ["SentAllToMomText"] = true, + ["SentHalfToMomText"] = true, + ["SentSomeToMomText"] = true, + ["SharedPainText"] = true, + ["ShedLeechSeedText"] = true, + ["SketchedText"] = true, + ["SpikesText"] = true, + ["SpiteEffectText"] = true, + ["StartPerishText"] = true, + ["StartedNightmareText"] = true, + ["StoleText"] = true, + ["StoringEnergyText"] = true, + ["SubFadedText"] = true, + ["SubTookDamageText"] = true, + ["SuckedHealthText"] = true, + ["SunGotBrightText"] = true, + ["SuperEffectiveText"] = true, + ["TVText"] = true, + ["TeamRocketOathText"] = true, + ["Text_BallCaught"] = true, + ["Text_BattleEffectActivate"] = true, + ["Text_BattleFoeEffectActivate"] = true, + ["Text_BattleUser"] = true, + ["Text_BreedHuh"] = true, + ["Text_Gained"] = true, + ["Text_MoveForgetCount"] = true, + ["Text_NPCTraded"] = true, + ["Text_PlayedPokeFlute"] = true, + ["TiedAgainstText"] = true, + ["TooWeakSubText"] = true, + ["TookAimText"] = true, + ["TookDownWithItText"] = true, + ["TransformedText"] = true, + ["TransformedTypeText"] = true, + ["TrashCanText"] = true, + ["TurnedAwayText"] = true, + ["UnaffectedText"] = true, + ["UnleashedEnergyText"] = true, + ["UnusedRivalLossText"] = true, + ["UnusedRivalWinText"] = true, + ["UsedBindText"] = true, + ["WantsToBattleText"] = true, + ["WasBurnedText"] = true, + ["WasDefrostedText"] = true, + ["WasDisabledText"] = true, + ["WasFrozenText"] = true, + ["WasPoisonedText"] = true, + ["WasSeededText"] = true, + ["WasTrappedText"] = true, + ["WentToSleepText"] = true, + ["WildPokemonAppearedText"] = true, + ["WindowText"] = true, + ["WokeUpText"] = true, + ["WontDropAnymoreText"] = true, + ["WontObeyText"] = true, + ["WontRiseAnymoreText"] = true, + ["WrappedByText"] = true, + ["_ActorNameText"] = true, + ["_AlreadyASaveFileText"] = true, + ["_AlreadySetUpText"] = true, + ["_AlreadySurfingText"] = true, + ["_AlreadyUsingStrengthText"] = true, + ["_AnEggCantHoldAnItemText"] = true, + ["_AnotherSaveFileText"] = true, + ["_AreWeGeniusesText"] = true, + ["_AskCutText"] = true, + ["_AskDeleteMoveText"] = true, + ["_AskFloorElevatorText"] = true, + ["_AskForgetMoveText"] = true, + ["_AskGiveNicknameText"] = true, + ["_AskHeadbuttText"] = true, + ["_AskItemMoveText"] = true, + ["_AskQuantityThrowAwayText"] = true, + ["_AskRockSmashText"] = true, + ["_AskStrengthText"] = true, + ["_AskSurfText"] = true, + ["_AskThrowAwayText"] = true, + ["_AskWaterfallText"] = true, + ["_AskWhirlpoolText"] = true, + ["_BGEventText"] = true, + ["_BackAlreadyText"] = true, + ["_BadgeRequiredText"] = true, + ["_BallAlmostHadItText"] = true, + ["_BallAppearedCaughtText"] = true, + ["_BallBlockedText"] = true, + ["_BallBoxFullText"] = true, + ["_BallBrokeFreeText"] = true, + ["_BallDodgedText"] = true, + ["_BallDontBeAThiefText"] = true, + ["_BallMissedText"] = true, + ["_BallSentToPCText"] = true, + ["_BallSoCloseText"] = true, + ["_BargainShopComeAgainText"] = true, + ["_BargainShopFinalPriceText"] = true, + ["_BargainShopIntroText"] = true, + ["_BargainShopNoFundsText"] = true, + ["_BargainShopPackFullText"] = true, + ["_BargainShopSoldOutText"] = true, + ["_BargainShopThanksText"] = true, + ["_BattleDugText"] = true, + ["_BattleFlewText"] = true, + ["_BattleGlowingText"] = true, + ["_BattleLoweredHeadText"] = true, + ["_BattleMadeWhirlwindText"] = true, + ["_BattleMonNickCommaText"] = true, + ["_BattleStatFellText"] = true, + ["_BattleStatSharplyFellText"] = true, + ["_BattleStatWentUpText"] = true, + ["_BattleStatWentWayUpText"] = true, + ["_BattleTookSunlightText"] = true, + ["_BenFernText1"] = true, + ["_BenFernText2A"] = true, + ["_BenFernText2B"] = true, + ["_BenFernText3A"] = true, + ["_BenFernText3B"] = true, + ["_BenIntroText1"] = true, + ["_BenIntroText2"] = true, + ["_BenIntroText3"] = true, + ["_BidsFarewellToMonText"] = true, + ["_BlindingFlashText"] = true, + ["_BoostedExpPointsText"] = true, + ["_BootedHMText"] = true, + ["_BootedTMText"] = true, + ["_BouldersMayMoveText"] = true, + ["_BouldersMoveText"] = true, + ["_BreedAppearsToCareForText"] = true, + ["_BreedAskNicknameText"] = true, + ["_BreedBrimmingWithEnergyText"] = true, + ["_BreedClearboxText"] = true, + ["_BreedEggHatchText"] = true, + ["_BreedFriendlyText"] = true, + ["_BreedNoInterestText"] = true, + ["_BreedShowsInterestText"] = true, + ["_BreedingIsNotPossibleText"] = true, + ["_BugCatchingContestIsOverText"] = true, + ["_BugCatchingContestTimeUpText"] = true, + ["_BurnWasHealedText"] = true, + ["_ButNoSpaceText"] = true, + ["_CameToItsSensesText"] = true, + ["_CanCutText"] = true, + ["_CantAcceptEggText"] = true, + ["_CantCarryItemText"] = true, + ["_CantGetOffBikeText"] = true, + ["_CantRegisterText"] = true, + ["_CantSurfText"] = true, + ["_CantUseDigText"] = true, + ["_CantUseItemText"] = true, + ["_CantUseTeleportText"] = true, + ["_CardFlipChooseACardText"] = true, + ["_CardFlipDarnText"] = true, + ["_CardFlipNotEnoughCoinsText"] = true, + ["_CardFlipPlaceYourBetText"] = true, + ["_CardFlipPlayAgainText"] = true, + ["_CardFlipPlayWithThreeCoinsText"] = true, + ["_CardFlipShuffledText"] = true, + ["_CardFlipYeahText"] = true, + ["_CaughtAskNicknameText"] = true, + ["_ChangeBoxSaveText"] = true, + ["_ChangeWhichNumberText"] = true, + ["_ClearAllSaveDataText"] = true, + ["_ClockHasResetText"] = true, + ["_ClockIsThisOKText"] = true, + ["_ClockSetWithControlPadText"] = true, + ["_ClockTimeMayBeWrongText"] = true, + ["_CoinCaseCountText"] = true, + ["_ComeAgainText"] = true, + ["_ComeBackLaterText"] = true, + ["_ComeBackText"] = true, + ["_CompatibilityShouldTheyBreedText"] = true, + ["_CongratulationsYourPokemonText"] = true, + ["_ContainedMoveText"] = true, + ["_ContestAlreadyCaughtText"] = true, + ["_ContestAskSwitchText"] = true, + ["_ContestCaughtMonText"] = true, + ["_ContestJudging_FirstPlaceScoreText"] = true, + ["_ContestJudging_FirstPlaceText"] = true, + ["_ContestJudging_SecondPlaceScoreText"] = true, + ["_ContestJudging_SecondPlaceText"] = true, + ["_ContestJudging_ThirdPlaceScoreText"] = true, + ["_ContestJudging_ThirdPlaceText"] = true, + ["_CoordinatesEventText"] = true, + ["_CorruptedEventText"] = true, + ["_CuredOfPoisonText"] = true, + ["_CutNothingText"] = true, + ["_DSTIsThatOKText"] = true, + ["_DayCareLadyIntroEggText"] = true, + ["_DayCareLadyIntroText"] = true, + ["_DayCareManIntroEggText"] = true, + ["_DayCareManIntroText"] = true, + ["_DaycareDummyText"] = true, + ["_DeleterAskWhichMonText"] = true, + ["_DeleterAskWhichMoveText"] = true, + ["_DeleterEggText"] = true, + ["_DeleterForgotMoveText"] = true, + ["_DeleterIntroText"] = true, + ["_DeleterNoComeAgainText"] = true, + ["_DidNotLearnMoveText"] = true, + ["_DoItMonText"] = true, + ["_EggPhotoText"] = true, + ["_EmptyMailboxText"] = true, + ["_EndUsedMove1Text"] = true, + ["_EndUsedMove2Text"] = true, + ["_EndUsedMove3Text"] = true, + ["_EndUsedMove4Text"] = true, + ["_EndUsedMove5Text"] = true, + ["_EnemyUsedOnText"] = true, + ["_EnemyWithdrewText"] = true, + ["_EnterTheAmountText"] = true, + ["_EnterTheIDNoText"] = true, + ["_EvolvedIntoText"] = true, + ["_EvolvingText"] = true, + ["_ExpPointsText"] = true, + ["_FernIntroText1"] = true, + ["_FernIntroText2"] = true, + ["_FluteWakeUpText"] = true, + ["_ForYourMonSendsText"] = true, + ["_ForYourMonWillTradeText"] = true, + ["_FoundAnEggText"] = true, + ["_FoundItemText"] = true, + ["_FruitBearingTreeText"] = true, + ["_FruitPackIsFullText"] = true, + ["_GearEllipseText"] = true, + ["_GearOutOfServiceText"] = true, + ["_GearTodayText"] = true, + ["_GoForItMonText"] = true, + ["_GoMonText"] = true, + ["_GoodComeBackText"] = true, + ["_GotBackMonText"] = true, + ["_GotOffBikeText"] = true, + ["_GotOnBikeText"] = true, + ["_GrewToLevelText"] = true, + ["_HaveNoRoomText"] = true, + ["_HeadbuttNothingText"] = true, + ["_HealthReturnedText"] = true, + ["_HerbShopLadyIntroText"] = true, + ["_HerbalLadyComeAgainText"] = true, + ["_HerbalLadyFinalPriceText"] = true, + ["_HerbalLadyHowManyText"] = true, + ["_HerbalLadyNoMoneyText"] = true, + ["_HerbalLadyPackFullText"] = true, + ["_HerbalLadyThanksText"] = true, + ["_HeyItsFruitText"] = true, + ["_HoldStillText"] = true, + ["_HugeWaterfallText"] = true, + ["_IllKeepItThanksText"] = true, + ["_IllRaiseYourMonText"] = true, + ["_IsThisOKText"] = true, + ["_ItemBelongsToSomeoneElseText"] = true, + ["_ItemCantGetOnText"] = true, + ["_ItemCantHeldText"] = true, + ["_ItemCantUseOnEggText"] = true, + ["_ItemCantUseOnMonText"] = true, + ["_ItemGotOffText"] = true, + ["_ItemGotOnText"] = true, + ["_ItemLooksBitterText"] = true, + ["_ItemOakWarningText"] = true, + ["_ItemStatRoseText"] = true, + ["_ItemStorageFullText"] = true, + ["_ItemUsedText"] = true, + ["_ItemWontHaveEffectText"] = true, + ["_ItemfinderItemNearbyText"] = true, + ["_ItemfinderNopeText"] = true, + ["_ItemsDiscardedText"] = true, + ["_ItemsOakWarningText"] = true, + ["_ItemsThrowAwayText"] = true, + ["_ItemsTooImportantText"] = true, + ["_ItemsTossOutHowManyText"] = true, + ["_ItsGoingToHatchText"] = true, + ["_JustSawSomeRareMonText"] = true, + ["_KarpGuruRecordText"] = true, + ["_KnowsMoveText"] = true, + ["_LC_DragText1"] = true, + ["_LC_DragText2"] = true, + ["_LC_Text1"] = true, + ["_LC_Text10"] = true, + ["_LC_Text11"] = true, + ["_LC_Text2"] = true, + ["_LC_Text3"] = true, + ["_LC_Text4"] = true, + ["_LC_Text5"] = true, + ["_LC_Text6"] = true, + ["_LC_Text7"] = true, + ["_LC_Text8"] = true, + ["_LC_Text9"] = true, + ["_LastHealthyMonText"] = true, + ["_LearnedMoveText"] = true, + ["_LeftWithDayCareLadyText"] = true, + ["_LeftWithDayCareManText"] = true, + ["_LinkAbnormalMonText"] = true, + ["_LinkAskTradeForText"] = true, + ["_LinkTimeoutText"] = true, + ["_LinkTradeCantBattleText"] = true, + ["_LookAdorableDecoText"] = true, + ["_LookClefairyPosterText"] = true, + ["_LookGiantDecoText"] = true, + ["_LookJigglypuffPosterText"] = true, + ["_LookPikachuPosterText"] = true, + ["_LookTownMapText"] = true, + ["_LuckyNumberMatchPCText"] = true, + ["_LuckyNumberMatchPartyText"] = true, + ["_MagikarpGuruMeasureText"] = true, + ["_MailAlreadyHoldingItemText"] = true, + ["_MailAskSendToPCText"] = true, + ["_MailClearedPutAwayText"] = true, + ["_MailDetachedText"] = true, + ["_MailEggText"] = true, + ["_MailLoseMessageText"] = true, + ["_MailMessageLostText"] = true, + ["_MailMovedFromBoxText"] = true, + ["_MailNoSpaceText"] = true, + ["_MailPackFullText"] = true, + ["_MailSentToPCText"] = true, + ["_MailboxFullText"] = true, + ["_MainMenuTimeUnknownText"] = true, + ["_MartAskMoreText"] = true, + ["_MartBoughtText"] = true, + ["_MartCantBuyText"] = true, + ["_MartComeAgainText"] = true, + ["_MartFinalPriceText"] = true, + ["_MartHowManyText"] = true, + ["_MartNoMoneyText"] = true, + ["_MartPackFullText"] = true, + ["_MartSellHowManyText"] = true, + ["_MartSellPriceText"] = true, + ["_MartThanksText"] = true, + ["_MartWelcomeText"] = true, + ["_MayPassWhirlpoolText"] = true, + ["_MayRegisterItemText"] = true, + ["_MaySmashText"] = true, + ["_MemoryGameDarnText"] = true, + ["_MemoryGameYeahText"] = true, + ["_MomBankWhatDoYouWantToDoText"] = true, + ["_MomBoughtWithYourMoneyText"] = true, + ["_MomFoundADollText"] = true, + ["_MomFoundAnItemText"] = true, + ["_MomHaventSavedThatMuchText"] = true, + ["_MomHiHowAreYouText"] = true, + ["_MomInsufficientFundsInWalletText"] = true, + ["_MomIsThisAboutYourMoneyText"] = true, + ["_MomItsInPCText"] = true, + ["_MomItsInYourRoomText"] = true, + ["_MomJustDoWhatYouCanText"] = true, + ["_MomLeavingText1"] = true, + ["_MomLeavingText2"] = true, + ["_MomLeavingText3"] = true, + ["_MomLostGearBookletText"] = true, + ["_MomNotEnoughRoomInBankText"] = true, + ["_MomNotEnoughRoomInWalletText"] = true, + ["_MomSaveMoneyText"] = true, + ["_MomStartSavingMoneyText"] = true, + ["_MomStoreMoneyText"] = true, + ["_MomStoredMoneyText"] = true, + ["_MomTakeMoneyText"] = true, + ["_MomTakenMoneyText"] = true, + ["_MonNameBidsFarewellText"] = true, + ["_MonNameSentToText"] = true, + ["_MonWasSentToText"] = true, + ["_MoveAskForgetText"] = true, + ["_MoveBoulderText"] = true, + ["_MoveCantForgetHMText"] = true, + ["_MoveForgotText"] = true, + ["_MoveKnowsOneText"] = true, + ["_MoveMonWOMailSaveText"] = true, + ["_MoveNameText"] = true, + ["_MysteryGiftCanceledText"] = true, + ["_MysteryGiftCommErrorText"] = true, + ["_MysteryGiftFiveADayText"] = true, + ["_MysteryGiftOneADayText"] = true, + ["_MysteryGiftSentHomeText"] = true, + ["_MysteryGiftSentText"] = true, + ["_NPCTradeAfterText1"] = true, + ["_NPCTradeAfterText2"] = true, + ["_NPCTradeAfterText3"] = true, + ["_NPCTradeCableText"] = true, + ["_NPCTradeCancelText1"] = true, + ["_NPCTradeCancelText2"] = true, + ["_NPCTradeCancelText3"] = true, + ["_NPCTradeCompleteText1"] = true, + ["_NPCTradeCompleteText2"] = true, + ["_NPCTradeCompleteText3"] = true, + ["_NPCTradeFanfareText"] = true, + ["_NPCTradeIntroText1"] = true, + ["_NPCTradeIntroText2"] = true, + ["_NPCTradeIntroText3"] = true, + ["_NPCTradeWrongText1"] = true, + ["_NPCTradeWrongText2"] = true, + ["_NPCTradeWrongText3"] = true, + ["_NameRaterBetterNameText"] = true, + ["_NameRaterComeAgainText"] = true, + ["_NameRaterEggText"] = true, + ["_NameRaterFinishedText"] = true, + ["_NameRaterHelloText"] = true, + ["_NameRaterNamedText"] = true, + ["_NameRaterPerfectNameText"] = true, + ["_NameRaterSameNameText"] = true, + ["_NameRaterWhatNameText"] = true, + ["_NameRaterWhichMonText"] = true, + ["_NewDexDataText"] = true, + ["_NoCoinCaseText"] = true, + ["_NoCoinsText"] = true, + ["_NoCyclingText"] = true, + ["_NoPhotoText"] = true, + ["_NoRoomForEggText"] = true, + ["_NoRoomTMHMText"] = true, + ["_NotEnoughMoneyText"] = true, + ["_NotYetText"] = true, + ["_NothingHereText"] = true, + ["_NothingToChooseText"] = true, + ["_NothingToPutAwayText"] = true, + ["_NothingToSellText"] = true, + ["_OKComeBackText"] = true, + ["_OPT_AddictivelyText"] = true, + ["_OPT_AlmostPoisonouslyText"] = true, + ["_OPT_AptlyNamedText"] = true, + ["_OPT_BoldSortOfText"] = true, + ["_OPT_CuteText"] = true, + ["_OPT_EvolutionMustBeText"] = true, + ["_OPT_ExcitingText"] = true, + ["_OPT_FlippedOutText"] = true, + ["_OPT_FriendlyText"] = true, + ["_OPT_FrighteningText"] = true, + ["_OPT_GuardedText"] = true, + ["_OPT_HeartMeltinglyText"] = true, + ["_OPT_HotHotHotText"] = true, + ["_OPT_InspiringText"] = true, + ["_OPT_IntroText1"] = true, + ["_OPT_IntroText2"] = true, + ["_OPT_IntroText3"] = true, + ["_OPT_LooksInWaterText"] = true, + ["_OPT_LovelyText"] = true, + ["_OPT_MaryText1"] = true, + ["_OPT_MischievouslyText"] = true, + ["_OPT_NowText"] = true, + ["_OPT_OakText1"] = true, + ["_OPT_OakText2"] = true, + ["_OPT_OakText3"] = true, + ["_OPT_PleasantText"] = true, + ["_OPT_PokemonChannelText"] = true, + ["_OPT_PowerfulText"] = true, + ["_OPT_ProvocativelyText"] = true, + ["_OPT_SensuallyText"] = true, + ["_OPT_SpeedyText"] = true, + ["_OPT_StimulatingText"] = true, + ["_OPT_SuaveDebonairText"] = true, + ["_OPT_SweetAdorablyText"] = true, + ["_OPT_TopicallyText"] = true, + ["_OPT_UnbearablyText"] = true, + ["_OPT_UndeniablyKindOfText"] = true, + ["_OPT_WeirdText"] = true, + ["_OPT_WigglySlicklyText"] = true, + ["_OPT_WowImpressivelyText"] = true, + ["_OTSendsText"] = true, + ["_OakPCText1"] = true, + ["_OakPCText2"] = true, + ["_OakPCText3"] = true, + ["_OakPCText4"] = true, + ["_OakRating01"] = true, + ["_OakRating02"] = true, + ["_OakRating03"] = true, + ["_OakRating04"] = true, + ["_OakRating05"] = true, + ["_OakRating06"] = true, + ["_OakRating07"] = true, + ["_OakRating08"] = true, + ["_OakRating09"] = true, + ["_OakRating10"] = true, + ["_OakRating11"] = true, + ["_OakRating12"] = true, + ["_OakRating13"] = true, + ["_OakRating14"] = true, + ["_OakRating15"] = true, + ["_OakRating16"] = true, + ["_OakRating17"] = true, + ["_OakRating18"] = true, + ["_OakRating19"] = true, + ["_OakText1"] = true, + ["_OakText2"] = true, + ["_OakText3"] = true, + ["_OakText4"] = true, + ["_OakText5"] = true, + ["_OakText6"] = true, + ["_OakText7"] = true, + ["_OakThisIsntTheTimeText"] = true, + ["_OakTimeHoursQuestionMarkText"] = true, + ["_OakTimeHowManyMinutesText"] = true, + ["_OakTimeIsItText"] = true, + ["_OakTimeMinutesQuestionMarkText"] = true, + ["_OakTimeOversleptText"] = true, + ["_OakTimeSoDarkText"] = true, + ["_OakTimeWhatDayIsItText"] = true, + ["_OakTimeWhatHoursText"] = true, + ["_OakTimeWhatTimeIsItText"] = true, + ["_OakTimeWhoaMinutesText"] = true, + ["_OakTimeWokeUpText"] = true, + ["_OakTimeYikesText"] = true, + ["_ObjectEventText"] = true, + ["_ObtainedFruitText"] = true, + ["_ObtainedTheVoltorbBadgeText"] = true, + ["_OhFineThenText"] = true, + ["_OnlyOneMonText"] = true, + ["_PCCantDepositLastMonText"] = true, + ["_PCCantTakeText"] = true, + ["_PCGottaHavePokemonText"] = true, + ["_PCMonHoldingMailText"] = true, + ["_PCNoSingleMonText"] = true, + ["_PCWhatText"] = true, + ["_PPIsMaxedOutText"] = true, + ["_PPRestoredText"] = true, + ["_PPsIncreasedText"] = true, + ["_PackEmptyText"] = true, + ["_PackNoItemText"] = true, + ["_PasswordAskEnterText"] = true, + ["_PasswordAskResetClockText"] = true, + ["_PasswordAskResetText"] = true, + ["_PasswordWrongText"] = true, + ["_PerfectHeresYourMonText"] = true, + ["_PharmacyComeAgainText"] = true, + ["_PharmacyFinalPriceText"] = true, + ["_PharmacyHowManyText"] = true, + ["_PharmacyIntroText"] = true, + ["_PharmacyNoMoneyText"] = true, + ["_PharmacyPackFullText"] = true, + ["_PharmacyThanksText"] = true, + ["_PhoneClickText"] = true, + ["_PhoneEllipseText"] = true, + ["_PhoneJustTalkToThemText"] = true, + ["_PhoneOutOfAreaText"] = true, + ["_PhoneThankYouText"] = true, + ["_PhoneWrongNumberText"] = true, + ["_PlayedFluteText"] = true, + ["_PlayerFoundItemText"] = true, + ["_PlayerPickedUpPayDayMoney"] = true, + ["_PlayersPCAskWhatDoText"] = true, + ["_PlayersPCDepositItemsText"] = true, + ["_PlayersPCHowManyDepositText"] = true, + ["_PlayersPCHowManyWithdrawText"] = true, + ["_PlayersPCNoItemsText"] = true, + ["_PlayersPCNoRoomDepositText"] = true, + ["_PlayersPCNoRoomWithdrawText"] = true, + ["_PlayersPCTurnOnText"] = true, + ["_PlayersPCWithdrewItemsText"] = true, + ["_PnP_BoldText"] = true, + ["_PnP_CoolText"] = true, + ["_PnP_CuteText"] = true, + ["_PnP_GreatText"] = true, + ["_PnP_HappyText"] = true, + ["_PnP_InspiringText"] = true, + ["_PnP_LazyText"] = true, + ["_PnP_MyTypeText"] = true, + ["_PnP_NoisyText"] = true, + ["_PnP_OddText"] = true, + ["_PnP_PickyText"] = true, + ["_PnP_PrecociousText"] = true, + ["_PnP_RightForMeText"] = true, + ["_PnP_SoSoText"] = true, + ["_PnP_SortOfOKText"] = true, + ["_PnP_Text1"] = true, + ["_PnP_Text2"] = true, + ["_PnP_Text3"] = true, + ["_PnP_Text4"] = true, + ["_PnP_Text5"] = true, + ["_PnP_WeirdText"] = true, + ["_PocketIsFullText"] = true, + ["_PoisonFaintText"] = true, + ["_PoisonWhiteoutText"] = true, + ["_PokecenterBillsPCText"] = true, + ["_PokecenterOaksPCText"] = true, + ["_PokecenterPCCantUseText"] = true, + ["_PokecenterPCOaksClosedText"] = true, + ["_PokecenterPCTurnOnText"] = true, + ["_PokecenterPCWhoseText"] = true, + ["_PokecenterPlayersPCText"] = true, + ["_PokedexShowText"] = true, + ["_PokegearAskDeleteText"] = true, + ["_PokegearAskWhoCallText"] = true, + ["_PokegearPressButtonText"] = true, + ["_PokemonAskSwapItemText"] = true, + ["_PokemonHoldItemText"] = true, + ["_PokemonNotEnoughHPText"] = true, + ["_PokemonNotHoldingText"] = true, + ["_PokemonRemoveMailText"] = true, + ["_PokemonSwapItemText"] = true, + ["_PokemonTookItemText"] = true, + ["_PrestoAllDoneText"] = true, + ["_PutAwayAndSetUpText"] = true, + ["_PutAwayTheDecoText"] = true, + ["_PutItemInPocketText"] = true, + ["_RaiseThePPOfWhichMoveText"] = true, + ["_ReceiveItemText"] = true, + ["_ReceivedEggText"] = true, + ["_ReceivedItemText"] = true, + ["_ReceivedTMHMText"] = true, + ["_RecoveredSomeHPText"] = true, + ["_RegisteredItemText"] = true, + ["_RemainingTimeText"] = true, + ["_RemoveMailText"] = true, + ["_RepelUsedEarlierIsStillInEffectText"] = true, + ["_RepelWoreOffText"] = true, + ["_RestoreThePPOfWhichMoveText"] = true, + ["_RetrieveMysteryGiftText"] = true, + ["_RevitalizedText"] = true, + ["_RidOfParalysisText"] = true, + ["_RocketRadioText1"] = true, + ["_RocketRadioText10"] = true, + ["_RocketRadioText2"] = true, + ["_RocketRadioText3"] = true, + ["_RocketRadioText4"] = true, + ["_RocketRadioText5"] = true, + ["_RocketRadioText6"] = true, + ["_RocketRadioText7"] = true, + ["_RocketRadioText8"] = true, + ["_RocketRadioText9"] = true, + ["_RodBiteText"] = true, + ["_RodNothingText"] = true, + ["_SaveFileCorruptedText"] = true, + ["_SavedTheGameText"] = true, + ["_SavingDontTurnOffThePowerText"] = true, + ["_SavingRecordText"] = true, + ["_SentTrophyHomeText"] = true, + ["_SetUpTheDecoText"] = true, + ["_SlotsBetHowManyCoinsText"] = true, + ["_SlotsDarnText"] = true, + ["_SlotsLinedUpText"] = true, + ["_SlotsNotEnoughCoinsText"] = true, + ["_SlotsPlayAgainText"] = true, + ["_SlotsRanOutOfCoinsText"] = true, + ["_SlotsStartText"] = true, + ["_SpaceSpaceColonText"] = true, + ["_SquirtbottleNothingText"] = true, + ["_StartMenuContestEndText"] = true, + ["_StopLearningMoveText"] = true, + ["_StoppedEvolvingText"] = true, + ["_SweetScentNothingText"] = true, + ["_TMHMNotCompatibleText"] = true, + ["_TakeGoodCareOfEggText"] = true, + ["_TakeGoodCareOfMonText"] = true, + ["_TeleportReturnText"] = true, + ["_TestEventText"] = true, + ["_ThatCantBeUsedRightNowText"] = true, + ["_ThatItemCantBePutInThePackText"] = true, + ["_ThatsEnoughComeBackText"] = true, + ["_TheBoxIsFullText"] = true, + ["_TheItemWasPutInThePackText"] = true, + ["_ThePasswordIsText"] = true, + ["_ThereIsNoEggText"] = true, + ["_ThrewAwayText"] = true, + ["_TimeAskOkayText"] = true, + ["_TimesetAskAdjustDSTText"] = true, + ["_TimesetAskDSTText"] = true, + ["_TimesetAskNotDSTText"] = true, + ["_TimesetDSTText"] = true, + ["_TimesetNotDSTText"] = true, + ["_UnusedNothingHereText"] = true, + ["_UseCutText"] = true, + ["_UseDigText"] = true, + ["_UseEscapeRopeText"] = true, + ["_UseHeadbuttText"] = true, + ["_UseRockSmashText"] = true, + ["_UseSacredAshText"] = true, + ["_UseStrengthText"] = true, + ["_UseSweetScentText"] = true, + ["_UseWaterfallText"] = true, + ["_UseWhirlpoolText"] = true, + ["_UsedInsteadText"] = true, + ["_UsedMove1Text"] = true, + ["_UsedMove2Text"] = true, + ["_UsedSurfText"] = true, + ["_WaitButtonText"] = true, + ["_WarpingText"] = true, + ["_WasDefrostedText"] = true, + ["_WasSentToBillsPCText"] = true, + ["_WhatDoYouWantToPlayWithText"] = true, + ["_WhatShouldIRaiseText"] = true, + ["_WhichMonPhotoText"] = true, + ["_WhichSidePutAwayText"] = true, + ["_WhichSidePutOnText"] = true, + ["_WhitedOutText"] = true, + ["_WillTradeText"] = true, + ["_WillYouPlayWithMonText"] = true, + ["_WindowAreaExceededErrorText"] = true, + ["_WindowPoppingErrorText"] = true, + ["_WokeUpText"] = true, + ["_WouldYouLikeToSaveTheGameText"] = true, + ["_YouCanHaveThisText"] = true, + ["_YouCantUseItInABattleText"] = true, + ["_YouDontHaveAMonText"] = true, + ["_YouNeedTwoMonForBreedingText"] = true, + ["_YourFoesWeakGetmMonText"] = true, + ["_YourFriendIsNotReadyText"] = true, + ["_YourMonHasGrownText"] = true, + ["_YourMonsHPWasHealedText"] = true, +} diff --git a/tests/engine/rom_text_labels.lua b/tests/engine/rom_text_labels.lua new file mode 100644 index 000000000..b028120dd --- /dev/null +++ b/tests/engine/rom_text_labels.lua @@ -0,0 +1,22 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local L = require("src.mods.RomTextLabels") +T.eq(package.loaded["src.mods.rom_text_labels.firered"], nil, "label data loads lazily") +T.eq(L.forVersion("red"), nil, "GB does not inherit a Gen-2/3 prose catalog") +T.eq(L.forVersion("platinum"), nil, "DS has no claimed label catalog") +T.eq(L.forVersion("ruby"), nil, "unregistered host GBA profile has no guessed offsets") +T.eq(L.forVersion("gold")._WokeUpText, true, "Gen-2 prose label is known") +T.eq(L.forVersion("gold")["55:4067"], nil, "VM bank/address is not engine prose") +T.eq(L.forVersion("gold").TEXT_S55_4067, nil, "host physical VM key is not engine prose") +T.eq(L.address("gold", "_WokeUpText"), nil, "Gen-2 labels are not mistaken for GBA addresses") +T.eq(L.address("firered", "gText_BigGuy"), 0x417FBB, "FireRed exact storage-label address") +T.eq(L.address("emerald", "gText_BigGuy"), 0x5EB5F9, "Emerald exact storage-label address") +T.eq(L.address("firered", "sText_PkmnWokeUp"), 0x3FBCD1, "FireRed native battle-label address") +T.eq(L.address("emerald", "sText_PkmnWokeUp"), 0x5CA541, "Emerald native battle-label address") +local _, battle = L.address("emerald", "sText_PkmnWokeUp") +T.eq(battle, true, "battle code dialect remains explicit") +local _, ordinary = L.address("firered", "gText_BigGuy") +T.eq(ordinary, false, "field/menu code dialect remains explicit") +T.eq(L.address("emerald", "UNKNOWN_MOD_LABEL"), nil, "unknown label does not acquire an offset") +T.eq(L.forVersion("emerald"), L.forVersion("emerald"), "native label dictionary is cached") +T.finish("native prose-label provenance facts") diff --git a/tools/rom-text-contract-check/README.md b/tools/rom-text-contract-check/README.md new file mode 100644 index 000000000..d608aad9c --- /dev/null +++ b/tools/rom-text-contract-check/README.md @@ -0,0 +1,25 @@ +# ROM-text portability work + +This branch is preparing the separate `mod.content.rom_text` registry. The +formatter and provenance facts below are prerequisites; they do not advertise +the registry or complete native UI/battle label coverage yet. + +`check-format.py --gen1 /clean/gen1 --lua luajit` compares actual native +GB/Gen-2 prose formatting against the clean reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The host's real battle reader uses +the common formatter. Cases cover named and ordinal substitutions, deferred +player/rival tokens, end markers, missing prose and mismatch/fallback policy. +Printf-shaped strings do not invent dynamic slots in this native contract. + +`generate-labels.py --gen1 /clean/gen1 --lua luajit` regenerates name/address +facts from the reference's public import metadata. `--check` verifies all +committed bytes without rewriting either checkout. No cartridge prose, ROM, +importer implementation or source scripts are copied. Gen-2 manifests supply +prose-label membership; GBA profiles resolve their exact ordinary/battle text +addresses through their native symbol tables. Text and battle-code dialects +remain distinct. A lazy host accessor avoids loading unneeded profile tables +and refuses to guess addresses for absent profiles. + +These facts establish provenance. They do not, by themselves, prove that a +host menu reads a label dynamically: pre-extracted UI constants need explicit +bindings and native consumer tests before the public registry is published. diff --git a/tools/rom-text-contract-check/export-labels.lua b/tools/rom-text-contract-check/export-labels.lua new file mode 100644 index 000000000..5bdcd26df --- /dev/null +++ b/tools/rom-text-contract-check/export-labels.lua @@ -0,0 +1,11 @@ +-- Read address/name facts from the pinned reference's public import metadata. +package.path = "./?.lua;./?/init.lua;" .. package.path +local version = assert(arg[1]) +local V = require("src.core.GameVersion") +V.set(version) +local M = require("src.import.gba.versions") +M.select(version) +print("ROM_LABEL_FACTS " .. require("src.link.Json").encode({ + text = M.NAMED_TEXTS, + battle = M.NAMED_BATTLE_TEXTS, +})) diff --git a/tools/rom-text-contract-check/generate-labels.py b/tools/rom-text-contract-check/generate-labels.py new file mode 100644 index 000000000..4619e0ff7 --- /dev/null +++ b/tools/rom-text-contract-check/generate-labels.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Generate label/address facts without copying any ROM text or importer code.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + +REFERENCE = '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91' +ROOT = Path(__file__).resolve().parents[2] + + +def git(root, *args): + return subprocess.check_output(['git', '-C', str(root), *args], text=True).strip() + + +def header(version): + return (f'-- Generated label/address facts for {version}; no cartridge prose.\n' + f'-- Gen1Recomp public import metadata at {REFERENCE}.\n' + '-- Regenerate with tools/rom-text-contract-check/generate-labels.py.\n') + + +def encode(name): + if not re.fullmatch(r'[A-Za-z0-9_:.]+', name): + raise ValueError('unexpected native symbol name: ' + name) + return '[' + json.dumps(name) + ']' + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--gen1', type=Path, required=True) + p.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) + p.add_argument('--check', action='store_true') + a = p.parse_args() + source = a.gen1.resolve() + if git(source, 'rev-parse', 'HEAD') != REFERENCE or git(source, 'status', '--porcelain'): + p.error('Gen1 must be clean at ' + REFERENCE) + output = ROOT / 'src/mods/rom_text_labels' + generated = {} + for version in ['gold', 'silver', 'crystal']: + manifest = json.loads((source / 'tools' / ('rom_manifest_' + version + '.json')).read_text()) + labels = manifest['text']['labels'] + assert len(set(labels)) == len(labels) + generated[version] = header(version) + 'return {\n' + ''.join( + f' {encode(label)} = true,\n' for label in sorted(labels)) + '}\n' + print(version, len(labels), 'native prose labels') + for version in ['firered', 'emerald']: + run = subprocess.run([a.lua, str(ROOT / 'tools/rom-text-contract-check/export-labels.lua'), version], + cwd=source, text=True, capture_output=True, timeout=30) + lines = [x[len('ROM_LABEL_FACTS '):] for x in run.stdout.splitlines() if x.startswith('ROM_LABEL_FACTS ')] + if run.returncode or len(lines) != 1: + raise RuntimeError(run.stdout + run.stderr) + facts = json.loads(lines[0]) + content = header(version) + 'return {\n' + for kind in ['text', 'battle']: + content += f' {kind} = {{\n' + for label, offset in sorted(facts[kind].items()): + assert isinstance(offset, int) and 0 <= offset < 0x1000000, (version, label, offset) + content += f' {encode(label)} = 0x{offset:06X},\n' + content += ' },\n' + generated[version] = content + '}\n' + print(version, len(facts['text']), 'text /', len(facts['battle']), 'battle addresses') + assert not git(source, 'status', '--porcelain'), 'reference changed' + if not a.check: + output.mkdir(parents=True, exist_ok=True) + for version, content in generated.items(): + path = output / (version + '.lua') + if a.check: + if path.read_text() != content: + raise AssertionError(f'stale label facts: {path}') + else: + path.write_text(content) + + +if __name__ == '__main__': + main() From 52541d1b0160de2361a2bffe15fd4fad4eb9a20c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:17:52 +0200 Subject: [PATCH 70/86] Add isolated ROM-prose registry and native Gen-2/GBA storage readers --- .github/workflows/rom-text-contract.yml | 41 + docs/api3-rom-text.md | 85 + examples/portable_rom_text/main.lua | 55 + examples/portable_rom_text/manifest.json | 8 + src/core/RomText.lua | 8 +- src/mods/Loader.lua | 5 + src/mods/RomTextContent.lua | 243 + src/mods/RomTextLabels.lua | 13 +- src/mods/Schemas.lua | 5 + src/mods/TextContent.lua | 50 +- src/mods/UnifiedContent.lua | 3 + src/mods/rom_text_labels/emerald.lua | 6870 ----------------- src/mods/rom_text_labels/firered.lua | 3708 --------- src/render/TextBox.lua | 5 + src/script/Commands.lua | 5 +- src/script/Gen2Commands.lua | 4 +- src/script/ScriptText.lua | 12 + src/ui/Gen3StorageMenu.lua | 4 +- tests/drivers/rom_text.lua | 85 + tests/engine/mod_supports.lua | 3 +- tests/engine/rom_text_authority.lua | 204 + tests/engine/rom_text_content.lua | 6 + tests/engine/rom_text_labels.lua | 23 +- tests/rom_text/cases.lua | 86 + tests/rom_text/fixture.lua | 145 + tests/rom_text/format_cases.lua | 1 + tests/rom_text/labels_emerald.lua | 18 + tests/rom_text/labels_firered.lua | 18 + tests/rom_text/worker.lua | 5 + tools/rom-text-contract-check/README.md | 13 +- tools/rom-text-contract-check/check.py | 56 + .../generate-labels.py | 13 +- 32 files changed, 1178 insertions(+), 10622 deletions(-) create mode 100644 .github/workflows/rom-text-contract.yml create mode 100644 docs/api3-rom-text.md create mode 100644 examples/portable_rom_text/main.lua create mode 100644 examples/portable_rom_text/manifest.json create mode 100644 src/mods/RomTextContent.lua delete mode 100644 src/mods/rom_text_labels/emerald.lua delete mode 100644 src/mods/rom_text_labels/firered.lua create mode 100644 src/script/ScriptText.lua create mode 100644 tests/drivers/rom_text.lua create mode 100644 tests/engine/rom_text_authority.lua create mode 100644 tests/engine/rom_text_content.lua create mode 100644 tests/rom_text/cases.lua create mode 100644 tests/rom_text/fixture.lua create mode 100644 tests/rom_text/labels_emerald.lua create mode 100644 tests/rom_text/labels_firered.lua create mode 100644 tests/rom_text/worker.lua create mode 100644 tools/rom-text-contract-check/check.py diff --git a/.github/workflows/rom-text-contract.yml b/.github/workflows/rom-text-contract.yml new file mode 100644 index 000000000..2faab3a33 --- /dev/null +++ b/.github/workflows/rom-text-contract.yml @@ -0,0 +1,41 @@ +name: portable ROM prose +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: rom-text-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Validate native readers and namespace ownership + run: | + luajit tests/engine/rom_text_content.lua + luajit tests/engine/rom_text_authority.lua + luajit tests/engine/rom_text_format.lua + luajit tests/engine/rom_text_labels.lua + - name: Verify provenance and compare native contracts + run: | + mkdir -p /tmp/rom-text-contract + python3 tools/rom-text-contract-check/generate-labels.py --gen1 .contract-reference/gen1 --check + python3 tools/rom-text-contract-check/check-format.py --gen1 .contract-reference/gen1 --report /tmp/rom-text-contract/format.json + python3 tools/rom-text-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/rom-text-contract/api3.json + python3 tools/rom-text-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/rom-text-contract/host2.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: rom-text-contract + path: /tmp/rom-text-contract + if-no-files-found: ignore diff --git a/docs/api3-rom-text.md b/docs/api3-rom-text.md new file mode 100644 index 000000000..bb04e7e9d --- /dev/null +++ b/docs/api3-rom-text.md @@ -0,0 +1,85 @@ +# Portable engine prose + +`mod.content.rom_text` exposes label-keyed prose on Gen 2 and the registered +GBA games. GB and DS report this registry unavailable. API 3 and API 2/unified +use the shared source-string grammar from [script text](api3-text-sources.md). +The host's native API-1/2 facade uses strings; the reference's native GBA IR +format is not a portable native-API-2 format. + +```lua +local mod = ... +if mod:supports("content", "rom_text") then + mod.content.rom_text:override("_WokeUpText", "{USER} woke up!") +end +``` + +The label belongs to its native generation. Gen-2 prose has an imported base; +GBA's registry is a separate overrides map, so an untouched label reads nil +while the native screen still uses its cartridge text. Removing a Gen-2 label +makes native prose readers use their fallback. Removing a GBA override exposes +the cartridge label again. `register`, `override`, `patch`, `remove`, detached +scalar reads, ordered iteration, failure rollback and frozen writes use the +ordinary registry contract. + +The native GB/Gen-2 formatter supports positional and named brace slots. With +one table argument, braced or unbraced token keys replace only supplied values. +Player/rival tokens can remain for the textbox. `{DONE}` and `{PROMPT}` are end +metadata, not substitution slots or visible prose. A `%s` in the source is not +a brace slot: an incompatible call retains the native fallback policy. + +## Native readers covered here + +- Gen-2 native battle prose uses the shared formatter. Overrides of the exact + imported prose labels also project into the existing native label table, + reaching existing native readers of those labels. The source-derived + membership lists are names only, with no cartridge prose. +- GBA Pokemon-storage actions and their descriptions have ten explicit native + label bindings. The real storage-menu constructor reads detached views of + these overrides, including its older-cache fallback. Imported constants are + unchanged. All five actions/descriptions are tested through both engines' + actual native menu readers; this does not invent a generic word matcher. + +**Registry availability does not mean every native GBA UI/battle reader has +been bound.** Party, item-PC, other field menus and GBA battle-message contexts +need further explicit adapters. Likewise, a Gen-2 screen that still prints a +hardcoded fallback rather than reading its ROM label needs a native reader +change. This PR is a shared registry and its first verified native consumers, +not complete translation coverage or universal printer/glyph equivalence. + +## Script/prose isolation + +An API-3 Gen-2 script and engine prose may both be called `_WokeUpText`. The +script namespace starts without the prose label; an authored script value is +stored separately and the native VM/text commands read that value. A removed +or empty private script prints the native missing-script placeholder instead +of revealing engine prose. Gen-2 `bank:address` IDs still reach their original +physical VM entries. Generated internal keys do not remain in the legacy text +table; authored labels beginning with the private prefix are escaped too. +Native API-1/2 legacy text keys retain their existing host access path. + +Successful prose and private-script writes retire on reload only while their +installed values and target identities remain present. External edits and +unrelated native values survive. The private projections do not consume RNG or +construct Game during discovery or capability checks. + +## Verification + +```sh +luajit tests/engine/rom_text_content.lua +luajit tests/engine/rom_text_authority.lua +luajit tests/engine/rom_text_format.lua +luajit tests/engine/rom_text_labels.lua +python3 tools/rom-text-contract-check/check.py --gen1 /path/to/clean/gen1 +python3 tools/rom-text-contract-check/check.py --gen1 /path/to/clean/gen1 --host-api 2 +python3 tools/rom-text-contract-check/check-format.py --gen1 /path/to/clean/gen1 +python3 tools/rom-text-contract-check/generate-labels.py --gen1 /path/to/clean/gen1 --check +``` + +The reference is pinned at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +API-2 host opt-in is compared with the reference's API 3, not mislabeled as +reference API-2/unified. The public consumer has no engine imports. Source +formatting, namespace isolation and native reader observations are separate +from imported-data/UI smoke tests. No ROM, imported cache or save is included. +The LÖVE driver is `tests/drivers/rom_text.lua`; use the isolated testfork +launcher. Validation of the complete combined runtime remains required before +publishing this working branch as a PR. diff --git a/examples/portable_rom_text/main.lua b/examples/portable_rom_text/main.lua new file mode 100644 index 000000000..555a297d8 --- /dev/null +++ b/examples/portable_rom_text/main.lua @@ -0,0 +1,55 @@ +-- Same public consumer on both SDKs. Labels select the game's native prose; +-- the mod never imports an engine module or replaces a renderer. +local mod = ... +local prose = mod.content.rom_text +mod.exports.api = mod +mod.exports.available = prose.available == true +if not mod.exports.available then + return +end +local mode = mod:read("case.txt") or "replace" +local label = mod:read("label.txt") or "gText_WithdrawPokemon" +local value = mod:read("source.txt") or "TAKE POKéMON" +mod.exports.before = prose:get(label) +if mode == "remove" then + prose:remove(label) +elseif mode == "register" then + mod.exports.value, mod.exports.reason = prose:register(label, value) +elseif mode == "patch" then + mod.exports.value, mod.exports.reason = prose:patch(label, value) +elseif mode == "delete" then + prose:patch(label, mod.DELETE) +else + mod.exports.value, mod.exports.reason = prose:override(label, value) +end +if mode == "storage-all" then + local labels = + { "gText_WithdrawPokemon", "gText_DepositPokemon", "gText_MovePokemon", "gText_MoveItems", "gText_SeeYa" } + local descriptions = { + "gText_WithdrawMonDescription", + "gText_DepositMonDescription", + "gText_MoveMonDescription", + "gText_MoveItemsDescription", + "gText_SeeYaDescription", + } + for i, id in ipairs(labels) do + prose:override(id, "MOD_ROW_" .. i) + end + for i, id in ipairs(descriptions) do + prose:override(id, "MOD_DESC_" .. i) + end +end +mod.exports.after = prose:get(label) +if mod:read("script.txt") then + mod.exports.scriptBefore = mod.content.text:get(label) + mod.content.text:register(label, mod:read("script.txt")) + mod.exports.scriptAfter = mod.content.text:get(label) +end +if mode == "namespace-prefix" then + local id = "__portable_script_text__:" .. label + mod.content.text:register(id, "AUTHORED PREFIX") + mod.exports.prefixValue = mod.content.text:get(id) +end +if mode == "rollback" then + error("intentional ROM-prose entry rollback") +end diff --git a/examples/portable_rom_text/manifest.json b/examples/portable_rom_text/manifest.json new file mode 100644 index 000000000..4faf140d2 --- /dev/null +++ b/examples/portable_rom_text/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_rom_text", + "name": "Portable ROM prose", + "version": "1.0.0", + "api": 3, + "games": ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"], + "entry": "main.lua" +} diff --git a/src/core/RomText.lua b/src/core/RomText.lua index e208eb54b..82f3417cd 100644 --- a/src/core/RomText.lua +++ b/src/core/RomText.lua @@ -3,8 +3,12 @@ local Strings = require("src.core.Strings") local ENDING = { ["{DONE}"] = true, ["{PROMPT}"] = true } return function(data, label, fallback, ...) - local text = data and data.text and data.text[label] - if text == nil then + local prose = data and data._romTextGeneration == 2 and data.romText + local text = prose and prose[label] or nil + if text == nil and not (prose and data._romTextTouched and data._romTextTouched[label]) then + text = data and data.text and data.text[label] + end + if not text then if fallback == nil then error("ROM text " .. tostring(label) .. " is not in the cache", 2) end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 915c316b7..f91fc75c9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -759,6 +759,9 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "rom_text" then + return require("src.mods.RomTextContent").api(loader, mod, api, loader:_generation(), false) + end if registry.name == "held_items" then return require("src.mods.HeldItemContent").api(loader, mod, api, loader:_generation(), false) end @@ -1423,6 +1426,7 @@ function Loader:load(data) end require("src.mods.HeldItemContent").prepare(self, data) require("src.mods.MartContent").prepare(self, data) + require("src.mods.RomTextContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1656,6 +1660,7 @@ function Loader:load(data) end require("src.mods.HeldItemContent").apply(self, data) require("src.mods.MartContent").apply(self, data) + require("src.mods.RomTextContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/RomTextContent.lua b/src/mods/RomTextContent.lua new file mode 100644 index 000000000..5e2f7846e --- /dev/null +++ b/src/mods/RomTextContent.lua @@ -0,0 +1,243 @@ +-- Separate engine prose from VM text. The registry is virtual so the native +-- VM addresses retain their original storage. Known Gen-2 prose labels also +-- project into the legacy table so existing native UI readers consume them. +local Copy = require("src.mods.Merge").deepCopy +local Source = require("src.mods.TextContent") +local Labels = require("src.mods.RomTextLabels") +local V = require("src.core.GameVersion") +local M = {} +local previous = setmetatable({}, { __mode = "k" }) +local function same(a, b) + 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 +function M.prepare(loader, data) + local old = data and previous[data] + if old then + if data.romText == old.value then + if same(old.value, old.snapshot) then + data.romText, data._romTextGeneration, data._romTextTouched = + old.before, old.beforeGeneration, old.beforeTouched + else + -- Preserve intervening external edits while retiring untouched keys. + for id in pairs(old.touched) do + if old.value[id] == old.snapshot[id] then + old.value[id] = old.before and old.before[id] or nil + data._romTextTouched[id] = old.beforeTouched and old.beforeTouched[id] or nil + end + end + end + end + for _, row in ipairs(old.projections or {}) do + if data.text == row.target and row.target[row.key] == row.value then + row.target[row.key] = row.before + end + end + previous[data] = nil + end + local base, gen = {}, loader:_generation() + loader._romTextNativeIds = {} + loader._romTextLabels = nil + loader._privateScriptTextPrefix = "__portable_script_text__:" + if gen == 2 then + -- Reserve an unused prefix, and escape authored IDs beginning with it. + -- This keeps internal registry keys disjoint from native base records. + local occupied = true + while occupied do + occupied = false + for id in pairs(data and data.text or {}) do + if + type(id) == "string" + and id:sub(1, #loader._privateScriptTextPrefix) == loader._privateScriptTextPrefix + then + loader._privateScriptTextPrefix = loader._privateScriptTextPrefix .. "_" + occupied = true + break + end + end + end + local known = Labels.forVersion(V.get()) + loader._romTextLabels = Copy(known or {}) + if known then + for id in pairs(known) do + local value = data and data.text and data.text[id] + if type(value) == "string" then + base[id] = value + loader._romTextNativeIds[id] = true + end + end + end + end + for id, value in pairs(data and data.romText or {}) do + base[id] = value + loader._romTextNativeIds[id] = nil + if gen == 2 then + loader._romTextLabels[id] = true + end + end + loader._romTextBase = base + loader.content.rom_text.base = function() + return base + end +end + +-- Stable semantic bindings for the native Pokemon-storage actions. Each row +-- is read from sPokemonStorageActions by the importer; no word matching is used. +local STORAGE_LABELS = { + "gText_WithdrawPokemon", + "gText_DepositPokemon", + "gText_MovePokemon", + "gText_MoveItems", + "gText_SeeYa", +} +local STORAGE_DESCRIPTIONS = { + "gText_WithdrawMonDescription", + "gText_DepositMonDescription", + "gText_MoveMonDescription", + "gText_MoveItemsDescription", + "gText_SeeYaDescription", +} +function M.storageView(game, base) + local data = game and game.data + local overrides = data and data._romTextGeneration == 3 and data.romText + if not overrides then + return base + end + local out + for field, labels in pairs({ rows = STORAGE_LABELS, describe = STORAGE_DESCRIPTIONS }) do + for i, label in ipairs(labels) do + local value = overrides[label] + if value ~= nil then + out = out or Copy(base) + out[field] = out[field] or {} + local text = require("src.render.TextBox").substitute(game, value) + out[field][i] = text:gsub("[\f\v]", "\n\n") + end + end + end + return out or base +end +function M.apply(loader, data) + if not data then + return + end + -- Private script records for labels that also exist in the engine-prose + -- namespace never remain as synthetic keys in the legacy text table. + for physical, logical in pairs(loader._privateScriptTextIds or {}) do + if loader.content.text.ops[physical] then + data._portableScriptText = data._portableScriptText or {} + data._portableScriptText[logical] = assert(loader._privateScriptTextRows[physical]) + data.text[physical] = nil + end + end + local gen, registry = loader:_generation(), loader.content.rom_text + if (gen ~= 2 and gen ~= 3) or next(registry.ops) == nil then + return + end + if gen == 2 then + for id in pairs(loader._romTextNativeIds) do + if not registry.ops[id] then + loader._romTextBase[id] = data.text and data.text[id] + registry.cache[id] = nil + end + end + end + local values, touched = Copy(loader._romTextBase), {} + for id in pairs(registry.ops) do + values[id], touched[id] = registry:get(id), true + end + local undo = { + before = data.romText, + beforeGeneration = data._romTextGeneration, + beforeTouched = data._romTextTouched, + value = values, + snapshot = Copy(values), + touched = touched, + projections = {}, + } + if gen == 2 then + local known = Labels.forVersion(V.get()) or {} + for id in pairs(touched) do + if known[id] then + data.text = data.text or {} + undo.projections[#undo.projections + 1] = + { target = data.text, key = id, before = data.text[id], value = values[id] } + data.text[id] = values[id] + end + end + end + data.romText, data._romTextGeneration, data._romTextTouched = values, gen, touched + previous[data] = undo +end + +function M.api(loader, mod, native, generation, unified) + local version = V.get() + local available = generation == 2 or generation == 3 + local reason = not available and "unsupported: rom_text has no native target in this generation" or nil + local out = { + available = available, + supports = function() + return available, reason + end, + } + function out:get(id) + if not available then + return nil, reason + end + assert(type(id) == "string" and id ~= "", "rom_text: registry id is required") + local value = native:get(id) + if value ~= nil and unified then + return Source.source(value, generation, version, true) + end + return value + end + function out:has(id) + return out:get(id) ~= nil + end + function out:each() + if not available then + return function() end + end + local nextRow = native:each() + return function() + local id = nextRow() + if id then + return id, out:get(id) + end + end + end + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + out[verb] = function(_, id, value) + if not available then + return nil, reason + end + assert(not loader.content.rom_text.frozen, "rom_text: content is frozen after load") + local ok, result = pcall(function() + local payload = value + if unified and verb ~= "remove" then + payload = Source.write(value, generation, version) + end + native[verb](native, id, payload) + return out:get(id) + end) + if ok then + return result + end + if type(result) == "string" and result:sub(1, 13) == "unsupported: " then + return nil, result + end + error(result, 0) + end + end + return out +end +return M diff --git a/src/mods/RomTextLabels.lua b/src/mods/RomTextLabels.lua index 5671cd943..ee0343d3d 100644 --- a/src/mods/RomTextLabels.lua +++ b/src/mods/RomTextLabels.lua @@ -2,7 +2,7 @@ -- registry needs them. Never identify a label by matching its rendered words. local Labels = {} local cache = {} -local supported = { gold = true, silver = true, crystal = true, firered = true, emerald = true } +local supported = { gold = true, silver = true, crystal = true } function Labels.forVersion(version) if not supported[version] then return nil @@ -12,15 +12,4 @@ function Labels.forVersion(version) end return cache[version] end -function Labels.address(version, name) - local rows = Labels.forVersion(version) - if not rows or not rows.text then - return nil - end - local offset = rows.battle[name] - if offset then - return offset, true - end - return rows.text[name], false -end return Labels diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 20e5ae266..53afb3aa4 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -613,6 +613,11 @@ R.sprites = { example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', } +R.rom_text = { + semantics = "record", generations = "23", value = f.str, + example = 'mod.content.rom_text:override("_WokeUpText", "{USER} woke up!")', +} + R.text = { semantics = "record", target = "text", value = f.str, diff --git a/src/mods/TextContent.lua b/src/mods/TextContent.lua index 28fa3241a..4e8df2938 100644 --- a/src/mods/TextContent.lua +++ b/src/mods/TextContent.lua @@ -124,6 +124,19 @@ function Text.api(loader, mod, native, generation) return id, id end local canonical, physical = addresses(id) + if + canonical == physical + and loader._romTextLabels + and ( + loader._romTextLabels[id] + or id:sub(1, #loader._privateScriptTextPrefix) == loader._privateScriptTextPrefix + ) + then + local physical = loader._privateScriptTextPrefix .. id + loader._privateScriptTextIds = loader._privateScriptTextIds or {} + loader._privateScriptTextIds[physical] = id + return id, physical + end if canonical ~= physical and registry:get(canonical) ~= nil then if registry:get(physical) ~= nil then refuse("ambiguous script text ID: " .. canonical) @@ -155,8 +168,12 @@ function Text.api(loader, mod, native, generation) function api:each() local ids, seen = {}, {} for physical in native:each() do - local canonical = generation == 2 and addresses(physical) or physical - if not seen[canonical] then + local private = loader._privateScriptTextIds and loader._privateScriptTextIds[physical] + local canonical = private or (generation == 2 and addresses(physical) or physical) + if generation == 2 and loader._romTextLabels[physical] and not private then + canonical = nil + end + if canonical and not seen[canonical] then seen[canonical] = true ids[#ids + 1] = canonical end @@ -202,7 +219,11 @@ end function Text.prepare(loader, data) local undo = data and previous[data] for _, row in ipairs(undo or {}) do - if data.text == row.target and row.target[row.key] == row.after then + if + data[row.namespace or "text"] == row.target + and row.target[row.key] == row.after + and (not row.private or row.after.value == row.snapshot) + then row.target[row.key] = row.before end end @@ -210,6 +231,7 @@ function Text.prepare(loader, data) previous[data] = nil end loader._unifiedTextKeys = nil + loader._privateScriptTextIds, loader._privateScriptTextRows = nil, nil end function Text.capture(loader, data) if not data or not loader._unifiedTextKeys then @@ -225,8 +247,26 @@ function Text.capture(loader, data) end end if active then - data.text = data.text or {} - undo[#undo + 1] = { target = data.text, key = key, before = data.text[key], after = registry:get(key) } + local logical = loader._privateScriptTextIds and loader._privateScriptTextIds[key] + if logical then + data._portableScriptText = data._portableScriptText or {} + loader._privateScriptTextRows = loader._privateScriptTextRows or {} + local row = { value = registry:get(key) } + loader._privateScriptTextRows[key] = row + undo[#undo + 1] = { + target = data._portableScriptText, + key = logical, + before = data._portableScriptText[logical], + after = row, + private = true, + namespace = "_portableScriptText", + snapshot = row.value, + } + else + data.text = data.text or {} + undo[#undo + 1] = + { target = data.text, key = key, before = data.text[key], after = registry:get(key) } + end end end previous[data] = undo diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index aa01f47d5..8f91fe7d5 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -403,6 +403,9 @@ local function protect(fn) end function U.new(loader, mod, native, gen) local out = {}; for name, handle in pairs(native) do out[name] = handle end + if native.rom_text then + out.rom_text = require("src.mods.RomTextContent").api(loader, mod, native.rom_text, gen, true) + end if native.text and gen <= 3 then out.text = require("src.mods.TextContent").api(loader, mod, native.text, gen) end diff --git a/src/mods/rom_text_labels/emerald.lua b/src/mods/rom_text_labels/emerald.lua deleted file mode 100644 index 01953e685..000000000 --- a/src/mods/rom_text_labels/emerald.lua +++ /dev/null @@ -1,6870 +0,0 @@ --- Generated label/address facts for emerald; no cartridge prose. --- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Regenerate with tools/rom-text-contract-check/generate-labels.py. -return { - text = { - ["BattleColosseum_2P_Text_TakePlaceStartBattle"] = 0x2784B4, - ["BattleDome_Text_EmphasizesAtk"] = 0x2C39C1, - ["BattleDome_Text_EmphasizesAtkAndDef"] = 0x2C3862, - ["BattleDome_Text_EmphasizesAtkAndSpAtk"] = 0x2C389E, - ["BattleDome_Text_EmphasizesAtkAndSpDef"] = 0x2C38C0, - ["BattleDome_Text_EmphasizesAtkAndSpeed"] = 0x2C3881, - ["BattleDome_Text_EmphasizesDef"] = 0x2C39D4, - ["BattleDome_Text_EmphasizesDefAndSpAtk"] = 0x2C3901, - ["BattleDome_Text_EmphasizesDefAndSpDef"] = 0x2C3924, - ["BattleDome_Text_EmphasizesDefAndSpeed"] = 0x2C38E3, - ["BattleDome_Text_EmphasizesHP"] = 0x2C39B2, - ["BattleDome_Text_EmphasizesHPAndAtk"] = 0x2C37D7, - ["BattleDome_Text_EmphasizesHPAndDef"] = 0x2C37F1, - ["BattleDome_Text_EmphasizesHPAndSpAtk"] = 0x2C3825, - ["BattleDome_Text_EmphasizesHPAndSpDef"] = 0x2C3843, - ["BattleDome_Text_EmphasizesHPAndSpeed"] = 0x2C380C, - ["BattleDome_Text_EmphasizesSpAtk"] = 0x2C39FA, - ["BattleDome_Text_EmphasizesSpAtkAndSpDef"] = 0x2C398B, - ["BattleDome_Text_EmphasizesSpDef"] = 0x2C3A11, - ["BattleDome_Text_EmphasizesSpeed"] = 0x2C39E8, - ["BattleDome_Text_EmphasizesSpeedAndSpAtk"] = 0x2C3948, - ["BattleDome_Text_EmphasizesSpeedAndSpDef"] = 0x2C3969, - ["BattleDome_Text_FinalMatch"] = 0x2C3E0F, - ["BattleDome_Text_LetTheBattleBegin"] = 0x2C3C78, - ["BattleDome_Text_NeglectsAtk"] = 0x2C3BF3, - ["BattleDome_Text_NeglectsAtkAndDef"] = 0x2C3AAA, - ["BattleDome_Text_NeglectsAtkAndSpAtk"] = 0x2C3AE2, - ["BattleDome_Text_NeglectsAtkAndSpDef"] = 0x2C3B02, - ["BattleDome_Text_NeglectsAtkAndSpeed"] = 0x2C3AC7, - ["BattleDome_Text_NeglectsDef"] = 0x2C3C04, - ["BattleDome_Text_NeglectsDefAndSpAtk"] = 0x2C3B3F, - ["BattleDome_Text_NeglectsDefAndSpDef"] = 0x2C3B60, - ["BattleDome_Text_NeglectsDefAndSpeed"] = 0x2C3B23, - ["BattleDome_Text_NeglectsHP"] = 0x2C3BE6, - ["BattleDome_Text_NeglectsHPAndAtk"] = 0x2C3A29, - ["BattleDome_Text_NeglectsHPAndDef"] = 0x2C3A41, - ["BattleDome_Text_NeglectsHPAndSpAtk"] = 0x2C3A71, - ["BattleDome_Text_NeglectsHPAndSpDef"] = 0x2C3A8D, - ["BattleDome_Text_NeglectsHPAndSpeed"] = 0x2C3A5A, - ["BattleDome_Text_NeglectsSpAtk"] = 0x2C3C26, - ["BattleDome_Text_NeglectsSpAtkAndSpDef"] = 0x2C3BC1, - ["BattleDome_Text_NeglectsSpDef"] = 0x2C3C3B, - ["BattleDome_Text_NeglectsSpeed"] = 0x2C3C16, - ["BattleDome_Text_NeglectsSpeedAndSpAtk"] = 0x2C3B82, - ["BattleDome_Text_NeglectsSpeedAndSpDef"] = 0x2C3BA1, - ["BattleDome_Text_Potential1"] = 0x2C3188, - ["BattleDome_Text_Potential10"] = 0x2C329B, - ["BattleDome_Text_Potential11"] = 0x2C32B7, - ["BattleDome_Text_Potential12"] = 0x2C32D9, - ["BattleDome_Text_Potential13"] = 0x2C32FC, - ["BattleDome_Text_Potential14"] = 0x2C3312, - ["BattleDome_Text_Potential15"] = 0x2C3332, - ["BattleDome_Text_Potential16"] = 0x2C3359, - ["BattleDome_Text_Potential2"] = 0x2C31AA, - ["BattleDome_Text_Potential3"] = 0x2C31C0, - ["BattleDome_Text_Potential4"] = 0x2C31DD, - ["BattleDome_Text_Potential5"] = 0x2C31FA, - ["BattleDome_Text_Potential6"] = 0x2C321B, - ["BattleDome_Text_Potential7"] = 0x2C3240, - ["BattleDome_Text_Potential8"] = 0x2C325C, - ["BattleDome_Text_Potential9"] = 0x2C327C, - ["BattleDome_Text_PotentialDomeAceTucker"] = 0x2C3378, - ["BattleDome_Text_RaisesMonsWellBalanced"] = 0x2C3C51, - ["BattleDome_Text_Round1Match1"] = 0x2C3D1F, - ["BattleDome_Text_Round1Match2"] = 0x2C3D30, - ["BattleDome_Text_Round1Match3"] = 0x2C3D41, - ["BattleDome_Text_Round1Match4"] = 0x2C3D52, - ["BattleDome_Text_Round1Match5"] = 0x2C3D63, - ["BattleDome_Text_Round1Match6"] = 0x2C3D74, - ["BattleDome_Text_Round1Match7"] = 0x2C3D85, - ["BattleDome_Text_Round1Match8"] = 0x2C3D96, - ["BattleDome_Text_Round2Match1"] = 0x2C3DA7, - ["BattleDome_Text_Round2Match2"] = 0x2C3DB8, - ["BattleDome_Text_Round2Match3"] = 0x2C3DC9, - ["BattleDome_Text_Round2Match4"] = 0x2C3DDA, - ["BattleDome_Text_SemifinalMatch1"] = 0x2C3DEB, - ["BattleDome_Text_SemifinalMatch2"] = 0x2C3DFD, - ["BattleDome_Text_StyleAggressivelySpectacularMoves"] = 0x2C369D, - ["BattleDome_Text_StyleAggressivelyStrongMoves"] = 0x2C35D4, - ["BattleDome_Text_StyleAttacksQuicklyStrongMoves"] = 0x2C3704, - ["BattleDome_Text_StyleBattlesWhileEnduring"] = 0x2C3562, - ["BattleDome_Text_StyleCleverlyDodgesAttacks"] = 0x2C35F4, - ["BattleDome_Text_StyleConstantlyWatchesHP"] = 0x2C3471, - ["BattleDome_Text_StyleEmphasizesDefenseOverOffense"] = 0x2C36E3, - ["BattleDome_Text_StyleEmphasizesOffenseOverDefense"] = 0x2C36C2, - ["BattleDome_Text_StyleEndureLongBattles"] = 0x2C33C4, - ["BattleDome_Text_StyleEnfeebleThenAttack"] = 0x2C353B, - ["BattleDome_Text_StyleEnfeeblesFoes"] = 0x2C34B6, - ["BattleDome_Text_StyleHasPowerfulComboMoves"] = 0x2C3659, - ["BattleDome_Text_StylePowerfulLowPPMoves"] = 0x2C3516, - ["BattleDome_Text_StylePrefersLuckTactics"] = 0x2C34D2, - ["BattleDome_Text_StyleRegalAtmosphere"] = 0x2C34F5, - ["BattleDome_Text_StyleRiskDisaster"] = 0x2C339B, - ["BattleDome_Text_StyleSampleMessage1"] = 0x2C376F, - ["BattleDome_Text_StyleSampleMessage2"] = 0x2C3789, - ["BattleDome_Text_StyleSampleMessage3"] = 0x2C37A3, - ["BattleDome_Text_StyleSampleMessage4"] = 0x2C37BD, - ["BattleDome_Text_StyleStoresAndLoosesPower"] = 0x2C3492, - ["BattleDome_Text_StyleStrongAndStraightforward"] = 0x2C35AD, - ["BattleDome_Text_StyleToughWinningPattern"] = 0x2C340B, - ["BattleDome_Text_StyleUpsetsFoesEmotionally"] = 0x2C3586, - ["BattleDome_Text_StyleUsesAddedEffectMoves"] = 0x2C3727, - ["BattleDome_Text_StyleUsesBalancedMixOfMoves"] = 0x2C374C, - ["BattleDome_Text_StyleUsesHighProbabilityMoves"] = 0x2C367E, - ["BattleDome_Text_StyleUsesPopularMoves"] = 0x2C3640, - ["BattleDome_Text_StyleUsesStartlingMoves"] = 0x2C344C, - ["BattleDome_Text_StyleUsesUpsettingMoves"] = 0x2C361C, - ["BattleDome_Text_StyleUsesVeryRareMove"] = 0x2C3428, - ["BattleDome_Text_StyleVariesTactics"] = 0x2C33E6, - ["BattleDome_Text_TrainerBecameChamp"] = 0x2C3C9F, - ["BattleDome_Text_TrainerWonByDefault"] = 0x2C3CB4, - ["BattleDome_Text_TrainerWonNoMoves"] = 0x2C3CE3, - ["BattleDome_Text_TrainerWonOutrightByDefault"] = 0x2C3CC7, - ["BattleDome_Text_TrainerWonOutrightNoMoves"] = 0x2C3D00, - ["BattleDome_Text_TrainerWonUsingMove"] = 0x2C3C8E, - ["BattleFrontier_BattleTowerLobby_Text_DescribeYourBattle"] = 0x27F97A, - ["BattleFrontier_BattleTowerLobby_Text_DifficultToMakeBattleTurnOutAsPlanned"] = 0x27F921, - ["BattleFrontier_BattleTowerLobby_Text_HowDidBattleTowerTurnOut"] = 0x27F7BA, - ["BattleFrontier_BattleTowerLobby_Text_InterviewRequest"] = 0x27F704, - ["BattleFrontier_BattleTowerLobby_Text_LookingForwardToNextBattle"] = 0x27FAF3, - ["BattleFrontier_BattleTowerLobby_Text_ObviousYouHadGreatBattle"] = 0x27F8AE, - ["BattleFrontier_BattleTowerLobby_Text_SilentType"] = 0x27FA6F, - ["BattleFrontier_BattleTowerLobby_Text_SorryWeDisturbedYou"] = 0x27F84C, - ["BattleFrontier_BattleTowerLobby_Text_ThatsGreatLine"] = 0x27F9FD, - ["BattleTentLobby_Text_ExplainLevelRules"] = 0x2C67CD, - ["BerryBlender_Text_AllPlayersArrived"] = 0x2939A5, - ["BerryBlender_Text_BlendMasterExplainBerryBlending"] = 0x2C444C, - ["BerryBlender_Text_BlendMasterKnowHowToMakePokeblocks"] = 0x2C43FA, - ["BerryBlender_Text_BlendMasterLetsBerryBlender"] = 0x2C451B, - ["BerryBlender_Text_BlendMasterNoBerries"] = 0x2C439D, - ["BerryBlender_Text_BlendMasterNoPokeblockCase"] = 0x2C4573, - ["BerryBlender_Text_BlendMasterPokeblockCaseFull"] = 0x2C45E8, - ["BerryBlender_Text_BlendWithTheBlendMaster"] = 0x2C427C, - ["BerryBlender_Text_CanHaveOneOfMyBerries"] = 0x292FD1, - ["BerryBlender_Text_DontHaveAnyBerries"] = 0x292F77, - ["BerryBlender_Text_DontHaveAnyBerries2"] = 0x293394, - ["BerryBlender_Text_DontHaveAnyBerries3"] = 0x29367D, - ["BerryBlender_Text_DontHaveAnyBerriesHaveOne"] = 0x293AF0, - ["BerryBlender_Text_DontHaveAnyBerriesNoneToSpare"] = 0x293BB4, - ["BerryBlender_Text_DontHaveAnyBerriesToSpare"] = 0x29307D, - ["BerryBlender_Text_DontHavePokeblockCase"] = 0x293157, - ["BerryBlender_Text_DontHavePokeblockCase2"] = 0x2934A2, - ["BerryBlender_Text_DontHavePokeblockCase3"] = 0x293792, - ["BerryBlender_Text_DontHavePokeblockCaseLink"] = 0x293A2D, - ["BerryBlender_Text_Excellent"] = 0x292E28, - ["BerryBlender_Text_ExplainBerryBlending"] = 0x292EA6, - ["BerryBlender_Text_ExplainBerryBlending2"] = 0x2932F1, - ["BerryBlender_Text_ExplainBerryBlending3"] = 0x2935E5, - ["BerryBlender_Text_KnowHowToMakePokeblocks"] = 0x292E57, - ["BerryBlender_Text_KnowHowToMakePokeblocks2"] = 0x29328C, - ["BerryBlender_Text_KnowHowToMakePokeblocks3"] = 0x29357E, - ["BerryBlender_Text_LeftUsInShock"] = 0x293558, - ["BerryBlender_Text_LetsBerryBlender"] = 0x292E78, - ["BerryBlender_Text_LetsBerryBlender2"] = 0x2932C3, - ["BerryBlender_Text_LetsBerryBlender3"] = 0x2935B1, - ["BerryBlender_Text_LetsGetBlendingAlready"] = 0x2931AA, - ["BerryBlender_Text_LookGoodAtBlendingJoinUs"] = 0x293842, - ["BerryBlender_Text_LoveMakingPokeblocks"] = 0x293A6B, - ["BerryBlender_Text_MadeAmazingPokeblocksWithMaster"] = 0x2C4679, - ["BerryBlender_Text_MadeOldTimerSad"] = 0x292E33, - ["BerryBlender_Text_MakeDeliciousPokeblocks"] = 0x29388F, - ["BerryBlender_Text_MakePokeblocksUsingBerryBlender"] = 0x293AA5, - ["BerryBlender_Text_MakePokeblocksWithOurGroup"] = 0x29350B, - ["BerryBlender_Text_MasterWorksOnSkillsInMountains"] = 0x2C4763, - ["BerryBlender_Text_NoBerriesLink"] = 0x2939BD, - ["BerryBlender_Text_OhDear"] = 0x29354E, - ["BerryBlender_Text_Okay"] = 0x293237, - ["BerryBlender_Text_Player1And2Arrived"] = 0x293992, - ["BerryBlender_Text_Player1Arrived"] = 0x293986, - ["BerryBlender_Text_PokeblockCaseIsFull"] = 0x293101, - ["BerryBlender_Text_PokeblockCaseIsFull2"] = 0x29343E, - ["BerryBlender_Text_PokeblockCaseIsFull3"] = 0x293738, - ["BerryBlender_Text_PokeblockCaseIsFullLink"] = 0x2939F3, - ["BerryBlender_Text_QualitiesOfBlendMaster"] = 0x2C46B1, - ["BerryBlender_Text_SaveGameBeforeBerryBlenderLink"] = 0x2938CD, - ["BerryBlender_Text_SearchingForFriends"] = 0x293955, - ["BerryBlender_Text_SeeMyMasteryInAction"] = 0x2C42F4, - ["BerryBlender_Text_SetNewBlenderRecord"] = 0x2937F9, - ["BerryBlender_Text_ThatsTooBad"] = 0x29323D, - ["BerryBlender_Text_TooBusyNowIsee"] = 0x2C4332, - ["BerryBlender_Text_UseItToMakePokeblocksTogether"] = 0x293B76, - ["BerryBlender_Text_WantToBlendPokeblocksWithUs"] = 0x293201, - ["BerryBlender_Text_WantToMakePokeblocks"] = 0x292DEE, - ["BerryBlender_Text_WhatAnExpert"] = 0x2C4669, - ["BerryBlender_Text_WhatKindOfPokeblockWillIGet"] = 0x2931C6, - ["BerryBlender_Text_WhoaAwesome"] = 0x2C464B, - ["BerryBlender_Text_WickedlyFast"] = 0x2C465A, - ["BerryTree_Text_BerryGrowthStage1"] = 0x274560, - ["BerryTree_Text_BerryGrowthStage2"] = 0x274579, - ["BerryTree_Text_BerryGrowthStage3"] = 0x27458A, - ["BerryTree_Text_BerryGrowthStage4"] = 0x2745AB, - ["BerryTree_Text_BerryLeftUnpicked"] = 0x2746CB, - ["BerryTree_Text_BerryPocketFull"] = 0x27468F, - ["BerryTree_Text_CareAdverbGood"] = 0x2745E5, - ["BerryTree_Text_CareAdverbGreat"] = 0x2745CD, - ["BerryTree_Text_CareAdverbPoor"] = 0x2745DE, - ["BerryTree_Text_ExclamationPoint"] = 0x274744, - ["BerryTree_Text_ItsSoftLoamySoil"] = 0x2744F0, - ["BerryTree_Text_PickedTheBerry"] = 0x27461B, - ["BerryTree_Text_PlantIsDelighted"] = 0x274723, - ["BerryTree_Text_PlantedOneBerry"] = 0x274535, - ["BerryTree_Text_PutAwayBerry"] = 0x274630, - ["BerryTree_Text_WantToPick"] = 0x2745EE, - ["BerryTree_Text_WantToPlant"] = 0x274507, - ["BerryTree_Text_WantToWater"] = 0x2746E4, - ["BerryTree_Text_WateredTheBerry"] = 0x274710, - ["BirthIsland_Harbor_Text_SailorReturn"] = 0x2C6B90, - ["BravoTrainerBattleTower_Text_Intro"] = 0x27FB20, - ["BravoTrainerBattleTower_Text_Lost"] = 0x27FC04, - ["BravoTrainerBattleTower_Text_LostFinal"] = 0x27FD91, - ["BravoTrainerBattleTower_Text_NewRecord"] = 0x27FBB3, - ["BravoTrainerBattleTower_Text_None1"] = 0x28009C, - ["BravoTrainerBattleTower_Text_None2"] = 0x2800A1, - ["BravoTrainerBattleTower_Text_None3"] = 0x2800A6, - ["BravoTrainerBattleTower_Text_None4"] = 0x2800AB, - ["BravoTrainerBattleTower_Text_Outro"] = 0x2801E6, - ["BravoTrainerBattleTower_Text_Response"] = 0x2800B0, - ["BravoTrainerBattleTower_Text_ResponseSatisfied"] = 0x2800B6, - ["BravoTrainerBattleTower_Text_ResponseUnsatisfied"] = 0x28013D, - ["BravoTrainerBattleTower_Text_Satisfied"] = 0x27FE93, - ["BravoTrainerBattleTower_Text_Unsatisfied"] = 0x27FF89, - ["BravoTrainerBattleTower_Text_Won"] = 0x27FCD1, - ["CableClub_Text_AccessRecordCorner"] = 0x2790E8, - ["CableClub_Text_AdapterNotConnected"] = 0x27871F, - ["CableClub_Text_AskAboutLinking"] = 0x279937, - ["CableClub_Text_BattleUsingLinkCable"] = 0x277F5A, - ["CableClub_Text_CanMakeBerryPowder"] = 0x2787FC, - ["CableClub_Text_CanMixRecords"] = 0x278831, - ["CableClub_Text_CancelSelectedItem"] = 0x27889C, - ["CableClub_Text_CantDoubleBattleWithXPlayers"] = 0x278307, - ["CableClub_Text_CantMixWithJapaneseGame"] = 0x278651, - ["CableClub_Text_CantSingleBattleWithXPlayers"] = 0x2782D1, - ["CableClub_Text_CantTradeEnigmaBerry"] = 0x278061, - ["CableClub_Text_ChooseGroupLeader"] = 0x2793A3, - ["CableClub_Text_ChooseGroupLeaderOfFour"] = 0x279334, - ["CableClub_Text_ChooseGroupLeaderOfTwo"] = 0x2792CD, - ["CableClub_Text_CloseThisMenu"] = 0x277FDD, - ["CableClub_Text_DirectYouToYourRoom"] = 0x2781A5, - ["CableClub_Text_EnjoyUnionRoom"] = 0x27961C, - ["CableClub_Text_ExplainBattleModes"] = 0x279142, - ["CableClub_Text_ExplainWirelessClub"] = 0x2799AA, - ["CableClub_Text_ExplainWirelessClubFirstTime"] = 0x279718, - ["CableClub_Text_FirstTimeRightThisWay"] = 0x27964A, - ["CableClub_Text_GotToLookAtColoredTrainerCard"] = 0x27847B, - ["CableClub_Text_GotToLookAtTrainerCard"] = 0x278452, - ["CableClub_Text_GuideToVariousServices"] = 0x27886B, - ["CableClub_Text_Hello"] = 0x27878B, - ["CableClub_Text_HopeToSeeYouAgain"] = 0x27854C, - ["CableClub_Text_HopeYouEnjoyWirelessSystem"] = 0x279C91, - ["CableClub_Text_IncorrectNumberOfParticipants"] = 0x2782A8, - ["CableClub_Text_NeedBerryForBerryCrush"] = 0x2788FC, - ["CableClub_Text_NeedFourPlayers"] = 0x27833D, - ["CableClub_Text_NeedTwoMonsForDoubleBattle"] = 0x277FEE, - ["CableClub_Text_NeedTwoMonsForUnionRoom"] = 0x27893E, - ["CableClub_Text_NeedTwoMonsToTrade"] = 0x278027, - ["CableClub_Text_NoEnigmaBerryInUnionRoom"] = 0x27897B, - ["CableClub_Text_NotSetUpForFarAwayRegion"] = 0x278565, - ["CableClub_Text_OhExcuseMe"] = 0x278A39, - ["CableClub_Text_OkayToSaveProgress"] = 0x278157, - ["CableClub_Text_OtherTrainerNotReady"] = 0x2785C9, - ["CableClub_Text_ParticipantsStepUpToCounter"] = 0x27874F, - ["CableClub_Text_PlayWhichBattleMode"] = 0x2790BE, - ["CableClub_Text_PlayerIsWaiting"] = 0x278A48, - ["CableClub_Text_PleaseConfirmNumberAndRestart"] = 0x278372, - ["CableClub_Text_PleaseEnter"] = 0x278197, - ["CableClub_Text_PleaseVisitAgain"] = 0x278291, - ["CableClub_Text_PleaseWait"] = 0x278792, - ["CableClub_Text_RecordCornerUsingLinkCable"] = 0x277F96, - ["CableClub_Text_ReturnsToPreviousStep"] = 0x2788DE, - ["CableClub_Text_TooBusyToNotice"] = 0x27842E, - ["CableClub_Text_TradePokemon"] = 0x27909D, - ["CableClub_Text_TradeUsingLinkCable"] = 0x277F1B, - ["CableClub_Text_TrainerCardDataOverwritten"] = 0x278521, - ["CableClub_Text_UnionRoomAdapterNotConnected"] = 0x2789B5, - ["CableClub_Text_UnionRoomInfo"] = 0x2794B8, - ["CableClub_Text_UseBerryCrush"] = 0x279114, - ["CableClub_Text_WelcomeUnionRoomEnter"] = 0x27940D, - ["CableClub_Text_WelcomeWhichCableClubService"] = 0x277EA4, - ["CableClub_Text_WelcomeWhichDirectCornerRoom"] = 0x279013, - ["CableClub_Text_WhichBattleMode"] = 0x2788BC, - ["CableClub_Text_WhichService"] = 0x277EF1, - ["CableClub_Text_YouHaveAMonThatCantBeTaken"] = 0x2785E9, - ["CableClub_Text_YouMayBattleHere"] = 0x2787D5, - ["CableClub_Text_YouMayTradeHere"] = 0x27879F, - ["ContestHall_Text_AcceptYourPrize"] = 0x27BBA8, - ["ContestHall_Text_AudienceWillVote"] = 0x27CA04, - ["ContestHall_Text_ConferRibbonAsPrize"] = 0x27BBD4, - ["ContestHall_Text_CongratsPleaseCompeteAgain"] = 0x27BB7A, - ["ContestHall_Text_CongratsTrainerXandMon"] = 0x27BB4C, - ["ContestHall_Text_EntryXTrainersMon"] = 0x27B815, - ["ContestHall_Text_GettingStartedParticipantsAsFollows"] = 0x27B711, - ["ContestHall_Text_GettingStartedParticipantsAsFollowsLink"] = 0x27B78F, - ["ContestHall_Text_GettingStartedWireless"] = 0x27C929, - ["ContestHall_Text_JudgeLooksReady"] = 0x27BAAC, - ["ContestHall_Text_LetVotingBegin"] = 0x27CA42, - ["ContestHall_Text_LetsAppeal"] = 0x27CB55, - ["ContestHall_Text_OnlyRegister4Players"] = 0x27BC82, - ["ContestHall_Text_ParticipantsAsFollows"] = 0x27C972, - ["ContestHall_Text_PickUpPrizeAtCounterLater"] = 0x27BC2F, - ["ContestHall_Text_PutRibbonOnMon"] = 0x27BC16, - ["ContestHall_Text_ReceivedRibbon"] = 0x27BC00, - ["ContestHall_Text_SecondStageOfJudging"] = 0x27CAD0, - ["ContestHall_Text_SeenContestantsAudienceWillVote"] = 0x27B830, - ["ContestHall_Text_ThankYouForAppeals"] = 0x27BA30, - ["ContestHall_Text_ThatsItForJudging"] = 0x27BA15, - ["ContestHall_Text_VotingComplete"] = 0x27CA71, - ["ContestHall_Text_VotingCompleteLetsAppeal"] = 0x27B904, - ["ContestHall_Text_VotingUnderWay"] = 0x27B8F2, - ["ContestHall_Text_WeWillNowDeclareWinner"] = 0x27BB25, - ["ContestHall_Text_WeveSeenContestants"] = 0x27C9AF, - ["ContestLadyShow_Text_Intro"] = 0x28C137, - ["ContestLadyShow_Text_Lost"] = 0x28C45B, - ["ContestLadyShow_Text_LostBadly"] = 0x28C662, - ["ContestLadyShow_Text_Won"] = 0x28C28C, - ["ContestLiveUpdates_Text_BetterRound1"] = 0x28A0C6, - ["ContestLiveUpdates_Text_BetterRound2"] = 0x289FB0, - ["ContestLiveUpdates_Text_EqualRounds"] = 0x28A047, - ["ContestLiveUpdates_Text_ExcitingAppeal"] = 0x28A24E, - ["ContestLiveUpdates_Text_FailedToAppeal"] = 0x28A5AC, - ["ContestLiveUpdates_Text_GotNervous"] = 0x28A132, - ["ContestLiveUpdates_Text_GotStartled"] = 0x28A455, - ["ContestLiveUpdates_Text_Intro"] = 0x289DD6, - ["ContestLiveUpdates_Text_LastInBothRounds"] = 0x28A638, - ["ContestLiveUpdates_Text_LostAfterWinningRound1"] = 0x28A797, - ["ContestLiveUpdates_Text_LostBySmallMargin"] = 0x28A920, - ["ContestLiveUpdates_Text_MoveWonderful"] = 0x28A4CF, - ["ContestLiveUpdates_Text_NeverExciting"] = 0x28A86D, - ["ContestLiveUpdates_Text_NotExcitingEnough"] = 0x28A6CF, - ["ContestLiveUpdates_Text_Outro"] = 0x28AB01, - ["ContestLiveUpdates_Text_RepeatedAppeals"] = 0x28A9CC, - ["ContestLiveUpdates_Text_StartledFoes"] = 0x28A1BE, - ["ContestLiveUpdates_Text_TalkAboutAnotherMon"] = 0x28A51C, - ["ContestLiveUpdates_Text_TookBreak"] = 0x28A3DC, - ["ContestLiveUpdates_Text_UsedCombo"] = 0x28A202, - ["ContestLiveUpdates_Text_ValiantEffortButLost"] = 0x28AA74, - ["ContestLiveUpdates_Text_VeryBeautiful"] = 0x28A350, - ["ContestLiveUpdates_Text_VeryCool"] = 0x28A32E, - ["ContestLiveUpdates_Text_VeryCute"] = 0x28A36F, - ["ContestLiveUpdates_Text_VeryExcitingAppeal"] = 0x28A2EE, - ["ContestLiveUpdates_Text_VerySmart"] = 0x28A38D, - ["ContestLiveUpdates_Text_VeryTough"] = 0x28A3B6, - ["ContestLiveUpdates_Text_WasBeautiful"] = 0x28A29B, - ["ContestLiveUpdates_Text_WasCool"] = 0x28A288, - ["ContestLiveUpdates_Text_WasCute"] = 0x28A2B3, - ["ContestLiveUpdates_Text_WasSmart"] = 0x28A2C6, - ["ContestLiveUpdates_Text_WasTough"] = 0x28A2DA, - ["ContestLiveUpdates_Text_WonBothRounds"] = 0x289F53, - ["EventTicket_Text_AsYouLike"] = 0x2A6A82, - ["EventTicket_Text_BrineyHoldOnASecond"] = 0x2C6951, - ["EventTicket_Text_BrineyLetsSail"] = 0x2C69AA, - ["EventTicket_Text_OddTicketGetOnBoard"] = 0x2C6A71, - ["EventTicket_Text_OddTicketsWhereTo"] = 0x2C6BD4, - ["EventTicket_Text_OldSeaMapTooFar"] = 0x2C68A5, - ["EventTicket_Text_SailHome"] = 0x2A6A5D, - ["EventTicket_Text_ShowEonTicket"] = 0x2A68FC, - ["EventTicket_Text_ShowOldSeaMap"] = 0x2A6848, - ["EventTicket_Text_SouthernIslandSailBack"] = 0x2A69F1, - ["EventTicket_Text_ThatPass"] = 0x2A68D2, - ["FallarborTown_BattleTentLobby_Text_AwaitAnotherChallenge"] = 0x2C487F, - ["FallarborTown_BattleTentLobby_Text_AwaitAnotherChallenge2"] = 0x2C501F, - ["FallarborTown_BattleTentLobby_Text_BagFullReturnForPrize"] = 0x2C4F83, - ["FallarborTown_BattleTentLobby_Text_BeatThreeTrainers"] = 0x2C4EC3, - ["FallarborTown_BattleTentLobby_Text_DidntSaveBeforeQuitting"] = 0x2C4DED, - ["FallarborTown_BattleTentLobby_Text_ExplainFallarborTent"] = 0x2C48AC, - ["FallarborTown_BattleTentLobby_Text_GuideYouToBattleTent"] = 0x2C4DC3, - ["FallarborTown_BattleTentLobby_Text_LookingForwardToArrival"] = 0x2C504C, - ["FallarborTown_BattleTentLobby_Text_NotEnoughValidMonsLv50"] = 0x2C4BFA, - ["FallarborTown_BattleTentLobby_Text_NotEnoughValidMonsLvOpen"] = 0x2C4CC0, - ["FallarborTown_BattleTentLobby_Text_PresentYouWithPrize"] = 0x2C4F22, - ["FallarborTown_BattleTentLobby_Text_ReceivedPrize"] = 0x2C4F69, - ["FallarborTown_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C4B35, - ["FallarborTown_BattleTentLobby_Text_SelectThreeMons"] = 0x2C4BC8, - ["FallarborTown_BattleTentLobby_Text_TakeChallenge"] = 0x2C4843, - ["FallarborTown_BattleTentLobby_Text_ThankYouWaitWhileSaving"] = 0x2C4FD7, - ["FallarborTown_BattleTentLobby_Text_WaitWhileSaveGame"] = 0x2C4EFF, - ["FallarborTown_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C47EB, - ["FallarborTown_BattleTentLobby_Text_WhichLevelMode"] = 0x2C4B77, - ["FarawayIsland_Entrance_Text_SailorReturn"] = 0x2C6B42, - ["FarawayIsland_Entrance_Text_Sign"] = 0x2C6D5A, - ["FarawayIsland_Interior_Text_Mew"] = 0x2C6DFF, - ["GabbyAndTy_Text_CleverItemSkillsInterviewAgain"] = 0x28B2FA, - ["GabbyAndTy_Text_DescribeYourFeelings"] = 0x28B433, - ["GabbyAndTy_Text_DontGiveUpKeepingEyeOut"] = 0x28B577, - ["GabbyAndTy_Text_GabbyDefeat"] = 0x28B719, - ["GabbyAndTy_Text_GabbyDefeatFirstTime"] = 0x28B000, - ["GabbyAndTy_Text_GabbyIntro"] = 0x28AF7D, - ["GabbyAndTy_Text_GabbyNotEnoughMons"] = 0x28B5EC, - ["GabbyAndTy_Text_GabbyPreFirstBattle"] = 0x28AF05, - ["GabbyAndTy_Text_GiveUsAnInterviewThisTime"] = 0x28B62D, - ["GabbyAndTy_Text_InterviewAgain"] = 0x28B3F3, - ["GabbyAndTy_Text_KeepingAnEyeOutForYou"] = 0x28B5C0, - ["GabbyAndTy_Text_PerfectWellBeSeeingYou"] = 0x28B4AB, - ["GabbyAndTy_Text_QuoteFromLastInterview"] = 0x28B137, - ["GabbyAndTy_Text_TyDefeat"] = 0x28B8F6, - ["GabbyAndTy_Text_TyDefeatFirstTime"] = 0x28B8B1, - ["GabbyAndTy_Text_TyIntro"] = 0x28B7B1, - ["GabbyAndTy_Text_TyNotEnoughMons"] = 0x28B841, - ["GabbyAndTy_Text_TyPostBattle"] = 0x28B805, - ["GabbyAndTy_Text_TyPreFirstBattle"] = 0x28B75C, - ["GabbyAndTy_Text_WeLookedRespectableInterviewAgain"] = 0x28B379, - ["GabbyAndTy_Text_WhoAreYouInterview"] = 0x28B042, - ["GabbyAndTy_Text_YouStompedUsInterviewAgain"] = 0x28B1B3, - ["GabbyAndTy_Text_YouThrewABallAtUsInterviewAgain"] = 0x28B23D, - ["GiddyText_DontYouAgree"] = 0x294301, - ["GiddyText_IBoughtCrayonsWith120Colors"] = 0x294359, - ["GiddyText_ISoWantToGoOnAVacation"] = 0x294313, - ["GiddyText_Is"] = 0x2942FD, - ["GiddyText_LyingBackInTheGreenGrass"] = 0x2944A0, - ["GiddyText_SecretBasesAreSoWonderful"] = 0x2944D5, - ["GiddyText_SoAmusing"] = 0x2942E3, - ["GiddyText_SoDarling"] = 0x2942A1, - ["GiddyText_SoDesirable"] = 0x2942C6, - ["GiddyText_SoExciting"] = 0x2942D5, - ["GiddyText_SoMagical"] = 0x2942F0, - ["GiddyText_SoPretty"] = 0x294295, - ["GiddyText_SoRelaxed"] = 0x2942AE, - ["GiddyText_SoSunny"] = 0x2942BB, - ["GiddyText_WhatsTheBottomOfTheSeaLike"] = 0x29441C, - ["GiddyText_WhenYouSeeTheSettingSunDoesIt"] = 0x294460, - ["GiddyText_WhenYouWriteOnASandyBeach"] = 0x2943DA, - ["GiddyText_WouldntItBeNiceIfWeCouldFloat"] = 0x294398, - ["InsideOfTruck_Text_BoxPrintedWithMonLogo"] = 0x23BF75, - ["LandmarkName_AbandonedShip"] = 0x5B8CD1, - ["LandmarkName_AlteringCave"] = 0x5B8EE1, - ["LandmarkName_AncientTomb"] = 0x5B8E85, - ["LandmarkName_BerryMastersHouse"] = 0x5B8E49, - ["LandmarkName_CableCar"] = 0x5B8D56, - ["LandmarkName_CyclingRoad"] = 0x5B8CFF, - ["LandmarkName_Desert"] = 0x5B8D3A, - ["LandmarkName_DesertRuins"] = 0x5B8E6A, - ["LandmarkName_DesertUnderpass"] = 0x5B8EEF, - ["LandmarkName_FieryPath"] = 0x5B8E27, - ["LandmarkName_FlowerShop"] = 0x5B8CA0, - ["LandmarkName_FossilManiacsHouse"] = 0x5B8EA1, - ["LandmarkName_GlassWorkshop"] = 0x5B8D60, - ["LandmarkName_GraniteCave"] = 0x5B8DFC, - ["LandmarkName_HuntersHouse"] = 0x5B8EB7, - ["LandmarkName_IslandCave"] = 0x5B8E5E, - ["LandmarkName_JaggedPass"] = 0x5B8E32, - ["LandmarkName_LanettesHouse"] = 0x5B8E17, - ["LandmarkName_MagmaHideout"] = 0x5B8EC6, - ["LandmarkName_MeteorFalls"] = 0x5B8D81, - ["LandmarkName_MirageTower"] = 0x5B8ED4, - ["LandmarkName_MrBrineysCottage"] = 0x5B8CBC, - ["LandmarkName_MtPyre"] = 0x5B8DD8, - ["LandmarkName_NewMauville"] = 0x5B8D0C, - ["LandmarkName_OceanCurrent"] = 0x5B8E09, - ["LandmarkName_OldLadysRestShop"] = 0x5B8D25, - ["LandmarkName_PetalburgWoods"] = 0x5B8CAC, - ["LandmarkName_PokemonDayCare"] = 0x5B8DB2, - ["LandmarkName_RusturfTunnel"] = 0x5B8DA3, - ["LandmarkName_SafariZoneEntrance"] = 0x5B8DC3, - ["LandmarkName_ScorchedSlab"] = 0x5B8E77, - ["LandmarkName_SeafloorCavern"] = 0x5B8DEC, - ["LandmarkName_SealedChamber"] = 0x5B8E92, - ["LandmarkName_SeashoreHouse"] = 0x5B8CE0, - ["LandmarkName_ShoalCave"] = 0x5B8DE1, - ["LandmarkName_SkyPillar"] = 0x5B8E3E, - ["LandmarkName_SlateportBeach"] = 0x5B8CEF, - ["LandmarkName_TrainerHill"] = 0x5B8F00, - ["LandmarkName_TrickHouse"] = 0x5B8D19, - ["LandmarkName_TunnelersRestHouse"] = 0x5B8D8E, - ["LandmarkName_WeatherInstitute"] = 0x5B8D6F, - ["LandmarkName_WinstrateFamily"] = 0x5B8D41, - ["LilycoveCity_ContestLobby_Text_AlreadyWonEnterAnyway"] = 0x27B547, - ["LilycoveCity_ContestLobby_Text_ComeBackForPrizeLater"] = 0x27B6E7, - ["LilycoveCity_ContestLobby_Text_ComeThroughHere"] = 0x27B653, - ["LilycoveCity_ContestLobby_Text_ConfirmContestMon"] = 0x27B5C4, - ["LilycoveCity_ContestLobby_Text_ContestBeginShortly"] = 0x27C043, - ["LilycoveCity_ContestLobby_Text_ContestReception"] = 0x27AEA8, - ["LilycoveCity_ContestLobby_Text_CounterOnlyFor4PlayerContests"] = 0x27AEE3, - ["LilycoveCity_ContestLobby_Text_DescribeContest"] = 0x27EFE7, - ["LilycoveCity_ContestLobby_Text_EggCannotTakePart"] = 0x27B4C4, - ["LilycoveCity_ContestLobby_Text_EggCannotTakePart2"] = 0x27C186, - ["LilycoveCity_ContestLobby_Text_EnterContest1"] = 0x27AF28, - ["LilycoveCity_ContestLobby_Text_EnterContest2"] = 0x27BD7A, - ["LilycoveCity_ContestLobby_Text_EnterContest3"] = 0x27C0F6, - ["LilycoveCity_ContestLobby_Text_EnterWhichContest1"] = 0x27B423, - ["LilycoveCity_ContestLobby_Text_EnterWhichContest2"] = 0x27BE9E, - ["LilycoveCity_ContestLobby_Text_EnterWhichContest3"] = 0x27C119, - ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon1"] = 0x27B44A, - ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon2"] = 0x27BEC5, - ["LilycoveCity_ContestLobby_Text_EnterWhichPokemon3"] = 0x27C1C3, - ["LilycoveCity_ContestLobby_Text_EnterWhichRank"] = 0x27B3FF, - ["LilycoveCity_ContestLobby_Text_Explain4PlayerContest"] = 0x27BD9D, - ["LilycoveCity_ContestLobby_Text_ExplainContestRanks"] = 0x27B221, - ["LilycoveCity_ContestLobby_Text_ExplainContestTypes"] = 0x27B17D, - ["LilycoveCity_ContestLobby_Text_ExplainContests"] = 0x27AF7A, - ["LilycoveCity_ContestLobby_Text_ExplainEMode"] = 0x27C5B1, - ["LilycoveCity_ContestLobby_Text_ExplainGMode"] = 0x27C742, - ["LilycoveCity_ContestLobby_Text_ExplainLinkContest"] = 0x27C340, - ["LilycoveCity_ContestLobby_Text_InterviewRequest"] = 0x27EF15, - ["LilycoveCity_ContestLobby_Text_LinkContestReception"] = 0x27C063, - ["LilycoveCity_ContestLobby_Text_LookingForwardToNextContest"] = 0x27F23F, - ["LilycoveCity_ContestLobby_Text_MonInNoCondition2"] = 0x27C140, - ["LilycoveCity_ContestLobby_Text_MonInNoConditionForContest"] = 0x27B501, - ["LilycoveCity_ContestLobby_Text_MonNotQualifiedForRank"] = 0x27B471, - ["LilycoveCity_ContestLobby_Text_NoWirelessAdapterInGMode"] = 0x27C879, - ["LilycoveCity_ContestLobby_Text_NowThatWeveClearedThatUp"] = 0x27AE47, - ["LilycoveCity_ContestLobby_Text_ParticipateAnotherTime"] = 0x27BD4F, - ["LilycoveCity_ContestLobby_Text_ParticipateAnotherTime2"] = 0x27BFA7, - ["LilycoveCity_ContestLobby_Text_PlayerAt4PCounterUseGMode"] = 0x27C254, - ["LilycoveCity_ContestLobby_Text_PlayersChoseDifferentContest"] = 0x27BF0E, - ["LilycoveCity_ContestLobby_Text_PlayersMadeDifferentChoice"] = 0x27BF4B, - ["LilycoveCity_ContestLobby_Text_PleaseDecideLinkLeader"] = 0x27C1EA, - ["LilycoveCity_ContestLobby_Text_PleaseDoShareStoryWithMe"] = 0x27F1EF, - ["LilycoveCity_ContestLobby_Text_PleaseWaitBButtonCancel"] = 0x27BF85, - ["LilycoveCity_ContestLobby_Text_PokemonWonWeHavePrize"] = 0x27B67B, - ["LilycoveCity_ContestLobby_Text_ProgressWillBeSaved"] = 0x27BD17, - ["LilycoveCity_ContestLobby_Text_ReceptionDontHavePokeblockCase"] = 0x27ADA7, - ["LilycoveCity_ContestLobby_Text_ThatsAllForInterview"] = 0x27F0EC, - ["LilycoveCity_ContestLobby_Text_TransmissionError"] = 0x27BEFA, - ["LilycoveCity_ContestLobby_Text_TransmissionErrorTryAgain"] = 0x27BFD2, - ["LilycoveCity_ContestLobby_Text_Transmitting"] = 0x27BEEC, - ["LilycoveCity_ContestLobby_Text_WhatImageWhenYouHearX"] = 0x27F03E, - ["LilycoveCity_ContestLobby_Text_WhichContestMode"] = 0x27C8FD, - ["LilycoveCity_ContestLobby_Text_WhichTopic1"] = 0x27AF5E, - ["LilycoveCity_ContestLobby_Text_WhichTopic2"] = 0x27C0DA, - ["LilycoveCity_ContestLobby_Text_YourMonIsEntryNum4"] = 0x27B5E2, - ["LilycoveCity_ContestLobby_Text_YourMonIsEntryNumX"] = 0x27BFF8, - ["LilycoveCity_DepartmentStore_1F_Text_AllFiveDigitsMatched"] = 0x2A6731, - ["LilycoveCity_DepartmentStore_1F_Text_ComeBackTomorrow"] = 0x2A6496, - ["LilycoveCity_DepartmentStore_1F_Text_FourDigitsMatched"] = 0x2A66ED, - ["LilycoveCity_DepartmentStore_1F_Text_LotteryCornerDrawTicket"] = 0x2A6390, - ["LilycoveCity_DepartmentStore_1F_Text_NoNumbersMatched"] = 0x2A663C, - ["LilycoveCity_DepartmentStore_1F_Text_NoRoomForThis"] = 0x2A678C, - ["LilycoveCity_DepartmentStore_1F_Text_PleasePickTicket"] = 0x2A64C8, - ["LilycoveCity_DepartmentStore_1F_Text_PleaseVisitAgain"] = 0x2A64B1, - ["LilycoveCity_DepartmentStore_1F_Text_PleaseVisitAgain2"] = 0x2A6831, - ["LilycoveCity_DepartmentStore_1F_Text_PrizeWeveBeenHolding"] = 0x2A67E1, - ["LilycoveCity_DepartmentStore_1F_Text_ThreeDigitsMatched"] = 0x2A66A7, - ["LilycoveCity_DepartmentStore_1F_Text_TicketMatchesPCMon"] = 0x2A65E6, - ["LilycoveCity_DepartmentStore_1F_Text_TicketMatchesPartyMon"] = 0x2A6592, - ["LilycoveCity_DepartmentStore_1F_Text_TicketNumberIsXPleaseWait"] = 0x2A650B, - ["LilycoveCity_DepartmentStore_1F_Text_TwoDigitsMatched"] = 0x2A6664, - ["LilycoveCity_PokemonCenter_1F_Text_AskingForOnePokeblock"] = 0x2A94E8, - ["LilycoveCity_PokemonCenter_1F_Text_DontHaveAPokeblockCase"] = 0x2A9451, - ["LilycoveCity_PokemonCenter_1F_Text_HowBoringBye"] = 0x2A8F65, - ["LilycoveCity_PokemonCenter_1F_Text_ICantHaveOnePokeblock"] = 0x2A9537, - ["LilycoveCity_PokemonCenter_1F_Text_IGetToKeepPrize"] = 0x2A90CD, - ["LilycoveCity_PokemonCenter_1F_Text_IWillCherishThis"] = 0x2A8C6F, - ["LilycoveCity_PokemonCenter_1F_Text_IWillTreasureThis"] = 0x2A8CC8, - ["LilycoveCity_PokemonCenter_1F_Text_IllGiveYouThisInReturn"] = 0x2A8D5D, - ["LilycoveCity_PokemonCenter_1F_Text_IllLookForAChallenger"] = 0x2A9336, - ["LilycoveCity_PokemonCenter_1F_Text_IllTryToCherishIt"] = 0x2A8C0F, - ["LilycoveCity_PokemonCenter_1F_Text_IllUseYourPokeblock"] = 0x2A9571, - ["LilycoveCity_PokemonCenter_1F_Text_ImTheContestLady"] = 0x2A93A7, - ["LilycoveCity_PokemonCenter_1F_Text_ImTheFavorLady"] = 0x2A8A69, - ["LilycoveCity_PokemonCenter_1F_Text_ImTheQuizLady"] = 0x2A8E2B, - ["LilycoveCity_PokemonCenter_1F_Text_IsThatSoGoodbye"] = 0x2A8BCD, - ["LilycoveCity_PokemonCenter_1F_Text_Kecleon"] = 0x2A96E6, - ["LilycoveCity_PokemonCenter_1F_Text_MakeYourOwnQuiz"] = 0x2A90FB, - ["LilycoveCity_PokemonCenter_1F_Text_MaybeNextTime"] = 0x2A9131, - ["LilycoveCity_PokemonCenter_1F_Text_MyFriendDisplaysQuality"] = 0x2A93F4, - ["LilycoveCity_PokemonCenter_1F_Text_NoChangeThanks"] = 0x2A95AD, - ["LilycoveCity_PokemonCenter_1F_Text_NotWillingToShare"] = 0x2A8BEE, - ["LilycoveCity_PokemonCenter_1F_Text_ObsessedWithThing"] = 0x2A8A7D, - ["LilycoveCity_PokemonCenter_1F_Text_PickYourPrize"] = 0x2A9153, - ["LilycoveCity_PokemonCenter_1F_Text_Pikachu"] = 0x2A9703, - ["LilycoveCity_PokemonCenter_1F_Text_PlayerGaveMeBadThing"] = 0x2A8ACE, - ["LilycoveCity_PokemonCenter_1F_Text_PlayerGaveMeGreatThing"] = 0x2A8B36, - ["LilycoveCity_PokemonCenter_1F_Text_Poochyena"] = 0x2A96F6, - ["LilycoveCity_PokemonCenter_1F_Text_QuitChoosingPrize"] = 0x2A9212, - ["LilycoveCity_PokemonCenter_1F_Text_QuitWritingQuizAnswer"] = 0x2A9305, - ["LilycoveCity_PokemonCenter_1F_Text_QuitWritingQuizQuestion"] = 0x2A92D3, - ["LilycoveCity_PokemonCenter_1F_Text_ReadyToEnterContests"] = 0x2A9669, - ["LilycoveCity_PokemonCenter_1F_Text_ReallyImprovedThanks"] = 0x2A9605, - ["LilycoveCity_PokemonCenter_1F_Text_Skitty"] = 0x2A970E, - ["LilycoveCity_PokemonCenter_1F_Text_TakeQuizChallenge"] = 0x2A8EEC, - ["LilycoveCity_PokemonCenter_1F_Text_TakeTheQuizAnotherTime"] = 0x2A8F9A, - ["LilycoveCity_PokemonCenter_1F_Text_ThankForPokeblock"] = 0x2A93D6, - ["LilycoveCity_PokemonCenter_1F_Text_ThankYouForLastTime"] = 0x2A8AB1, - ["LilycoveCity_PokemonCenter_1F_Text_WaitForAnswer"] = 0x2A8F4D, - ["LilycoveCity_PokemonCenter_1F_Text_WaitingForChallenger"] = 0x2A8EAC, - ["LilycoveCity_PokemonCenter_1F_Text_WaitingToTakeYourQuiz"] = 0x2A8E4E, - ["LilycoveCity_PokemonCenter_1F_Text_WhatACheapskate"] = 0x2A9556, - ["LilycoveCity_PokemonCenter_1F_Text_WhatWillYouGiveMe"] = 0x2A8BAD, - ["LilycoveCity_PokemonCenter_1F_Text_WillYouShareThing"] = 0x2A8B69, - ["LilycoveCity_PokemonCenter_1F_Text_WriteYourQuiz"] = 0x2A9270, - ["LilycoveCity_PokemonCenter_1F_Text_WrongTheCorrectAnswerIs"] = 0x2A90A5, - ["LilycoveCity_PokemonCenter_1F_Text_XReceivedOneY"] = 0x2A9056, - ["LilycoveCity_PokemonCenter_1F_Text_YouDontHaveSpaceForIt"] = 0x2A8DBD, - ["LilycoveCity_PokemonCenter_1F_Text_YouGotItRight"] = 0x2A8FC7, - ["LilycoveCity_PokemonCenter_1F_Text_YouGotItRightYouveWonPersonsPrize"] = 0x2A9007, - ["LilycoveCity_PokemonCenter_1F_Text_YourBagIsFilledUp"] = 0x2A906A, - ["LilycoveCity_PokemonCenter_1F_Text_YoureGoingToQuit"] = 0x2A8F7E, - ["LilycoveCity_PokemonCenter_1F_Text_Zigzagoon"] = 0x2A96DA, - ["LilycoveCity_PokemonTrainerFanClub_HopeYouCatchTVSpecial"] = 0x281DB4, - ["LilycoveCity_PokemonTrainerFanClub_Text_HaveYouForgottenTrainer"] = 0x281C3D, - ["LilycoveCity_PokemonTrainerFanClub_Text_HaveYouForgottenTrainer2"] = 0x281CCD, - ["LilycoveCity_PokemonTrainerFanClub_Text_HowStrongRateTrainer"] = 0x281C95, - ["LilycoveCity_PokemonTrainerFanClub_Text_ThankYouIllShareThisInfo"] = 0x281D40, - ["LilycoveCity_PokemonTrainerFanClub_Text_ThatsWhatYouThink"] = 0x281C06, - ["LilycoveCity_PokemonTrainerFanClub_Text_WhatsYourOpinionOfTrainer"] = 0x281BCB, - ["LilycoveCity_PokemonTrainerFanClub_Text_WhatsYourOpinionOfTrainer2"] = 0x281C65, - ["LilycoveCity_PokemonTrainerFanClub_Text_YouShouldMeetTrainer"] = 0x281CF5, - ["LilycoveCity_Text_BecauseYoureTrainer"] = 0x2A72E3, - ["LilycoveCity_Text_BerrySuitsYou"] = 0x2A7244, - ["LilycoveCity_Text_PokeblocksSuitPokemon"] = 0x2A7321, - ["LittlerootTown_ProfessorBirchsLab_Text_LikeYouToHavePokemon"] = 0x1FA74D, - ["MatchCall_BattleDomeText1"] = 0x2B0BC5, - ["MatchCall_BattleDomeText10"] = 0x2B10A7, - ["MatchCall_BattleDomeText11"] = 0x2B1121, - ["MatchCall_BattleDomeText12"] = 0x2B11D3, - ["MatchCall_BattleDomeText13"] = 0x2B124D, - ["MatchCall_BattleDomeText14"] = 0x2B12D0, - ["MatchCall_BattleDomeText2"] = 0x2B0C4A, - ["MatchCall_BattleDomeText3"] = 0x2B0CC7, - ["MatchCall_BattleDomeText4"] = 0x2B0D4A, - ["MatchCall_BattleDomeText5"] = 0x2B0DC8, - ["MatchCall_BattleDomeText6"] = 0x2B0E35, - ["MatchCall_BattleDomeText7"] = 0x2B0ED1, - ["MatchCall_BattleDomeText8"] = 0x2B0F72, - ["MatchCall_BattleDomeText9"] = 0x2B102A, - ["MatchCall_BattleFrontierRecordStreakText1"] = 0x2B03E6, - ["MatchCall_BattleFrontierRecordStreakText10"] = 0x2B0908, - ["MatchCall_BattleFrontierRecordStreakText11"] = 0x2B0984, - ["MatchCall_BattleFrontierRecordStreakText12"] = 0x2B0A11, - ["MatchCall_BattleFrontierRecordStreakText13"] = 0x2B0AB8, - ["MatchCall_BattleFrontierRecordStreakText14"] = 0x2B0B45, - ["MatchCall_BattleFrontierRecordStreakText2"] = 0x2B0457, - ["MatchCall_BattleFrontierRecordStreakText3"] = 0x2B04DE, - ["MatchCall_BattleFrontierRecordStreakText4"] = 0x2B0586, - ["MatchCall_BattleFrontierRecordStreakText5"] = 0x2B061C, - ["MatchCall_BattleFrontierRecordStreakText6"] = 0x2B06A3, - ["MatchCall_BattleFrontierRecordStreakText7"] = 0x2B073B, - ["MatchCall_BattleFrontierRecordStreakText8"] = 0x2B07C7, - ["MatchCall_BattleFrontierRecordStreakText9"] = 0x2B0894, - ["MatchCall_BattleFrontierStreakText1"] = 0x2AFC07, - ["MatchCall_BattleFrontierStreakText10"] = 0x2B0129, - ["MatchCall_BattleFrontierStreakText11"] = 0x2B01A5, - ["MatchCall_BattleFrontierStreakText12"] = 0x2B0232, - ["MatchCall_BattleFrontierStreakText13"] = 0x2B02D9, - ["MatchCall_BattleFrontierStreakText14"] = 0x2B0366, - ["MatchCall_BattleFrontierStreakText2"] = 0x2AFC78, - ["MatchCall_BattleFrontierStreakText3"] = 0x2AFCFF, - ["MatchCall_BattleFrontierStreakText4"] = 0x2AFDA7, - ["MatchCall_BattleFrontierStreakText5"] = 0x2AFE3D, - ["MatchCall_BattleFrontierStreakText6"] = 0x2AFECA, - ["MatchCall_BattleFrontierStreakText7"] = 0x2AFF64, - ["MatchCall_BattleFrontierStreakText8"] = 0x2AFFF0, - ["MatchCall_BattleFrontierStreakText9"] = 0x2B00B5, - ["MatchCall_BattlePikeText1"] = 0x2B1347, - ["MatchCall_BattlePikeText10"] = 0x2B17F8, - ["MatchCall_BattlePikeText11"] = 0x2B1877, - ["MatchCall_BattlePikeText12"] = 0x2B1946, - ["MatchCall_BattlePikeText13"] = 0x2B19C7, - ["MatchCall_BattlePikeText14"] = 0x2B1A4C, - ["MatchCall_BattlePikeText2"] = 0x2B13B1, - ["MatchCall_BattlePikeText3"] = 0x2B142B, - ["MatchCall_BattlePikeText4"] = 0x2B14B4, - ["MatchCall_BattlePikeText5"] = 0x2B1525, - ["MatchCall_BattlePikeText6"] = 0x2B158E, - ["MatchCall_BattlePikeText7"] = 0x2B1622, - ["MatchCall_BattlePikeText8"] = 0x2B169D, - ["MatchCall_BattlePikeText9"] = 0x2B1775, - ["MatchCall_BattlePyramidText1"] = 0x2B1ACA, - ["MatchCall_BattlePyramidText10"] = 0x2B1FA8, - ["MatchCall_BattlePyramidText11"] = 0x2B2022, - ["MatchCall_BattlePyramidText12"] = 0x2B20F3, - ["MatchCall_BattlePyramidText13"] = 0x2B2175, - ["MatchCall_BattlePyramidText14"] = 0x2B21FC, - ["MatchCall_BattlePyramidText2"] = 0x2B1B50, - ["MatchCall_BattlePyramidText3"] = 0x2B1BE4, - ["MatchCall_BattlePyramidText4"] = 0x2B1C6A, - ["MatchCall_BattlePyramidText5"] = 0x2B1CCF, - ["MatchCall_BattlePyramidText6"] = 0x2B1D38, - ["MatchCall_BattlePyramidText7"] = 0x2B1DCD, - ["MatchCall_BattlePyramidText8"] = 0x2B1E4B, - ["MatchCall_BattlePyramidText9"] = 0x2B1F24, - ["MatchCall_DifferentRouteBattleRequestText1"] = 0x2AB670, - ["MatchCall_DifferentRouteBattleRequestText10"] = 0x2ABC26, - ["MatchCall_DifferentRouteBattleRequestText11"] = 0x2ABCE9, - ["MatchCall_DifferentRouteBattleRequestText12"] = 0x2ABDA2, - ["MatchCall_DifferentRouteBattleRequestText13"] = 0x2ABE5E, - ["MatchCall_DifferentRouteBattleRequestText14"] = 0x2ABF36, - ["MatchCall_DifferentRouteBattleRequestText2"] = 0x2AB6CD, - ["MatchCall_DifferentRouteBattleRequestText3"] = 0x2AB73C, - ["MatchCall_DifferentRouteBattleRequestText4"] = 0x2AB808, - ["MatchCall_DifferentRouteBattleRequestText5"] = 0x2AB8B7, - ["MatchCall_DifferentRouteBattleRequestText6"] = 0x2AB95D, - ["MatchCall_DifferentRouteBattleRequestText7"] = 0x2ABA03, - ["MatchCall_DifferentRouteBattleRequestText8"] = 0x2ABA9F, - ["MatchCall_DifferentRouteBattleRequestText9"] = 0x2ABB62, - ["MatchCall_NegativeBattleText1"] = 0x2A9FAB, - ["MatchCall_NegativeBattleText10"] = 0x2AA442, - ["MatchCall_NegativeBattleText11"] = 0x2AA4C5, - ["MatchCall_NegativeBattleText12"] = 0x2AA520, - ["MatchCall_NegativeBattleText13"] = 0x2AA5AD, - ["MatchCall_NegativeBattleText14"] = 0x2AA64D, - ["MatchCall_NegativeBattleText2"] = 0x2AA028, - ["MatchCall_NegativeBattleText3"] = 0x2AA099, - ["MatchCall_NegativeBattleText4"] = 0x2AA100, - ["MatchCall_NegativeBattleText5"] = 0x2AA188, - ["MatchCall_NegativeBattleText6"] = 0x2AA214, - ["MatchCall_NegativeBattleText7"] = 0x2AA2A1, - ["MatchCall_NegativeBattleText8"] = 0x2AA31B, - ["MatchCall_NegativeBattleText9"] = 0x2AA3A8, - ["MatchCall_PersonalizedText1"] = 0x2AC009, - ["MatchCall_PersonalizedText10"] = 0x2AC755, - ["MatchCall_PersonalizedText11"] = 0x2AC82C, - ["MatchCall_PersonalizedText12"] = 0x2AC914, - ["MatchCall_PersonalizedText13"] = 0x2ACA59, - ["MatchCall_PersonalizedText14"] = 0x2ACB02, - ["MatchCall_PersonalizedText15"] = 0x2ACC3F, - ["MatchCall_PersonalizedText16"] = 0x2ACD2F, - ["MatchCall_PersonalizedText17"] = 0x2ACE1E, - ["MatchCall_PersonalizedText18"] = 0x2ACF32, - ["MatchCall_PersonalizedText19"] = 0x2ACFBE, - ["MatchCall_PersonalizedText2"] = 0x2AC0BD, - ["MatchCall_PersonalizedText20"] = 0x2AD0AC, - ["MatchCall_PersonalizedText21"] = 0x2AD194, - ["MatchCall_PersonalizedText22"] = 0x2AD1DF, - ["MatchCall_PersonalizedText23"] = 0x2AD2A8, - ["MatchCall_PersonalizedText24"] = 0x2AD34F, - ["MatchCall_PersonalizedText25"] = 0x2AD44E, - ["MatchCall_PersonalizedText26"] = 0x2AD53A, - ["MatchCall_PersonalizedText27"] = 0x2AD642, - ["MatchCall_PersonalizedText28"] = 0x2AD801, - ["MatchCall_PersonalizedText29"] = 0x2AD92E, - ["MatchCall_PersonalizedText3"] = 0x2AC18C, - ["MatchCall_PersonalizedText30"] = 0x2ADA8F, - ["MatchCall_PersonalizedText31"] = 0x2ADB9B, - ["MatchCall_PersonalizedText32"] = 0x2ADC92, - ["MatchCall_PersonalizedText33"] = 0x2ADE08, - ["MatchCall_PersonalizedText34"] = 0x2ADF07, - ["MatchCall_PersonalizedText35"] = 0x2AE0D9, - ["MatchCall_PersonalizedText36"] = 0x2AE1FD, - ["MatchCall_PersonalizedText37"] = 0x2AE327, - ["MatchCall_PersonalizedText38"] = 0x2AE3DA, - ["MatchCall_PersonalizedText39"] = 0x2AE489, - ["MatchCall_PersonalizedText4"] = 0x2AC228, - ["MatchCall_PersonalizedText40"] = 0x2AE5CD, - ["MatchCall_PersonalizedText41"] = 0x2AE698, - ["MatchCall_PersonalizedText42"] = 0x2AE704, - ["MatchCall_PersonalizedText43"] = 0x2AE78F, - ["MatchCall_PersonalizedText44"] = 0x2AE859, - ["MatchCall_PersonalizedText45"] = 0x2AE8E6, - ["MatchCall_PersonalizedText46"] = 0x2AE998, - ["MatchCall_PersonalizedText47"] = 0x2AEA8F, - ["MatchCall_PersonalizedText48"] = 0x2AEB77, - ["MatchCall_PersonalizedText49"] = 0x2AEBFA, - ["MatchCall_PersonalizedText5"] = 0x2AC30C, - ["MatchCall_PersonalizedText50"] = 0x2AECC1, - ["MatchCall_PersonalizedText51"] = 0x2AED52, - ["MatchCall_PersonalizedText52"] = 0x2AEE35, - ["MatchCall_PersonalizedText53"] = 0x2AEEF4, - ["MatchCall_PersonalizedText54"] = 0x2AEFDA, - ["MatchCall_PersonalizedText55"] = 0x2AF0E7, - ["MatchCall_PersonalizedText56"] = 0x2AF1B8, - ["MatchCall_PersonalizedText57"] = 0x2AF2C4, - ["MatchCall_PersonalizedText58"] = 0x2AF371, - ["MatchCall_PersonalizedText59"] = 0x2AF480, - ["MatchCall_PersonalizedText6"] = 0x2AC3B6, - ["MatchCall_PersonalizedText60"] = 0x2AF671, - ["MatchCall_PersonalizedText61"] = 0x2AF7D8, - ["MatchCall_PersonalizedText62"] = 0x2AF8F7, - ["MatchCall_PersonalizedText63"] = 0x2AFA39, - ["MatchCall_PersonalizedText64"] = 0x2AFB26, - ["MatchCall_PersonalizedText7"] = 0x2AC446, - ["MatchCall_PersonalizedText8"] = 0x2AC5C7, - ["MatchCall_PersonalizedText9"] = 0x2AC682, - ["MatchCall_PositiveBattleText1"] = 0x2AA6AF, - ["MatchCall_PositiveBattleText10"] = 0x2AAB8C, - ["MatchCall_PositiveBattleText11"] = 0x2AAC25, - ["MatchCall_PositiveBattleText12"] = 0x2AAC9D, - ["MatchCall_PositiveBattleText13"] = 0x2AAD41, - ["MatchCall_PositiveBattleText14"] = 0x2AAE00, - ["MatchCall_PositiveBattleText2"] = 0x2AA730, - ["MatchCall_PositiveBattleText3"] = 0x2AA77A, - ["MatchCall_PositiveBattleText4"] = 0x2AA81C, - ["MatchCall_PositiveBattleText5"] = 0x2AA88C, - ["MatchCall_PositiveBattleText6"] = 0x2AA934, - ["MatchCall_PositiveBattleText7"] = 0x2AA9D3, - ["MatchCall_PositiveBattleText8"] = 0x2AAA40, - ["MatchCall_PositiveBattleText9"] = 0x2AAAE4, - ["MatchCall_SameRouteBattleRequestText1"] = 0x2AAE7F, - ["MatchCall_SameRouteBattleRequestText10"] = 0x2AB382, - ["MatchCall_SameRouteBattleRequestText11"] = 0x2AB410, - ["MatchCall_SameRouteBattleRequestText12"] = 0x2AB4B0, - ["MatchCall_SameRouteBattleRequestText13"] = 0x2AB538, - ["MatchCall_SameRouteBattleRequestText14"] = 0x2AB5E4, - ["MatchCall_SameRouteBattleRequestText2"] = 0x2AAEF1, - ["MatchCall_SameRouteBattleRequestText3"] = 0x2AAF69, - ["MatchCall_SameRouteBattleRequestText4"] = 0x2AB010, - ["MatchCall_SameRouteBattleRequestText5"] = 0x2AB076, - ["MatchCall_SameRouteBattleRequestText6"] = 0x2AB11A, - ["MatchCall_SameRouteBattleRequestText7"] = 0x2AB1B4, - ["MatchCall_SameRouteBattleRequestText8"] = 0x2AB23D, - ["MatchCall_SameRouteBattleRequestText9"] = 0x2AB2E9, - ["MatchCall_Text_BirchRegisterCall"] = 0x2B5F52, - ["MatchCall_Text_Brawly_PostRematch"] = 0x2B28D1, - ["MatchCall_Text_Brawly_Preparing"] = 0x2B2659, - ["MatchCall_Text_Brawly_PreparingPostGame"] = 0x2B275D, - ["MatchCall_Text_Brawly_RematchReady"] = 0x2B286F, - ["MatchCall_Text_Brendan1"] = 0x2B43EF, - ["MatchCall_Text_Brendan10"] = 0x2B49C4, - ["MatchCall_Text_Brendan11"] = 0x2B4A44, - ["MatchCall_Text_Brendan12"] = 0x2B4B28, - ["MatchCall_Text_Brendan13"] = 0x2B4C15, - ["MatchCall_Text_Brendan14"] = 0x2B4CD8, - ["MatchCall_Text_Brendan15"] = 0x2B4D46, - ["MatchCall_Text_Brendan2"] = 0x2B4486, - ["MatchCall_Text_Brendan3"] = 0x2B4560, - ["MatchCall_Text_Brendan4"] = 0x2B463F, - ["MatchCall_Text_Brendan5"] = 0x2B46B7, - ["MatchCall_Text_Brendan6"] = 0x2B4761, - ["MatchCall_Text_Brendan7"] = 0x2B47F4, - ["MatchCall_Text_Brendan8"] = 0x2B4882, - ["MatchCall_Text_Brendan9"] = 0x2B4909, - ["MatchCall_Text_BrendanRayquazaCall"] = 0x2B39C6, - ["MatchCall_Text_Drake"] = 0x2B368B, - ["MatchCall_Text_Flannery_PostRematch"] = 0x2B2D54, - ["MatchCall_Text_Flannery_Preparing"] = 0x2B2B4D, - ["MatchCall_Text_Flannery_PreparingPostGame"] = 0x2B2C0E, - ["MatchCall_Text_Flannery_RematchReady"] = 0x2B2CF1, - ["MatchCall_Text_Glacia"] = 0x2B35E4, - ["MatchCall_Text_Juan_PostRematch"] = 0x2B341E, - ["MatchCall_Text_Juan_Preparing"] = 0x2B3249, - ["MatchCall_Text_Juan_PreparingPostGame"] = 0x2B32EC, - ["MatchCall_Text_Juan_RematchReady"] = 0x2B33AA, - ["MatchCall_Text_May1"] = 0x2B3AB3, - ["MatchCall_Text_May10"] = 0x2B3FFB, - ["MatchCall_Text_May11"] = 0x2B4077, - ["MatchCall_Text_May12"] = 0x2B414B, - ["MatchCall_Text_May13"] = 0x2B4228, - ["MatchCall_Text_May14"] = 0x2B42E0, - ["MatchCall_Text_May15"] = 0x2B4350, - ["MatchCall_Text_May2"] = 0x2B3B3F, - ["MatchCall_Text_May3"] = 0x2B3C13, - ["MatchCall_Text_May4"] = 0x2B3CF3, - ["MatchCall_Text_May5"] = 0x2B3D4B, - ["MatchCall_Text_May6"] = 0x2B3DD1, - ["MatchCall_Text_May7"] = 0x2B3E69, - ["MatchCall_Text_May8"] = 0x2B3ECD, - ["MatchCall_Text_May9"] = 0x2B3F55, - ["MatchCall_Text_MayRayquazaCall"] = 0x2B38C1, - ["MatchCall_Text_Mom1"] = 0x2B227B, - ["MatchCall_Text_Mom2"] = 0x2B2310, - ["MatchCall_Text_Mom3"] = 0x2B23F3, - ["MatchCall_Text_MrStone1"] = 0x2B60C0, - ["MatchCall_Text_MrStone10"] = 0x2B6703, - ["MatchCall_Text_MrStone11"] = 0x2B67ED, - ["MatchCall_Text_MrStone2"] = 0x2B61E6, - ["MatchCall_Text_MrStone3"] = 0x2B6302, - ["MatchCall_Text_MrStone4"] = 0x2B63A0, - ["MatchCall_Text_MrStone5"] = 0x2B64A2, - ["MatchCall_Text_MrStone6"] = 0x2B6526, - ["MatchCall_Text_MrStone7"] = 0x2B65BB, - ["MatchCall_Text_MrStone8"] = 0x2B6664, - ["MatchCall_Text_MrStone9"] = 0x2B66B1, - ["MatchCall_Text_Norman1"] = 0x2B5719, - ["MatchCall_Text_Norman2"] = 0x2B5795, - ["MatchCall_Text_Norman3"] = 0x2B584D, - ["MatchCall_Text_Norman4"] = 0x2B58E3, - ["MatchCall_Text_Norman5"] = 0x2B5979, - ["MatchCall_Text_Norman_PostRematch"] = 0x2B5B5E, - ["MatchCall_Text_Norman_Preparing"] = 0x2B5A07, - ["MatchCall_Text_Norman_PreparingPostGame"] = 0x2B5A69, - ["MatchCall_Text_Norman_RematchReady"] = 0x2B5ACF, - ["MatchCall_Text_Phoebe"] = 0x2B3561, - ["MatchCall_Text_RegisteredBirch"] = 0x2B603A, - ["MatchCall_Text_Roxanne_PostRematch"] = 0x2B2607, - ["MatchCall_Text_Roxanne_Preparing"] = 0x2B2456, - ["MatchCall_Text_Roxanne_PreparingPostGame"] = 0x2B250E, - ["MatchCall_Text_Roxanne_RematchReady"] = 0x2B25C1, - ["MatchCall_Text_Scott1"] = 0x2B5184, - ["MatchCall_Text_Scott2"] = 0x2B5275, - ["MatchCall_Text_Scott3"] = 0x2B5323, - ["MatchCall_Text_Scott4"] = 0x2B53DB, - ["MatchCall_Text_Scott5"] = 0x2B54A5, - ["MatchCall_Text_Scott6"] = 0x2B5541, - ["MatchCall_Text_Scott7"] = 0x2B56CA, - ["MatchCall_Text_Sidney"] = 0x2B34CC, - ["MatchCall_Text_Steven1"] = 0x2B5B95, - ["MatchCall_Text_Steven2"] = 0x2B5C53, - ["MatchCall_Text_Steven3"] = 0x2B5CC9, - ["MatchCall_Text_Steven4"] = 0x2B5DB4, - ["MatchCall_Text_Steven5"] = 0x2B5E26, - ["MatchCall_Text_Steven6"] = 0x2B5EA2, - ["MatchCall_Text_Steven7"] = 0x2B5ED9, - ["MatchCall_Text_TateLiza_PostRematch"] = 0x2B31CD, - ["MatchCall_Text_TateLiza_Preparing"] = 0x2B2F97, - ["MatchCall_Text_TateLiza_PreparingPostGame"] = 0x2B306E, - ["MatchCall_Text_TateLiza_RematchReady"] = 0x2B3158, - ["MatchCall_Text_UnusedProfBirch"] = 0x2B6061, - ["MatchCall_Text_Wallace"] = 0x2B3790, - ["MatchCall_Text_Wally1"] = 0x2B4DE2, - ["MatchCall_Text_Wally2"] = 0x2B4E57, - ["MatchCall_Text_Wally3"] = 0x2B4EA5, - ["MatchCall_Text_Wally4"] = 0x2B4F41, - ["MatchCall_Text_Wally5"] = 0x2B4FF3, - ["MatchCall_Text_Wally6"] = 0x2B50B1, - ["MatchCall_Text_Wally7"] = 0x2B5100, - ["MatchCall_Text_Wattson_PostRematch"] = 0x2B2B01, - ["MatchCall_Text_Wattson_Preparing"] = 0x2B2912, - ["MatchCall_Text_Wattson_PreparingPostGame"] = 0x2B29CA, - ["MatchCall_Text_Wattson_RematchReady"] = 0x2B2AB6, - ["MatchCall_Text_Winona_PostRematch"] = 0x2B2F16, - ["MatchCall_Text_Winona_Preparing"] = 0x2B2DA4, - ["MatchCall_Text_Winona_PreparingPostGame"] = 0x2B2E2D, - ["MatchCall_Text_Winona_RematchReady"] = 0x2B2EC2, - ["MatchCall_WildBattleText1"] = 0x2A971C, - ["MatchCall_WildBattleText10"] = 0x2A9C36, - ["MatchCall_WildBattleText11"] = 0x2A9CC8, - ["MatchCall_WildBattleText12"] = 0x2A9D44, - ["MatchCall_WildBattleText13"] = 0x2A9DD7, - ["MatchCall_WildBattleText14"] = 0x2A9E70, - ["MatchCall_WildBattleText15"] = 0x2A9EFD, - ["MatchCall_WildBattleText2"] = 0x2A9798, - ["MatchCall_WildBattleText3"] = 0x2A9813, - ["MatchCall_WildBattleText4"] = 0x2A98A8, - ["MatchCall_WildBattleText5"] = 0x2A9977, - ["MatchCall_WildBattleText6"] = 0x2A99FD, - ["MatchCall_WildBattleText7"] = 0x2A9A78, - ["MatchCall_WildBattleText8"] = 0x2A9AE8, - ["MatchCall_WildBattleText9"] = 0x2A9BA7, - ["MauvilleCity_PokemonCenter_1F_Text_AlsoIWasThinking"] = 0x2902A7, - ["MauvilleCity_PokemonCenter_1F_Text_BardFeelingTheBlues1"] = 0x2903C0, - ["MauvilleCity_PokemonCenter_1F_Text_BardFeelingTheBlues2"] = 0x29049B, - ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesAction"] = 0x28F694, - ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesStory"] = 0x28F6B4, - ["MauvilleCity_PokemonCenter_1F_Text_BattleTowerChallengesTitle"] = 0x28F678, - ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedAction"] = 0x28EA98, - ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedStory"] = 0x28EAA8, - ["MauvilleCity_PokemonCenter_1F_Text_BerriesPlantedTitle"] = 0x28EA7D, - ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesAction"] = 0x28EB31, - ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesStory"] = 0x28EB3E, - ["MauvilleCity_PokemonCenter_1F_Text_BikeTradesTitle"] = 0x28EB19, - ["MauvilleCity_PokemonCenter_1F_Text_CantTradeThatOne"] = 0x28E45A, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockAction"] = 0x28FDBD, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockStory"] = 0x28FDCE, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedClockTitle"] = 0x28FDA2, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexAction"] = 0x28FB35, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexStory"] = 0x28FB47, - ["MauvilleCity_PokemonCenter_1F_Text_CheckedPokedexTitle"] = 0x28FB1D, - ["MauvilleCity_PokemonCenter_1F_Text_CouldThereBeOtherLegends"] = 0x28E78A, - ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedAction"] = 0x28EE5D, - ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedStory"] = 0x28EE6A, - ["MauvilleCity_PokemonCenter_1F_Text_EggsHatchedTitle"] = 0x28EE45, - ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsAction"] = 0x28F811, - ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsStory"] = 0x28F822, - ["MauvilleCity_PokemonCenter_1F_Text_EnteredContestsTitle"] = 0x28F7F6, - ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtAction"] = 0x28EDB5, - ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtStory"] = 0x28EDCF, - ["MauvilleCity_PokemonCenter_1F_Text_FishingPokemonCaughtTitle"] = 0x28EDA1, - ["MauvilleCity_PokemonCenter_1F_Text_GiddyFeelingTheBlues"] = 0x290281, - ["MauvilleCity_PokemonCenter_1F_Text_HaveYouAnyLegendaryTales"] = 0x28E7EE, - ["MauvilleCity_PokemonCenter_1F_Text_HaveYouHeardOfWord"] = 0x290666, - ["MauvilleCity_PokemonCenter_1F_Text_HearAnotherLegendaryTale"] = 0x28E844, - ["MauvilleCity_PokemonCenter_1F_Text_HearMyStory"] = 0x29022F, - ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsAction"] = 0x2900B5, - ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsStory"] = 0x2900CB, - ["MauvilleCity_PokemonCenter_1F_Text_HotSpringsTitle"] = 0x290097, - ["MauvilleCity_PokemonCenter_1F_Text_IAlreadyTaughtYou"] = 0x290598, - ["MauvilleCity_PokemonCenter_1F_Text_IKnowNoTales"] = 0x28E6AE, - ["MauvilleCity_PokemonCenter_1F_Text_IWishMorePeopleWereInterested"] = 0x28E8D9, - ["MauvilleCity_PokemonCenter_1F_Text_IllSingThisSongForAWhile"] = 0x290514, - ["MauvilleCity_PokemonCenter_1F_Text_InterviewsAction"] = 0x28EBCD, - ["MauvilleCity_PokemonCenter_1F_Text_InterviewsStory"] = 0x28EBDD, - ["MauvilleCity_PokemonCenter_1F_Text_InterviewsTitle"] = 0x28EBB5, - ["MauvilleCity_PokemonCenter_1F_Text_IveGotNothingNewToTeach"] = 0x290602, - ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedAction"] = 0x28FC85, - ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedStory"] = 0x28FC98, - ["MauvilleCity_PokemonCenter_1F_Text_LedgesJumpedTitle"] = 0x28FC6B, - ["MauvilleCity_PokemonCenter_1F_Text_LetMeSingItForYou"] = 0x2904C1, - ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksAction"] = 0x28F76A, - ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksStory"] = 0x28F776, - ["MauvilleCity_PokemonCenter_1F_Text_MadePokeblocksTitle"] = 0x28F751, - ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesAction"] = 0x28F314, - ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesStory"] = 0x28F32A, - ["MauvilleCity_PokemonCenter_1F_Text_MovedBasesTitle"] = 0x28F2FC, - ["MauvilleCity_PokemonCenter_1F_Text_NotWorthyOfLegend"] = 0x28E881, - ["MauvilleCity_PokemonCenter_1F_Text_OnceBelongedToPlayerDoYouWantIt"] = 0x28E2A9, - ["MauvilleCity_PokemonCenter_1F_Text_PickADecorItem"] = 0x28E23F, - ["MauvilleCity_PokemonCenter_1F_Text_PickTheDecorToTrade"] = 0x28E323, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtAction"] = 0x28ED21, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtStory"] = 0x28ED30, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonCaughtTitle"] = 0x28ED04, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedAction"] = 0x28EEF1, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedStory"] = 0x28EF01, - ["MauvilleCity_PokemonCenter_1F_Text_PokemonEvolvedTitle"] = 0x28EEDD, - ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsAction"] = 0x28FBD9, - ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsStory"] = 0x28FBEA, - ["MauvilleCity_PokemonCenter_1F_Text_ReceivedRibbonsTitle"] = 0x28FBC4, - ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeAction"] = 0x28F05A, - ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeStory"] = 0x28F071, - ["MauvilleCity_PokemonCenter_1F_Text_RestedAtHomeTitle"] = 0x28F045, - ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarAction"] = 0x28FFFA, - ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarStory"] = 0x29000D, - ["MauvilleCity_PokemonCenter_1F_Text_RodeCableCarTitle"] = 0x28FFDD, - ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsAction"] = 0x28F5D1, - ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsStory"] = 0x28F5F2, - ["MauvilleCity_PokemonCenter_1F_Text_RouletteWinsTitle"] = 0x28F5BE, - ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesAction"] = 0x28F10D, - ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesStory"] = 0x28F125, - ["MauvilleCity_PokemonCenter_1F_Text_SafariGamesTitle"] = 0x28F0F3, - ["MauvilleCity_PokemonCenter_1F_Text_SavedGameAction"] = 0x28E947, - ["MauvilleCity_PokemonCenter_1F_Text_SavedGameStory"] = 0x28E956, - ["MauvilleCity_PokemonCenter_1F_Text_SavedGameTitle"] = 0x28E930, - ["MauvilleCity_PokemonCenter_1F_Text_SendDecorToYourPC"] = 0x28E424, - ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsAction"] = 0x28F51B, - ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsStory"] = 0x28F538, - ["MauvilleCity_PokemonCenter_1F_Text_SlotJackpotsTitle"] = 0x28F50C, - ["MauvilleCity_PokemonCenter_1F_Text_SoWellTradeTheseDecor"] = 0x28E3C4, - ["MauvilleCity_PokemonCenter_1F_Text_StorytellerFeelingTheBlues"] = 0x28E64D, - ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedAction"] = 0x28FD35, - ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedStory"] = 0x28FD40, - ["MauvilleCity_PokemonCenter_1F_Text_TVWatchedTitle"] = 0x28FD1D, - ["MauvilleCity_PokemonCenter_1F_Text_TeachWhatsHipAndHappening"] = 0x29054C, - ["MauvilleCity_PokemonCenter_1F_Text_ThatDecorIsInUse"] = 0x28E3EC, - ["MauvilleCity_PokemonCenter_1F_Text_ThatHowYouWantedSongToGo"] = 0x2904EB, - ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedAction"] = 0x28FA99, - ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedStory"] = 0x28FAA7, - ["MauvilleCity_PokemonCenter_1F_Text_TimesRainedTitle"] = 0x28FA81, - ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedAction"] = 0x28F941, - ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedStory"] = 0x28F949, - ["MauvilleCity_PokemonCenter_1F_Text_TimesShoppedTitle"] = 0x28F92F, - ["MauvilleCity_PokemonCenter_1F_Text_TraderFeelingTheBlues"] = 0x28E1E8, - ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesAction"] = 0x28EC79, - ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesStory"] = 0x28EC81, - ["MauvilleCity_PokemonCenter_1F_Text_TrainerBattlesTitle"] = 0x28EC60, - ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedAction"] = 0x28E9EF, - ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedStory"] = 0x28E9FE, - ["MauvilleCity_PokemonCenter_1F_Text_TrendsStartedTitle"] = 0x28E9D7, - ["MauvilleCity_PokemonCenter_1F_Text_UsedCutAction"] = 0x28F1D5, - ["MauvilleCity_PokemonCenter_1F_Text_UsedCutStory"] = 0x28F1DE, - ["MauvilleCity_PokemonCenter_1F_Text_UsedCutTitle"] = 0x28F1BE, - ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareAction"] = 0x28FF27, - ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareStory"] = 0x28FF44, - ["MauvilleCity_PokemonCenter_1F_Text_UsedDaycareTitle"] = 0x28FF0C, - ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderAction"] = 0x28F9EA, - ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderStory"] = 0x28F9FD, - ["MauvilleCity_PokemonCenter_1F_Text_UsedItemFinderTitle"] = 0x28F9D1, - ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterAction"] = 0x28EF95, - ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterStory"] = 0x28EFAA, - ["MauvilleCity_PokemonCenter_1F_Text_UsedPokemonCenterTitle"] = 0x28EF73, - ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashAction"] = 0x28F269, - ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashStory"] = 0x28F277, - ["MauvilleCity_PokemonCenter_1F_Text_UsedRockSmashTitle"] = 0x28F24F, - ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashAction"] = 0x28F3C6, - ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashStory"] = 0x28F3D2, - ["MauvilleCity_PokemonCenter_1F_Text_UsedSplashTitle"] = 0x28F3AD, - ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleAction"] = 0x28F461, - ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleStory"] = 0x28F47C, - ["MauvilleCity_PokemonCenter_1F_Text_UsedStruggleTitle"] = 0x28F44B, - ["MauvilleCity_PokemonCenter_1F_Text_WantToTradeDecor"] = 0x28E1B1, - ["MauvilleCity_PokemonCenter_1F_Text_WeShouldChatAgain"] = 0x2902BD, - ["MauvilleCity_PokemonCenter_1F_Text_WeveAlreadyTraded"] = 0x28E20E, - ["MauvilleCity_PokemonCenter_1F_Text_WhichTaleToTell"] = 0x28E673, - ["MauvilleCity_PokemonCenter_1F_Text_WillYouHearMyTale"] = 0x28E5F6, - ["MauvilleCity_PokemonCenter_1F_Text_WishICouldPlaySongForOthers"] = 0x2903E6, - ["MauvilleCity_PokemonCenter_1F_Text_WonContestsAction"] = 0x28F8AF, - ["MauvilleCity_PokemonCenter_1F_Text_WonContestsStory"] = 0x28F8BC, - ["MauvilleCity_PokemonCenter_1F_Text_WonContestsTitle"] = 0x28F89C, - ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryAction"] = 0x28FE72, - ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryStory"] = 0x28FE88, - ["MauvilleCity_PokemonCenter_1F_Text_WonLotteryTitle"] = 0x28FE57, - ["MauvilleCity_PokemonCenter_1F_Text_WouldYouLikeToHearMySong"] = 0x29038E, - ["MauvilleCity_PokemonCenter_1F_Text_WouldYouLikeToWriteSomeLyrics"] = 0x290421, - ["MauvilleCity_PokemonCenter_1F_Text_YouDidStatXTimes"] = 0x28E726, - ["MauvilleCity_PokemonCenter_1F_Text_YouDontHaveAnyDecor"] = 0x28E2E3, - ["MauvilleCity_PokemonCenter_1F_Text_YouDontWantAnything"] = 0x28E27F, - ["MauvilleCity_PokemonCenter_1F_Text_YouDontWantToTrade"] = 0x28E356, - ["MauvilleCity_PokemonCenter_1F_Text_YouveNoRoomForThis"] = 0x28E380, - ["MossdeepCity_GameCorner_1F_Text_AdapterNotConnected"] = 0x278D68, - ["MossdeepCity_GameCorner_1F_Text_AllGoodToGo"] = 0x278E00, - ["MossdeepCity_GameCorner_1F_Text_ComeAgain"] = 0x278D51, - ["MossdeepCity_GameCorner_1F_Text_DescribeWhichGame"] = 0x278A7D, - ["MossdeepCity_GameCorner_1F_Text_DodrioBerryPickingInfo"] = 0x278BF1, - ["MossdeepCity_GameCorner_1F_Text_EnterWhichPokemon"] = 0x278DD9, - ["MossdeepCity_GameCorner_1F_Text_ExplainRequiredMon"] = 0x278E60, - ["MossdeepCity_GameCorner_1F_Text_LeavingDoComeAgain"] = 0x278E3C, - ["MossdeepCity_GameCorner_1F_Text_OnlyDodrioAllowed"] = 0x278FA4, - ["MossdeepCity_GameCorner_1F_Text_PlayWhichGame"] = 0x278DAD, - ["MossdeepCity_GameCorner_1F_Text_PokemonJumpInfo"] = 0x278ACB, - ["MossdeepCity_GameCorner_1F_Text_RetryPlease"] = 0x278FE0, - ["MossdeepCity_GameCorner_1F_Text_ShortJumpingPokemonAllowed"] = 0x278EDC, - ["MossdeepCity_GameCorner_1F_Text_TalkToOldManToPlay"] = 0x278CAC, - ["MossdeepCity_GameCorner_1F_Text_WelcomeCanYouWait"] = 0x278CEB, - ["MoveTutor_MimicDeclined"] = 0x2C737F, - ["MoveTutor_MimicTeach"] = 0x2C72B6, - ["MoveTutor_Text_DoubleEdgeDeclined"] = 0x2C7C7E, - ["MoveTutor_Text_DoubleEdgeTaught"] = 0x2C7CC8, - ["MoveTutor_Text_DoubleEdgeTeach"] = 0x2C7B8E, - ["MoveTutor_Text_DoubleEdgeWhichMon"] = 0x2C7C98, - ["MoveTutor_Text_DynamicPunchDeclined"] = 0x2C7AD4, - ["MoveTutor_Text_DynamicPunchTaught"] = 0x2C7B4F, - ["MoveTutor_Text_DynamicPunchTeach"] = 0x2C79A6, - ["MoveTutor_Text_DynamicPunchWhichMon"] = 0x2C7B0D, - ["MoveTutor_Text_ExplosionDeclined"] = 0x2C7E04, - ["MoveTutor_Text_ExplosionTaught"] = 0x2C7E7A, - ["MoveTutor_Text_ExplosionTeach"] = 0x2C7CFA, - ["MoveTutor_Text_ExplosionWhichMon"] = 0x2C7E40, - ["MoveTutor_Text_FuryCutterDeclined"] = 0x2C7221, - ["MoveTutor_Text_FuryCutterTaught"] = 0x2C726E, - ["MoveTutor_Text_FuryCutterTeach"] = 0x2C7174, - ["MoveTutor_Text_FuryCutterWhichMon"] = 0x2C7243, - ["MoveTutor_Text_MetronomeDeclined"] = 0x2C7556, - ["MoveTutor_Text_MetronomeTaught"] = 0x2C75B5, - ["MoveTutor_Text_MetronomeTeach"] = 0x2C7449, - ["MoveTutor_Text_MetronomeWhichMon"] = 0x2C7582, - ["MoveTutor_Text_MimicTaught"] = 0x2C73F6, - ["MoveTutor_Text_MimicWhichMon"] = 0x2C73B1, - ["MoveTutor_Text_RolloutDeclined"] = 0x2C70C4, - ["MoveTutor_Text_RolloutTaught"] = 0x2C7133, - ["MoveTutor_Text_RolloutTeach"] = 0x2C6FDB, - ["MoveTutor_Text_RolloutWhichMon"] = 0x2C70F3, - ["MoveTutor_Text_SleepTalkDeclined"] = 0x2C76E2, - ["MoveTutor_Text_SleepTalkTaught"] = 0x2C775A, - ["MoveTutor_Text_SleepTalkTeach"] = 0x2C7637, - ["MoveTutor_Text_SleepTalkWhichMon"] = 0x2C7721, - ["MoveTutor_Text_SubstituteDeclined"] = 0x2C78D1, - ["MoveTutor_Text_SubstituteTaught"] = 0x2C794B, - ["MoveTutor_Text_SubstituteTeach"] = 0x2C77C6, - ["MoveTutor_Text_SubstituteWhichMon"] = 0x2C7911, - ["MoveTutor_Text_SwaggerDeclined"] = 0x2C6F33, - ["MoveTutor_Text_SwaggerTaught"] = 0x2C6F9E, - ["MoveTutor_Text_SwaggerTeach"] = 0x2C6E37, - ["MoveTutor_Text_SwaggerWhichMon"] = 0x2C6F66, - ["MoveTutor_Text_ThisMoveCanOnlyBeLearnedOnce"] = 0x2C6E05, - ["MysteryGift_Text_TheresATicketForYou"] = 0x273594, - ["MysteryGift_Text_TryUsingItAtLilycovePort"] = 0x2735F2, - ["NavelRock_Harbor_Text_SailorReturn"] = 0x2C6CE6, - ["PetalburgCity_Gym_Text_GiveEnigmaBerry"] = 0x2A6D3D, - ["ProfileMan_Text_CollectTrainerProfiles"] = 0x291687, - ["ProfileMan_Text_EasyChatExplanation"] = 0x291729, - ["ProfileMan_Text_EvenBetterThanLastProfile"] = 0x291A6B, - ["ProfileMan_Text_FantasticProfile"] = 0x291B22, - ["ProfileMan_Text_ImagineYouWouldHaveWonderfulProfile"] = 0x291991, - ["ProfileMan_Text_LetsSeeItThen"] = 0x291969, - ["ProfileMan_Text_LikeProfileWayItIs"] = 0x291ABA, - ["ProfileMan_Text_MayISeeYourNewProfile"] = 0x291A4F, - ["ProfileMan_Text_MayISeeYourProfile"] = 0x2916FF, - ["ProfileMan_Text_NotIntoItRightNow"] = 0x2919DC, - ["ProfileMan_Text_YouHaveWonderfulSmile"] = 0x291A1B, - ["ProfileMan_Text_YouKnowSecretSaying"] = 0x291BB7, - ["Questionnaire_Text_FillOut"] = 0x27339F, - ["Questionnaire_Text_ThankYou"] = 0x2733D8, - ["Questionnaire_Text_YouCanAccessMysteryEvent"] = 0x273559, - ["Questionnaire_Text_YouCanAccessMysteryGift"] = 0x2734CC, - ["Questionnaire_Text_YouKnowThoseWordsEvent"] = 0x273506, - ["Questionnaire_Text_YouKnowThoseWordsGift"] = 0x273446, - ["RecordCorner_Text_PlayerSentOverOneX"] = 0x27863C, - ["RecordCorner_Text_TakeSeatAndWait"] = 0x27861C, - ["RecordCorner_Text_ThanksForComing"] = 0x27850E, - ["Roulette_Text_BoardWillBeCleared"] = 0x2A5C21, - ["Roulette_Text_CoinCaseIsFull"] = 0x2A5C61, - ["Roulette_Text_ControlsInstruction"] = 0x2A5B89, - ["Roulette_Text_ItsAHit"] = 0x2A5BCB, - ["Roulette_Text_Jackpot"] = 0x2A5BD7, - ["Roulette_Text_KeepPlaying"] = 0x2A5C13, - ["Roulette_Text_NoCoinsLeft"] = 0x2A5C04, - ["Roulette_Text_NotEnoughCoins"] = 0x2A5B4E, - ["Roulette_Text_NothingDoing"] = 0x2A5BE0, - ["Roulette_Text_PlayMinimumWagerIsX"] = 0x2A5B12, - ["Roulette_Text_SpecialRateTable"] = 0x2A5B6B, - ["Roulette_Text_YouDontHaveACoinCase"] = 0x2A5C45, - ["Roulette_Text_YouveWonXCoins"] = 0x2A5BEF, - ["Route102_Text_AllenDefeated"] = 0x2947AA, - ["Route102_Text_AllenIntro"] = 0x294775, - ["Route102_Text_AllenPostBattle"] = 0x2947DB, - ["Route102_Text_CalvinDefeated"] = 0x29457C, - ["Route102_Text_CalvinIntro"] = 0x294513, - ["Route102_Text_CalvinPostBattle"] = 0x2945AC, - ["Route102_Text_CalvinRegister"] = 0x2945EB, - ["Route102_Text_CalvinRegisterShort"] = 0x294668, - ["Route102_Text_CalvinRematchDefeated"] = 0x29470C, - ["Route102_Text_CalvinRematchIntro"] = 0x2946AE, - ["Route102_Text_CalvinRematchPostBattle"] = 0x29473C, - ["Route102_Text_RickDefeated"] = 0x294847, - ["Route102_Text_RickIntro"] = 0x29480C, - ["Route102_Text_RickPostBattle"] = 0x294859, - ["Route102_Text_TianaDefeated"] = 0x2948F3, - ["Route102_Text_TianaIntro"] = 0x29489E, - ["Route102_Text_TianaPostBattle"] = 0x294916, - ["Route103_Text_AmyDefeated"] = 0x294A3D, - ["Route103_Text_AmyIntro"] = 0x2949F9, - ["Route103_Text_AmyLivRegister"] = 0x294B8A, - ["Route103_Text_AmyNotEnoughPokemon"] = 0x294AAE, - ["Route103_Text_AmyPostBattle"] = 0x294A52, - ["Route103_Text_AmyRematchDefeated"] = 0x294C6D, - ["Route103_Text_AmyRematchIntro"] = 0x294C29, - ["Route103_Text_AmyRematchNotEnoughPokemon"] = 0x294CEF, - ["Route103_Text_AmyRematchPostBattle"] = 0x294C93, - ["Route103_Text_AndrewDefeated"] = 0x294E92, - ["Route103_Text_AndrewIntro"] = 0x294E34, - ["Route103_Text_AndrewPostBattle"] = 0x294EBB, - ["Route103_Text_DaisyDefeated"] = 0x29498F, - ["Route103_Text_DaisyIntro"] = 0x29495A, - ["Route103_Text_DaisyPostBattle"] = 0x2949B4, - ["Route103_Text_IsabelleDefeated"] = 0x295146, - ["Route103_Text_IsabelleIntro"] = 0x295116, - ["Route103_Text_IsabellePostBattle"] = 0x29514D, - ["Route103_Text_LivDefeated"] = 0x294B22, - ["Route103_Text_LivIntro"] = 0x294AFD, - ["Route103_Text_LivNotEnoughPokemon"] = 0x294BD0, - ["Route103_Text_LivPostBattle"] = 0x294B40, - ["Route103_Text_LivRematchDefeated"] = 0x294D63, - ["Route103_Text_LivRematchIntro"] = 0x294D3E, - ["Route103_Text_LivRematchNotEnoughPokemon"] = 0x294DDB, - ["Route103_Text_LivRematchPostBattle"] = 0x294D89, - ["Route103_Text_MarcosDefeated"] = 0x29522B, - ["Route103_Text_MarcosIntro"] = 0x295206, - ["Route103_Text_MarcosPostBattle"] = 0x295246, - ["Route103_Text_MiguelDefeated"] = 0x294F21, - ["Route103_Text_MiguelIntro"] = 0x294EDF, - ["Route103_Text_MiguelPostBattle"] = 0x294F42, - ["Route103_Text_MiguelRegister"] = 0x294F7E, - ["Route103_Text_MiguelRematchDefeated"] = 0x295006, - ["Route103_Text_MiguelRematchIntro"] = 0x294FC3, - ["Route103_Text_MiguelRematchPostBattle"] = 0x29501E, - ["Route103_Text_PeteDefeated"] = 0x295093, - ["Route103_Text_PeteIntro"] = 0x295063, - ["Route103_Text_PetePostBattle"] = 0x2950AA, - ["Route103_Text_RhettDefeated"] = 0x2951C6, - ["Route103_Text_RhettIntro"] = 0x295199, - ["Route103_Text_RhettPostBattle"] = 0x2951DE, - ["Route104_PrettyPetalFlowerShop_Text_BerriesExplanation"] = 0x2A775B, - ["Route104_PrettyPetalFlowerShop_Text_FlowersBringHappiness"] = 0x2A78DF, - ["Route104_PrettyPetalFlowerShop_Text_ImGrowingFlowers"] = 0x2A7A98, - ["Route104_PrettyPetalFlowerShop_Text_IntroLearnAboutBerries"] = 0x2A7706, - ["Route104_PrettyPetalFlowerShop_Text_LearnAboutBerries"] = 0x2A76D9, - ["Route104_PrettyPetalFlowerShop_Text_MachineMixesBerries"] = 0x2A7AF3, - ["Route104_PrettyPetalFlowerShop_Text_ThisIsPrettyPetalFlowerShop"] = 0x2A7686, - ["Route104_PrettyPetalFlowerShop_Text_WailmerPailExplanation"] = 0x2A79A6, - ["Route104_PrettyPetalFlowerShop_Text_YouCanHaveThis"] = 0x2A7916, - ["Route104_Text_BillyDefeat"] = 0x2955B4, - ["Route104_Text_BillyIntro"] = 0x29558A, - ["Route104_Text_BillyPostBattle"] = 0x2955E8, - ["Route104_Text_CindyDefeat"] = 0x295ABB, - ["Route104_Text_CindyIntro"] = 0x295A7E, - ["Route104_Text_CindyPostBattle"] = 0x295AC3, - ["Route104_Text_CindyPostRematch"] = 0x295C1F, - ["Route104_Text_CindyRegister1"] = 0x295B01, - ["Route104_Text_CindyRegister2"] = 0x295B60, - ["Route104_Text_CindyRematchDefeat"] = 0x295BFA, - ["Route104_Text_CindyRematchIntro"] = 0x295BC8, - ["Route104_Text_DarianDefeat"] = 0x295CC9, - ["Route104_Text_DarianIntro"] = 0x295C5D, - ["Route104_Text_DarianPostBattle"] = 0x295CD3, - ["Route104_Text_GinaDefeat"] = 0x2952E6, - ["Route104_Text_GinaIntro"] = 0x2952BB, - ["Route104_Text_GinaNotEnoughMons"] = 0x295330, - ["Route104_Text_GinaPostBattle"] = 0x2952FE, - ["Route104_Text_HaleyDefeat"] = 0x29566F, - ["Route104_Text_HaleyIntro"] = 0x29563A, - ["Route104_Text_HaleyPostBattle"] = 0x295689, - ["Route104_Text_HaleyPostRematch"] = 0x29580C, - ["Route104_Text_HaleyRegister1"] = 0x2956FF, - ["Route104_Text_HaleyRegister2"] = 0x29576B, - ["Route104_Text_HaleyRematchDefeat"] = 0x2957F0, - ["Route104_Text_HaleyRematchIntro"] = 0x2957D7, - ["Route104_Text_IvanDefeat"] = 0x295509, - ["Route104_Text_IvanIntro"] = 0x2954BD, - ["Route104_Text_IvanPostBattle"] = 0x29554E, - ["Route104_Text_MiaDefeat"] = 0x2953E1, - ["Route104_Text_MiaIntro"] = 0x2953AF, - ["Route104_Text_MiaNotEnoughMons"] = 0x295449, - ["Route104_Text_MiaPostBattle"] = 0x29540D, - ["Route104_Text_PlantBerriesInSoilTakeThis"] = 0x2A6D86, - ["Route104_Text_TrainersOftenMakeMonHoldBerries"] = 0x2A6E32, - ["Route104_Text_WinstonDefeat"] = 0x2958AD, - ["Route104_Text_WinstonIntro"] = 0x295870, - ["Route104_Text_WinstonPostBattle"] = 0x2958C1, - ["Route104_Text_WinstonPostRematch"] = 0x295A1E, - ["Route104_Text_WinstonRegister1"] = 0x2958F8, - ["Route104_Text_WinstonRegister2"] = 0x29595A, - ["Route104_Text_WinstonRematchDefeat"] = 0x2959FC, - ["Route104_Text_WinstonRematchIntro"] = 0x2959BC, - ["Route105_Text_AndresDefeated"] = 0x2960DB, - ["Route105_Text_AndresIntro"] = 0x2960A9, - ["Route105_Text_AndresPostBattle"] = 0x2960FA, - ["Route105_Text_AndresRegister"] = 0x296159, - ["Route105_Text_AndresRematchDefeated"] = 0x2961DD, - ["Route105_Text_AndresRematchIntro"] = 0x29619E, - ["Route105_Text_AndresRematchPostBattle"] = 0x2961FE, - ["Route105_Text_BeverlyDefeated"] = 0x295FA0, - ["Route105_Text_BeverlyIntro"] = 0x295F5C, - ["Route105_Text_DominikDefeated"] = 0x295EEA, - ["Route105_Text_DominikIntro"] = 0x295EB7, - ["Route105_Text_DominikPostBattle"] = 0x295F08, - ["Route105_Text_FosterDefeated"] = 0x295D62, - ["Route105_Text_FosterIntro"] = 0x295D0F, - ["Route105_Text_FosterPostBattle"] = 0x295DAB, - ["Route105_Text_ImaniDefeated"] = 0x296059, - ["Route105_Text_ImaniIntro"] = 0x296025, - ["Route105_Text_ImaniPostBattle"] = 0x296076, - ["Route105_Text_JosueDefeated"] = 0x2962CB, - ["Route105_Text_JosueIntro"] = 0x29626F, - ["Route105_Text_JosuePostBattle"] = 0x2962EC, - ["Route105_Text_LuisDefeated"] = 0x295E66, - ["Route105_Text_LuisIntro"] = 0x295DF5, - ["Route105_Text_LuisPostBattle"] = 0x295E72, - ["Route105_Text_PostBattle"] = 0x295FAE, - ["Route106_Text_DouglasDefeated"] = 0x2965FD, - ["Route106_Text_DouglasIntro"] = 0x2965BB, - ["Route106_Text_DouglasPostBattle"] = 0x296608, - ["Route106_Text_ElliotDefeated"] = 0x29635C, - ["Route106_Text_ElliotIntro"] = 0x296327, - ["Route106_Text_ElliotPostBattle"] = 0x29638C, - ["Route106_Text_ElliotRegister"] = 0x2963E3, - ["Route106_Text_ElliotRematchDefeated"] = 0x296477, - ["Route106_Text_ElliotRematchIntro"] = 0x29642E, - ["Route106_Text_ElliotRematchPostBattle"] = 0x296493, - ["Route106_Text_KylaDefeated"] = 0x296674, - ["Route106_Text_KylaIntro"] = 0x296628, - ["Route106_Text_KylaPostBattle"] = 0x29669E, - ["Route106_Text_NedDefeated"] = 0x296553, - ["Route106_Text_NedIntro"] = 0x2964D4, - ["Route106_Text_NedPostBattle"] = 0x296588, - ["Route107_Text_BethDefeated"] = 0x296A17, - ["Route107_Text_BethIntro"] = 0x2969E4, - ["Route107_Text_BethPostBattle"] = 0x296A35, - ["Route107_Text_CamronDefeated"] = 0x296CAD, - ["Route107_Text_CamronIntro"] = 0x296C6F, - ["Route107_Text_CamronPostBattle"] = 0x296CC0, - ["Route107_Text_DarrinDefeated"] = 0x29672F, - ["Route107_Text_DarrinIntro"] = 0x2966E5, - ["Route107_Text_DarrinPostBattle"] = 0x29675B, - ["Route107_Text_DeniseDefeated"] = 0x29699E, - ["Route107_Text_DeniseIntro"] = 0x296974, - ["Route107_Text_DenisePostBattle"] = 0x2969AB, - ["Route107_Text_LisaDefeated"] = 0x296AA7, - ["Route107_Text_LisaIntro"] = 0x296A77, - ["Route107_Text_LisaNotEnoughPokemon"] = 0x296B1E, - ["Route107_Text_LisaPostBattle"] = 0x296ADC, - ["Route107_Text_RayDefeated"] = 0x296BB3, - ["Route107_Text_RayIntro"] = 0x296B57, - ["Route107_Text_RayNotEnoughPokemon"] = 0x296C34, - ["Route107_Text_RayPostBattle"] = 0x296BE1, - ["Route107_Text_TonyDefeated"] = 0x2967D8, - ["Route107_Text_TonyIntro"] = 0x2967AD, - ["Route107_Text_TonyPostBattle"] = 0x2967FF, - ["Route107_Text_TonyRegister"] = 0x29685A, - ["Route107_Text_TonyRematchDefeated"] = 0x2968D5, - ["Route107_Text_TonyRematchIntro"] = 0x296897, - ["Route107_Text_TonyRematchPostBattle"] = 0x296923, - ["Route108_Text_CarolinaDefeated"] = 0x2970D7, - ["Route108_Text_CarolinaIntro"] = 0x297094, - ["Route108_Text_CarolinaPostBattle"] = 0x2970F0, - ["Route108_Text_CoryDefeated"] = 0x296F93, - ["Route108_Text_CoryIntro"] = 0x296F5D, - ["Route108_Text_CoryPostBattle"] = 0x296FB0, - ["Route108_Text_CoryRegister"] = 0x296FD8, - ["Route108_Text_CoryRematchDefeated"] = 0x297036, - ["Route108_Text_CoryRematchIntro"] = 0x297011, - ["Route108_Text_CoryRematchPostBattle"] = 0x297059, - ["Route108_Text_JeromeDefeated"] = 0x296D3C, - ["Route108_Text_JeromeIntro"] = 0x296D10, - ["Route108_Text_JeromePostBattle"] = 0x296D6E, - ["Route108_Text_MatthewDefeated"] = 0x296DEA, - ["Route108_Text_MatthewIntro"] = 0x296DAF, - ["Route108_Text_MatthewPostBattle"] = 0x296E03, - ["Route108_Text_MissyDefeated"] = 0x296EFC, - ["Route108_Text_MissyIntro"] = 0x296EC7, - ["Route108_Text_MissyPostBattle"] = 0x296F2A, - ["Route108_Text_TaraDefeated"] = 0x296E71, - ["Route108_Text_TaraIntro"] = 0x296E33, - ["Route108_Text_TaraPostBattle"] = 0x296E7A, - ["Route109_Text_AliceDefeated"] = 0x2971FD, - ["Route109_Text_AliceIntro"] = 0x2971D1, - ["Route109_Text_AlicePostBattle"] = 0x29720F, - ["Route109_Text_AustinaDefeated"] = 0x297601, - ["Route109_Text_AustinaIntro"] = 0x2975C0, - ["Route109_Text_AustinaPostBattle"] = 0x29762A, - ["Route109_Text_CarterDefeated"] = 0x2976FC, - ["Route109_Text_CarterIntro"] = 0x2976C5, - ["Route109_Text_CarterPostBattle"] = 0x297715, - ["Route109_Text_ChandlerDefeated"] = 0x2979AE, - ["Route109_Text_ChandlerIntro"] = 0x29798A, - ["Route109_Text_ChandlerPostBattle"] = 0x2979BF, - ["Route109_Text_DavidDefeated"] = 0x29717D, - ["Route109_Text_DavidIntro"] = 0x297140, - ["Route109_Text_DavidPostBattle"] = 0x297191, - ["Route109_Text_EdmondDefeated"] = 0x2972CE, - ["Route109_Text_EdmondIntro"] = 0x2972B3, - ["Route109_Text_EdmondPostBattle"] = 0x2972ED, - ["Route109_Text_ElijahDefeated"] = 0x297AC7, - ["Route109_Text_ElijahIntro"] = 0x297A82, - ["Route109_Text_ElijahPostBattle"] = 0x297AE5, - ["Route109_Text_GwenDefeated"] = 0x297691, - ["Route109_Text_GwenIntro"] = 0x297667, - ["Route109_Text_GwenPostBattle"] = 0x2976A4, - ["Route109_Text_HaileyDefeated"] = 0x297A15, - ["Route109_Text_HaileyIntro"] = 0x2979EC, - ["Route109_Text_HaileyPostBattle"] = 0x297A40, - ["Route109_Text_HueyDefeated"] = 0x29727B, - ["Route109_Text_HueyIntro"] = 0x297235, - ["Route109_Text_HueyPostBattle"] = 0x29728C, - ["Route109_Text_LolaDefeated"] = 0x2974CF, - ["Route109_Text_LolaIntro"] = 0x29749C, - ["Route109_Text_LolaPostBattle"] = 0x2974D6, - ["Route109_Text_LolaRegister"] = 0x297520, - ["Route109_Text_LolaRematchDefeated"] = 0x297576, - ["Route109_Text_LolaRematchIntro"] = 0x297538, - ["Route109_Text_LolaRematchPostBattle"] = 0x29757D, - ["Route109_Text_MelDefeated"] = 0x2978BD, - ["Route109_Text_MelIntro"] = 0x297872, - ["Route109_Text_MelNotEnoughPokemon"] = 0x29792E, - ["Route109_Text_MelPostBattle"] = 0x2978F1, - ["Route109_Text_PaulDefeated"] = 0x2977B0, - ["Route109_Text_PaulIntro"] = 0x297754, - ["Route109_Text_PaulNotEnoughPokemon"] = 0x29781D, - ["Route109_Text_PaulPostBattle"] = 0x2977C7, - ["Route109_Text_RickyDefeated"] = 0x297379, - ["Route109_Text_RickyIntro"] = 0x29733B, - ["Route109_Text_RickyPostBattle"] = 0x297380, - ["Route109_Text_RickyRegister"] = 0x2973C1, - ["Route109_Text_RickyRematchDefeated"] = 0x297437, - ["Route109_Text_RickyRematchIntro"] = 0x2973FF, - ["Route109_Text_RickyRematchPostBattle"] = 0x297458, - ["Route110_Text_AbigailDefeated"] = 0x297E69, - ["Route110_Text_AbigailIntro"] = 0x297DFA, - ["Route110_Text_AbigailPostBattle"] = 0x297E88, - ["Route110_Text_AbigailRegister"] = 0x297ECD, - ["Route110_Text_AbigailRematchDefeated"] = 0x297F37, - ["Route110_Text_AbigailRematchIntro"] = 0x297F09, - ["Route110_Text_AbigailRematchPostBattle"] = 0x297F58, - ["Route110_Text_AlyssaDefeated"] = 0x2985F9, - ["Route110_Text_AlyssaIntro"] = 0x2985AB, - ["Route110_Text_AlyssaPostBattle"] = 0x298612, - ["Route110_Text_AnthonyDefeated"] = 0x297C0F, - ["Route110_Text_AnthonyIntro"] = 0x297BE7, - ["Route110_Text_AnthonyPostBattle"] = 0x297C1F, - ["Route110_Text_BenjaminDefeated"] = 0x297C8A, - ["Route110_Text_BenjaminIntro"] = 0x297C63, - ["Route110_Text_BenjaminPostBattle"] = 0x297CB4, - ["Route110_Text_BenjaminRegister"] = 0x297CFE, - ["Route110_Text_BenjaminRematchDefeated"] = 0x297D8E, - ["Route110_Text_BenjaminRematchIntro"] = 0x297D4B, - ["Route110_Text_BenjaminRematchPostBattle"] = 0x297DB0, - ["Route110_Text_DaleDefeated"] = 0x2982F5, - ["Route110_Text_DaleIntro"] = 0x2982CC, - ["Route110_Text_DalePostBattle"] = 0x298303, - ["Route110_Text_EdwardDefeated"] = 0x298064, - ["Route110_Text_EdwardIntro"] = 0x29802B, - ["Route110_Text_EdwardPostBattle"] = 0x29808A, - ["Route110_Text_EdwinDefeated"] = 0x29818F, - ["Route110_Text_EdwinIntro"] = 0x29815E, - ["Route110_Text_EdwinPostBattle"] = 0x2981B3, - ["Route110_Text_EdwinRegister"] = 0x298201, - ["Route110_Text_EdwinRematchDefeated"] = 0x298288, - ["Route110_Text_EdwinRematchIntro"] = 0x298232, - ["Route110_Text_EdwinRematchPostBattle"] = 0x2982A2, - ["Route110_Text_IsabelDefeated"] = 0x298389, - ["Route110_Text_IsabelIntro"] = 0x298349, - ["Route110_Text_IsabelPostBattle"] = 0x2983A2, - ["Route110_Text_IsabelRegister"] = 0x2983EE, - ["Route110_Text_IsabelRematchDefeated"] = 0x2984AF, - ["Route110_Text_IsabelRematchIntro"] = 0x298466, - ["Route110_Text_IsabelRematchPostBattle"] = 0x2984C8, - ["Route110_Text_JaclynDefeated"] = 0x2980E5, - ["Route110_Text_JaclynIntro"] = 0x2980B9, - ["Route110_Text_JaclynPostBattle"] = 0x2980F8, - ["Route110_Text_JacobDefeated"] = 0x297B77, - ["Route110_Text_JacobIntro"] = 0x297B3F, - ["Route110_Text_JacobPostBattle"] = 0x297B8F, - ["Route110_Text_JasmineDefeated"] = 0x297FD0, - ["Route110_Text_JasmineIntro"] = 0x297F93, - ["Route110_Text_JasminePostBattle"] = 0x297FF1, - ["Route110_Text_JosephDefeated"] = 0x298686, - ["Route110_Text_JosephIntro"] = 0x298642, - ["Route110_Text_JosephPostBattle"] = 0x2986A9, - ["Route110_Text_KalebDefeated"] = 0x298735, - ["Route110_Text_KalebIntro"] = 0x2986ED, - ["Route110_Text_KalebPostBattle"] = 0x298755, - ["Route110_Text_TimmyDefeated"] = 0x298559, - ["Route110_Text_TimmyIntro"] = 0x298525, - ["Route110_Text_TimmyPostBattle"] = 0x298579, - ["Route111_Text_BeauDefeat"] = 0x2989E6, - ["Route111_Text_BeauIntro"] = 0x29898E, - ["Route111_Text_BeauPostBattle"] = 0x298A03, - ["Route111_Text_BeckyDefeat"] = 0x298AAF, - ["Route111_Text_BeckyIntro"] = 0x298A65, - ["Route111_Text_BeckyPostBattle"] = 0x298AC0, - ["Route111_Text_BiancaDefeat"] = 0x29973D, - ["Route111_Text_BiancaIntro"] = 0x2996FE, - ["Route111_Text_BiancaPostBattle"] = 0x29975D, - ["Route111_Text_BrandenDefeat"] = 0x2994C5, - ["Route111_Text_BrandenIntro"] = 0x299493, - ["Route111_Text_BrandenPostBattle"] = 0x2994FB, - ["Route111_Text_BrookeDefeat"] = 0x299193, - ["Route111_Text_BrookeIntro"] = 0x299143, - ["Route111_Text_BrookePostBattle"] = 0x2991C2, - ["Route111_Text_BrookePostRematch"] = 0x2992F5, - ["Route111_Text_BrookeRegister"] = 0x29921D, - ["Route111_Text_BrookeRematchDefeat"] = 0x2992D6, - ["Route111_Text_BrookeRematchIntro"] = 0x29925D, - ["Route111_Text_BryanDefeat"] = 0x299431, - ["Route111_Text_BryanIntro"] = 0x299401, - ["Route111_Text_BryanPostBattle"] = 0x29945F, - ["Route111_Text_CeliaDefeat"] = 0x299384, - ["Route111_Text_CeliaIntro"] = 0x29934B, - ["Route111_Text_CeliaPostBattle"] = 0x2993A7, - ["Route111_Text_CelinaDefeat"] = 0x299625, - ["Route111_Text_CelinaIntro"] = 0x2995F0, - ["Route111_Text_CelinaPostBattle"] = 0x29964B, - ["Route111_Text_DaisukeDefeat"] = 0x298F14, - ["Route111_Text_DaisukeIntro"] = 0x298EE6, - ["Route111_Text_DaisukePostBattle"] = 0x298F26, - ["Route111_Text_DontHaveAnyRoom"] = 0x27659D, - ["Route111_Text_DontWantThis"] = 0x27655C, - ["Route111_Text_DrewDefeat"] = 0x29880A, - ["Route111_Text_DrewIntro"] = 0x29878E, - ["Route111_Text_DrewPostBattle"] = 0x298853, - ["Route111_Text_DustyDefeat"] = 0x298B53, - ["Route111_Text_DustyIntro"] = 0x298B08, - ["Route111_Text_DustyPostBattle"] = 0x298B9A, - ["Route111_Text_DustyPostRematch"] = 0x298D0C, - ["Route111_Text_DustyRegister"] = 0x298C00, - ["Route111_Text_DustyRematchDefeat"] = 0x298CD2, - ["Route111_Text_DustyRematchIntro"] = 0x298C5F, - ["Route111_Text_ExplainSecretPower"] = 0x27636E, - ["Route111_Text_GoingToTryToMakeDifferentColorBerries"] = 0x2A6F3D, - ["Route111_Text_HaydenDefeat"] = 0x2996BE, - ["Route111_Text_HaydenIntro"] = 0x299682, - ["Route111_Text_HaydenPostBattle"] = 0x2996C5, - ["Route111_Text_HeidiDefeat"] = 0x298908, - ["Route111_Text_HeidiIntro"] = 0x2988A3, - ["Route111_Text_HeidiPostBattle"] = 0x29891B, - ["Route111_Text_IreneDefeat"] = 0x298E6E, - ["Route111_Text_IreneIntro"] = 0x298E2F, - ["Route111_Text_IrenePostBattle"] = 0x298E8C, - ["Route111_Text_MakingRoomUseTMToMakeYourOwn"] = 0x2762C9, - ["Route111_Text_TravisDefeat"] = 0x298DD8, - ["Route111_Text_TravisIntro"] = 0x298DA9, - ["Route111_Text_TravisPostBattle"] = 0x298DF1, - ["Route111_Text_TyronDefeat"] = 0x299549, - ["Route111_Text_TyronIntro"] = 0x299524, - ["Route111_Text_TyronPostBattle"] = 0x299576, - ["Route111_Text_WateredPlantsEveryDayTakeBerry"] = 0x2A6EBD, - ["Route111_Text_WhatColorBerriesToLookForToday"] = 0x2A6F9A, - ["Route111_Text_WiltonDefeat"] = 0x298FA9, - ["Route111_Text_WiltonIntro"] = 0x298F79, - ["Route111_Text_WiltonPostBattle"] = 0x298FD4, - ["Route111_Text_WiltonPostRematch"] = 0x299102, - ["Route111_Text_WiltonRegister"] = 0x29903D, - ["Route111_Text_WiltonRematchDefeat"] = 0x2990EE, - ["Route111_Text_WiltonRematchIntro"] = 0x29909B, - ["Route112_Text_BriceDefeat"] = 0x2997CA, - ["Route112_Text_BriceIntro"] = 0x29978E, - ["Route112_Text_BricePostBattle"] = 0x2997DC, - ["Route112_Text_BryantDefeat"] = 0x299BDF, - ["Route112_Text_BryantIntro"] = 0x299BAE, - ["Route112_Text_BryantPostBattle"] = 0x299BFB, - ["Route112_Text_CarolDefeat"] = 0x299B36, - ["Route112_Text_CarolIntro"] = 0x299AE4, - ["Route112_Text_CarolPostBattle"] = 0x299B4C, - ["Route112_Text_LarryDefeat"] = 0x299AAC, - ["Route112_Text_LarryIntro"] = 0x299A89, - ["Route112_Text_LarryPostBattle"] = 0x299AB4, - ["Route112_Text_ShaylaDefeat"] = 0x299C8F, - ["Route112_Text_ShaylaIntro"] = 0x299C34, - ["Route112_Text_ShaylaPostBattle"] = 0x299CC6, - ["Route112_Text_TrentDefeat"] = 0x29987C, - ["Route112_Text_TrentIntro"] = 0x299815, - ["Route112_Text_TrentPostBattle"] = 0x299896, - ["Route112_Text_TrentRegister"] = 0x29993C, - ["Route112_Text_TrentRematchDefeat"] = 0x2999F7, - ["Route112_Text_TrentRematchIntro"] = 0x2999BD, - ["Route112_Text_TrentRematchPostBattle"] = 0x299A0F, - ["Route113_Text_CobyDefeat"] = 0x29A480, - ["Route113_Text_CobyIntro"] = 0x29A453, - ["Route113_Text_CobyPostBattle"] = 0x29A489, - ["Route113_Text_DillonDefeat"] = 0x299E1C, - ["Route113_Text_DillonIntro"] = 0x299DE3, - ["Route113_Text_DillonPostBattle"] = 0x299E38, - ["Route113_Text_JaylenDefeat"] = 0x299D68, - ["Route113_Text_JaylenIntro"] = 0x299D3C, - ["Route113_Text_JaylenPostBattle"] = 0x299D7D, - ["Route113_Text_LaoDefeat"] = 0x29A04C, - ["Route113_Text_LaoIntro"] = 0x29A015, - ["Route113_Text_LaoPostBattle"] = 0x29A067, - ["Route113_Text_LaoPostRematch"] = 0x29A13A, - ["Route113_Text_LaoRegister"] = 0x29A0A2, - ["Route113_Text_LaoRematchDefeat"] = 0x29A11F, - ["Route113_Text_LaoRematchIntro"] = 0x29A0E8, - ["Route113_Text_LawrenceDefeat"] = 0x29A5A0, - ["Route113_Text_LawrenceIntro"] = 0x29A566, - ["Route113_Text_LawrencePostBattle"] = 0x29A5BE, - ["Route113_Text_LungDefeat"] = 0x29A1C6, - ["Route113_Text_LungIntro"] = 0x29A192, - ["Route113_Text_LungPostBattle"] = 0x29A219, - ["Route113_Text_MadelineDefeat"] = 0x299EF9, - ["Route113_Text_MadelineIntro"] = 0x299EA6, - ["Route113_Text_MadelinePostBattle"] = 0x299F15, - ["Route113_Text_MadelinePostRematch"] = 0x299FD8, - ["Route113_Text_MadelineRegister"] = 0x299F49, - ["Route113_Text_MadelineRematchDefeat"] = 0x299FC9, - ["Route113_Text_MadelineRematchIntro"] = 0x299F8A, - ["Route113_Text_SophieDefeat"] = 0x29A508, - ["Route113_Text_SophieIntro"] = 0x29A4BF, - ["Route113_Text_SophiePostBattle"] = 0x29A529, - ["Route113_Text_TiaDefeat"] = 0x29A397, - ["Route113_Text_TiaIntro"] = 0x29A35C, - ["Route113_Text_TiaNotEnoughMons"] = 0x29A419, - ["Route113_Text_TiaPostBattle"] = 0x29A3DA, - ["Route113_Text_ToriDefeat"] = 0x29A29D, - ["Route113_Text_ToriIntro"] = 0x29A261, - ["Route113_Text_ToriNotEnoughMons"] = 0x29A31F, - ["Route113_Text_ToriPostBattle"] = 0x29A2DE, - ["Route113_Text_WyattDefeat"] = 0x29A628, - ["Route113_Text_WyattIntro"] = 0x29A5E4, - ["Route113_Text_WyattPostBattle"] = 0x29A65D, - ["Route114_Text_AngelinaDefeat"] = 0x29B19C, - ["Route114_Text_AngelinaIntro"] = 0x29B16F, - ["Route114_Text_AngelinaPostBattle"] = 0x29B1BC, - ["Route114_Text_BernieDefeat"] = 0x29AB1A, - ["Route114_Text_BernieIntro"] = 0x29AADB, - ["Route114_Text_BerniePostBattle"] = 0x29AB36, - ["Route114_Text_BerniePostRematch"] = 0x29AC43, - ["Route114_Text_BernieRegister"] = 0x29ABA6, - ["Route114_Text_BernieRematchDefeat"] = 0x29AC10, - ["Route114_Text_BernieRematchIntro"] = 0x29ABDC, - ["Route114_Text_CharlotteDefeat"] = 0x29B0FB, - ["Route114_Text_CharlotteIntro"] = 0x29B0DB, - ["Route114_Text_CharlottePostBattle"] = 0x29B11A, - ["Route114_Text_ClaudeDefeat"] = 0x29AD0A, - ["Route114_Text_ClaudeIntro"] = 0x29ACB3, - ["Route114_Text_ClaudePostBattle"] = 0x29AD2E, - ["Route114_Text_FunToThinkAboutBerries"] = 0x2A706E, - ["Route114_Text_IvyDefeat"] = 0x29AFAC, - ["Route114_Text_IvyIntro"] = 0x29AF89, - ["Route114_Text_IvyNotEnoughMons"] = 0x29B01B, - ["Route114_Text_IvyPostBattle"] = 0x29AFCF, - ["Route114_Text_KaiDefeat"] = 0x29B089, - ["Route114_Text_KaiIntro"] = 0x29B05D, - ["Route114_Text_KaiPostBattle"] = 0x29B0B5, - ["Route114_Text_LennyDefeat"] = 0x29A707, - ["Route114_Text_LennyIntro"] = 0x29A6B1, - ["Route114_Text_LennyPostBattle"] = 0x29A715, - ["Route114_Text_LoveUsingBerryCrushShareBerry"] = 0x2A6FCB, - ["Route114_Text_LucasDefeat"] = 0x29A7B5, - ["Route114_Text_LucasIntro"] = 0x29A777, - ["Route114_Text_LucasPostBattle"] = 0x29A7D4, - ["Route114_Text_NancyDefeat"] = 0x29A90E, - ["Route114_Text_NancyIntro"] = 0x29A8D9, - ["Route114_Text_NancyPostBattle"] = 0x29A916, - ["Route114_Text_NolanDefeat"] = 0x29AE05, - ["Route114_Text_NolanIntro"] = 0x29ADA1, - ["Route114_Text_NolanPostBattle"] = 0x29AE3D, - ["Route114_Text_ShaneDefeat"] = 0x29A88F, - ["Route114_Text_ShaneIntro"] = 0x29A818, - ["Route114_Text_ShanePostBattle"] = 0x29A89F, - ["Route114_Text_SteveDefeat"] = 0x29A973, - ["Route114_Text_SteveIntro"] = 0x29A943, - ["Route114_Text_StevePostBattle"] = 0x29A981, - ["Route114_Text_StevePostRematch"] = 0x29AA94, - ["Route114_Text_SteveRegister"] = 0x29A9F7, - ["Route114_Text_SteveRematchDefeat"] = 0x29AA67, - ["Route114_Text_SteveRematchIntro"] = 0x29AA3E, - ["Route114_Text_TryBerryCrushWithFriends"] = 0x2A7034, - ["Route114_Text_TyraDefeat"] = 0x29AEE7, - ["Route114_Text_TyraIntro"] = 0x29AE96, - ["Route114_Text_TyraNotEnoughMons"] = 0x29AF3D, - ["Route114_Text_TyraPostBattle"] = 0x29AF0B, - ["Route115_Text_AlixDefeat"] = 0x29B9F6, - ["Route115_Text_AlixIntro"] = 0x29B9CB, - ["Route115_Text_AlixPostBattle"] = 0x29BA04, - ["Route115_Text_CyndyDefeat"] = 0x29B62D, - ["Route115_Text_CyndyIntro"] = 0x29B5EF, - ["Route115_Text_CyndyPostBattle"] = 0x29B647, - ["Route115_Text_CyndyPostRematch"] = 0x29B737, - ["Route115_Text_CyndyRegister"] = 0x29B6AB, - ["Route115_Text_CyndyRematchDefeat"] = 0x29B71A, - ["Route115_Text_CyndyRematchIntro"] = 0x29B6FA, - ["Route115_Text_HectorDefeat"] = 0x29B7C5, - ["Route115_Text_HectorIntro"] = 0x29B78F, - ["Route115_Text_HectorPostBattle"] = 0x29B7EA, - ["Route115_Text_HeleneDefeat"] = 0x29B958, - ["Route115_Text_HeleneIntro"] = 0x29B92D, - ["Route115_Text_HelenePostBattle"] = 0x29B971, - ["Route115_Text_JaidenDefeat"] = 0x29B8E8, - ["Route115_Text_JaidenIntro"] = 0x29B8C1, - ["Route115_Text_JaidenPostBattle"] = 0x29B904, - ["Route115_Text_KoichiDefeat"] = 0x29B3ED, - ["Route115_Text_KoichiIntro"] = 0x29B3CC, - ["Route115_Text_KoichiPostBattle"] = 0x29B3FF, - ["Route115_Text_KyraDefeat"] = 0x29B85D, - ["Route115_Text_KyraIntro"] = 0x29B826, - ["Route115_Text_KyraPostBattle"] = 0x29B869, - ["Route115_Text_MarleneDefeat"] = 0x29BA67, - ["Route115_Text_MarleneIntro"] = 0x29BA2C, - ["Route115_Text_MarlenePostBattle"] = 0x29BA87, - ["Route115_Text_NobDefeat"] = 0x29B480, - ["Route115_Text_NobIntro"] = 0x29B449, - ["Route115_Text_NobPostBattle"] = 0x29B49D, - ["Route115_Text_NobPostRematch"] = 0x29B5B5, - ["Route115_Text_NobRegister"] = 0x29B50B, - ["Route115_Text_NobRematchDefeat"] = 0x29B59D, - ["Route115_Text_NobRematchIntro"] = 0x29B547, - ["Route115_Text_TimothyDefeat"] = 0x29B230, - ["Route115_Text_TimothyIntro"] = 0x29B1FA, - ["Route115_Text_TimothyPostBattle"] = 0x29B258, - ["Route115_Text_TimothyPostRematch"] = 0x29B385, - ["Route115_Text_TimothyRegister"] = 0x29B2B3, - ["Route115_Text_TimothyRematchDefeat"] = 0x29B372, - ["Route115_Text_TimothyRematchIntro"] = 0x29B32C, - ["Route116_Text_ClarkDefeat"] = 0x29BB0F, - ["Route116_Text_ClarkIntro"] = 0x29BACD, - ["Route116_Text_ClarkPostBattle"] = 0x29BB31, - ["Route116_Text_DawsonDefeat"] = 0x29C2C2, - ["Route116_Text_DawsonIntro"] = 0x29C266, - ["Route116_Text_DawsonPostBattle"] = 0x29C2DD, - ["Route116_Text_DevanDefeat"] = 0x29C365, - ["Route116_Text_DevanIntro"] = 0x29C350, - ["Route116_Text_DevanPostBattle"] = 0x29C380, - ["Route116_Text_JaniceDefeat"] = 0x29BCB3, - ["Route116_Text_JaniceIntro"] = 0x29BC7F, - ["Route116_Text_JanicePostBattle"] = 0x29BCCC, - ["Route116_Text_JerryDefeat"] = 0x29BD66, - ["Route116_Text_JerryIntro"] = 0x29BD0C, - ["Route116_Text_JerryPostBattle"] = 0x29BD92, - ["Route116_Text_JerryPostRematch"] = 0x29BF68, - ["Route116_Text_JerryRegister1"] = 0x29BDEF, - ["Route116_Text_JerryRegister2"] = 0x29BE71, - ["Route116_Text_JerryRematchDefeat"] = 0x29BF4C, - ["Route116_Text_JerryRematchIntro"] = 0x29BEF3, - ["Route116_Text_JoeyDefeat"] = 0x29BB9A, - ["Route116_Text_JoeyIntro"] = 0x29BB79, - ["Route116_Text_JoeyPostBattle"] = 0x29BBC6, - ["Route116_Text_JohnsonDefeat"] = 0x29C3F7, - ["Route116_Text_JohnsonIntro"] = 0x29C3C1, - ["Route116_Text_JohnsonPostBattle"] = 0x29C418, - ["Route116_Text_JoseDefeat"] = 0x29BC25, - ["Route116_Text_JoseIntro"] = 0x29BBFD, - ["Route116_Text_JosePostBattle"] = 0x29BC42, - ["Route116_Text_KarenDefeat"] = 0x29BFFA, - ["Route116_Text_KarenIntro"] = 0x29BFC5, - ["Route116_Text_KarenPostBattle"] = 0x29C010, - ["Route116_Text_KarenPostRematch"] = 0x29C13B, - ["Route116_Text_KarenRegister1"] = 0x29C052, - ["Route116_Text_KarenRegister2"] = 0x29C096, - ["Route116_Text_KarenRematchDefeat"] = 0x29C11F, - ["Route116_Text_KarenRematchIntro"] = 0x29C0DA, - ["Route116_Text_SarahDefeat"] = 0x29C1B8, - ["Route116_Text_SarahIntro"] = 0x29C173, - ["Route116_Text_SarahPostBattle"] = 0x29C1EA, - ["Route117_PokemonDayCare_Text_ComeAgain"] = 0x292476, - ["Route117_PokemonDayCare_Text_FineThenComeAgain"] = 0x292416, - ["Route117_PokemonDayCare_Text_GoodToSeeYou"] = 0x292488, - ["Route117_PokemonDayCare_Text_HeresYourMon"] = 0x292575, - ["Route117_PokemonDayCare_Text_HusbandWasLookingForYou"] = 0x2923F6, - ["Route117_PokemonDayCare_Text_ItWillCostX"] = 0x292549, - ["Route117_PokemonDayCare_Text_NotEnoughMoney"] = 0x292432, - ["Route117_PokemonDayCare_Text_TakeBackWhichMon"] = 0x292527, - ["Route117_PokemonDayCare_Text_TakeOtherOneBackToo"] = 0x29244F, - ["Route117_PokemonDayCare_Text_TakeYourMonBack"] = 0x2925F6, - ["Route117_PokemonDayCare_Text_TookBackMon"] = 0x292593, - ["Route117_PokemonDayCare_Text_WeCanRaiseOneMore"] = 0x2923AF, - ["Route117_PokemonDayCare_Text_WellRaiseYourMon"] = 0x292370, - ["Route117_PokemonDayCare_Text_WhatWillYouBattleWith"] = 0x292617, - ["Route117_PokemonDayCare_Text_WhichMonShouldWeRaise"] = 0x292349, - ["Route117_PokemonDayCare_Text_WouldYouLikeUsToRaiseAMon"] = 0x2922F4, - ["Route117_PokemonDayCare_Text_YouHaveJustOneMon"] = 0x2925BB, - ["Route117_PokemonDayCare_Text_YoullBeLeftWithJustOne"] = 0x29266D, - ["Route117_PokemonDayCare_Text_YourMonHasGrownXLevels"] = 0x2924CC, - ["Route117_PokemonDayCare_Text_YourTeamIsFull"] = 0x2924EF, - ["Route117_Text_AishaDefeat"] = 0x29D206, - ["Route117_Text_AishaIntro"] = 0x29D1D1, - ["Route117_Text_AishaPostBattle"] = 0x29D24C, - ["Route117_Text_AnnaAndMegRegister"] = 0x29CD1D, - ["Route117_Text_AnnaDefeat"] = 0x29CC8E, - ["Route117_Text_AnnaIntro"] = 0x29CC4A, - ["Route117_Text_AnnaNotEnoughMons"] = 0x29CD61, - ["Route117_Text_AnnaPostBattle"] = 0x29CCCB, - ["Route117_Text_AnnaPostRematch"] = 0x29CF1C, - ["Route117_Text_AnnaRematchDefeat"] = 0x29CEF6, - ["Route117_Text_AnnaRematchIntro"] = 0x29CEB6, - ["Route117_Text_AnnaRematchNotEnoughMons"] = 0x29CF6E, - ["Route117_Text_BrandiDefeat"] = 0x29D189, - ["Route117_Text_BrandiIntro"] = 0x29D14B, - ["Route117_Text_BrandiPostBattle"] = 0x29D196, - ["Route117_Text_DerekDefeat"] = 0x29CB87, - ["Route117_Text_DerekIntro"] = 0x29CB32, - ["Route117_Text_DerekPostBattle"] = 0x29CBAD, - ["Route117_Text_DoYouWantEgg"] = 0x29205D, - ["Route117_Text_DylanDefeat"] = 0x29C7EB, - ["Route117_Text_DylanIntro"] = 0x29C7A5, - ["Route117_Text_DylanPostBattle"] = 0x29C800, - ["Route117_Text_DylanPostRematch"] = 0x29C8FE, - ["Route117_Text_DylanRegister"] = 0x29C846, - ["Route117_Text_DylanRematchDefeat"] = 0x29C8E3, - ["Route117_Text_DylanRematchIntro"] = 0x29C880, - ["Route117_Text_FriendlyWithOtherTrainersMon"] = 0x2921E5, - ["Route117_Text_IWillKeepDoYouWantIt"] = 0x2922C6, - ["Route117_Text_IllKeepIt"] = 0x292149, - ["Route117_Text_IsaacDefeat"] = 0x29C47A, - ["Route117_Text_IsaacIntro"] = 0x29C43F, - ["Route117_Text_IsaacPostBattle"] = 0x29C498, - ["Route117_Text_IsaacPostRematch"] = 0x29C5D0, - ["Route117_Text_IsaacRegister"] = 0x29C508, - ["Route117_Text_IsaacRematchDefeat"] = 0x29C58B, - ["Route117_Text_IsaacRematchIntro"] = 0x29C549, - ["Route117_Text_LydiaDefeat"] = 0x29C659, - ["Route117_Text_LydiaIntro"] = 0x29C612, - ["Route117_Text_LydiaPostBattle"] = 0x29C679, - ["Route117_Text_LydiaPostRematch"] = 0x29C75A, - ["Route117_Text_LydiaRegister"] = 0x29C6BC, - ["Route117_Text_LydiaRematchDefeat"] = 0x29C73E, - ["Route117_Text_LydiaRematchIntro"] = 0x29C6FD, - ["Route117_Text_MariaDefeat"] = 0x29C9A6, - ["Route117_Text_MariaIntro"] = 0x29C955, - ["Route117_Text_MariaPostBattle"] = 0x29C9D0, - ["Route117_Text_MariaPostRematch"] = 0x29CAF6, - ["Route117_Text_MariaRegister"] = 0x29CA32, - ["Route117_Text_MariaRematchDefeat"] = 0x29CACC, - ["Route117_Text_MariaRematchIntro"] = 0x29CA7C, - ["Route117_Text_MegDefeat"] = 0x29CDE9, - ["Route117_Text_MegIntro"] = 0x29CD9D, - ["Route117_Text_MegNotEnoughMons"] = 0x29CE52, - ["Route117_Text_MegPostBattle"] = 0x29CE17, - ["Route117_Text_MegPostRematch"] = 0x29CFFE, - ["Route117_Text_MegRematchDefeat"] = 0x29CFED, - ["Route117_Text_MegRematchIntro"] = 0x29CFAA, - ["Route117_Text_MegRematchNotEnoughMons"] = 0x29D053, - ["Route117_Text_MelinaDefeat"] = 0x29D0F1, - ["Route117_Text_MelinaIntro"] = 0x29D0B7, - ["Route117_Text_MelinaPostBattle"] = 0x29D10E, - ["Route117_Text_ReceivedEgg"] = 0x2921A4, - ["Route117_Text_SeeWifeIfYouWantToPickUpMon"] = 0x29225A, - ["Route117_Text_SeeWifeIfYoudLikeMeToRaiseMon"] = 0x291FCF, - ["Route117_Text_TakeGoodCareOfIt"] = 0x2921CF, - ["Route117_Text_YouHaveNoRoomForIt"] = 0x29216A, - ["Route117_Text_YourMonIsDoingFine"] = 0x292114, - ["Route117_Text_YourMonsAreDoingFine"] = 0x292299, - ["Route118_Text_BarnyDefeat"] = 0x29D55C, - ["Route118_Text_BarnyIntro"] = 0x29D515, - ["Route118_Text_BarnyPostBattle"] = 0x29D587, - ["Route118_Text_ChesterDefeat"] = 0x29D4C4, - ["Route118_Text_ChesterIntro"] = 0x29D4A6, - ["Route118_Text_ChesterPostBattle"] = 0x29D4DA, - ["Route118_Text_DaltonDefeat"] = 0x29D6CD, - ["Route118_Text_DaltonIntro"] = 0x29D6AF, - ["Route118_Text_DaltonPostBattle"] = 0x29D6D8, - ["Route118_Text_DaltonPostRematch"] = 0x29D7D5, - ["Route118_Text_DaltonRegister"] = 0x29D74A, - ["Route118_Text_DaltonRematchDefeat"] = 0x29D7CA, - ["Route118_Text_DaltonRematchIntro"] = 0x29D789, - ["Route118_Text_DeandreDefeat"] = 0x29D83C, - ["Route118_Text_DeandreIntro"] = 0x29D81B, - ["Route118_Text_DeandrePostBattle"] = 0x29D872, - ["Route118_Text_PerryDefeat"] = 0x29D45B, - ["Route118_Text_PerryIntro"] = 0x29D41D, - ["Route118_Text_PerryPostBattle"] = 0x29D46C, - ["Route118_Text_RoseDefeat"] = 0x29D2D8, - ["Route118_Text_RoseIntro"] = 0x29D290, - ["Route118_Text_RosePostBattle"] = 0x29D2FA, - ["Route118_Text_RosePostRematch"] = 0x29D3D9, - ["Route118_Text_RoseRegister"] = 0x29D343, - ["Route118_Text_RoseRematchDefeat"] = 0x29D3AA, - ["Route118_Text_RoseRematchIntro"] = 0x29D382, - ["Route118_Text_WadeDefeat"] = 0x29D64C, - ["Route118_Text_WadeIntro"] = 0x29D5DF, - ["Route118_Text_WadePostBattle"] = 0x29D663, - ["Route119_Text_BrentDefeat"] = 0x29D8F0, - ["Route119_Text_BrentIntro"] = 0x29D8C2, - ["Route119_Text_BrentPostBattle"] = 0x29D902, - ["Route119_Text_CatherineDefeat"] = 0x29DE5D, - ["Route119_Text_CatherineIntro"] = 0x29DE0F, - ["Route119_Text_CatherinePostBattle"] = 0x29DE88, - ["Route119_Text_CatherinePostRematch"] = 0x29DFAF, - ["Route119_Text_CatherineRegister"] = 0x29DEF7, - ["Route119_Text_CatherineRematchDefeat"] = 0x29DF92, - ["Route119_Text_CatherineRematchIntro"] = 0x29DF6B, - ["Route119_Text_ChrisDefeat"] = 0x29E372, - ["Route119_Text_ChrisIntro"] = 0x29E30D, - ["Route119_Text_ChrisPostBattle"] = 0x29E3A2, - ["Route119_Text_DaytonDefeat"] = 0x29E513, - ["Route119_Text_DaytonIntro"] = 0x29E4DF, - ["Route119_Text_DaytonPostBattle"] = 0x29E532, - ["Route119_Text_DonaldDefeat"] = 0x29D97C, - ["Route119_Text_DonaldIntro"] = 0x29D941, - ["Route119_Text_DonaldPostBattle"] = 0x29D993, - ["Route119_Text_DougDefeat"] = 0x29DABC, - ["Route119_Text_DougIntro"] = 0x29DA7D, - ["Route119_Text_DougPostBattle"] = 0x29DADB, - ["Route119_Text_FabianDefeat"] = 0x29E44F, - ["Route119_Text_FabianIntro"] = 0x29E3FF, - ["Route119_Text_FabianPostBattle"] = 0x29E492, - ["Route119_Text_GregDefeat"] = 0x29DB66, - ["Route119_Text_GregIntro"] = 0x29DB17, - ["Route119_Text_GregPostBattle"] = 0x29DB7C, - ["Route119_Text_HideoDefeat"] = 0x29E288, - ["Route119_Text_HideoIntro"] = 0x29E26A, - ["Route119_Text_HideoPostBattle"] = 0x29E2A3, - ["Route119_Text_HughDefeat"] = 0x29E063, - ["Route119_Text_HughIntro"] = 0x29E007, - ["Route119_Text_HughPostBattle"] = 0x29E071, - ["Route119_Text_JacksonDefeat"] = 0x29DC9F, - ["Route119_Text_JacksonIntro"] = 0x29DC4C, - ["Route119_Text_JacksonPostBattle"] = 0x29DCC6, - ["Route119_Text_JacksonPostRematch"] = 0x29DDBD, - ["Route119_Text_JacksonRegister"] = 0x29DD1C, - ["Route119_Text_JacksonRematchDefeat"] = 0x29DDA5, - ["Route119_Text_JacksonRematchIntro"] = 0x29DD62, - ["Route119_Text_KentDefeat"] = 0x29DC20, - ["Route119_Text_KentIntro"] = 0x29DBC2, - ["Route119_Text_KentPostBattle"] = 0x29DC2B, - ["Route119_Text_PhilDefeat"] = 0x29E0E1, - ["Route119_Text_PhilIntro"] = 0x29E0A5, - ["Route119_Text_PhilPostBattle"] = 0x29E0F6, - ["Route119_Text_RachelDefeat"] = 0x29E5B1, - ["Route119_Text_RachelIntro"] = 0x29E56F, - ["Route119_Text_RachelPostBattle"] = 0x29E5CB, - ["Route119_Text_TakashiDefeat"] = 0x29E22B, - ["Route119_Text_TakashiIntro"] = 0x29E1F5, - ["Route119_Text_TakashiPostBattle"] = 0x29E245, - ["Route119_Text_TaylorDefeat"] = 0x29DA14, - ["Route119_Text_TaylorIntro"] = 0x29D9CD, - ["Route119_Text_TaylorPostBattle"] = 0x29DA2C, - ["Route119_Text_YasuDefeat"] = 0x29E18D, - ["Route119_Text_YasuIntro"] = 0x29E134, - ["Route119_Text_YasuPostBattle"] = 0x29E19D, - ["Route120_Text_AngelicaDefeat"] = 0x29EDD3, - ["Route120_Text_AngelicaIntro"] = 0x29ED71, - ["Route120_Text_AngelicaPostBattle"] = 0x29EDF7, - ["Route120_Text_BerriesExpressionOfLoveIsntIt"] = 0x2A70C7, - ["Route120_Text_BerryIsRareRaiseItWithCare"] = 0x2A71D5, - ["Route120_Text_CallieDefeat"] = 0x29EFE1, - ["Route120_Text_CallieIntro"] = 0x29EFB1, - ["Route120_Text_CalliePostBattle"] = 0x29F002, - ["Route120_Text_ChipDefeat"] = 0x29EC25, - ["Route120_Text_ChipIntro"] = 0x29EBAF, - ["Route120_Text_ChipPostBattle"] = 0x29EC41, - ["Route120_Text_ClarissaDefeat"] = 0x29ECEF, - ["Route120_Text_ClarissaIntro"] = 0x29ECA6, - ["Route120_Text_ClarissaPostBattle"] = 0x29ED19, - ["Route120_Text_ColinDefeat"] = 0x29E665, - ["Route120_Text_ColinIntro"] = 0x29E62D, - ["Route120_Text_ColinPostBattle"] = 0x29E67A, - ["Route120_Text_IllGetMoreBerriesFromBerryMaster"] = 0x2A7217, - ["Route120_Text_JeffreyDefeat"] = 0x29E9FF, - ["Route120_Text_JeffreyIntro"] = 0x29E9D7, - ["Route120_Text_JeffreyPostBattle"] = 0x29EA08, - ["Route120_Text_JeffreyPostRematch"] = 0x29EAA9, - ["Route120_Text_JeffreyRegister"] = 0x29EA31, - ["Route120_Text_JeffreyRematchDefeat"] = 0x29EA8F, - ["Route120_Text_JeffreyRematchIntro"] = 0x29EA61, - ["Route120_Text_JennaDefeat"] = 0x29E980, - ["Route120_Text_JennaIntro"] = 0x29E91C, - ["Route120_Text_JennaPostBattle"] = 0x29E996, - ["Route120_Text_JenniferDefeat"] = 0x29EB53, - ["Route120_Text_JenniferIntro"] = 0x29EAEF, - ["Route120_Text_JenniferPostBattle"] = 0x29EB6E, - ["Route120_Text_KeigoDefeat"] = 0x29EE97, - ["Route120_Text_KeigoIntro"] = 0x29EE50, - ["Route120_Text_KeigoPostBattle"] = 0x29EED4, - ["Route120_Text_LeonelDefeat"] = 0x29F080, - ["Route120_Text_LeonelIntro"] = 0x29F04F, - ["Route120_Text_LeonelPostBattle"] = 0x29F0A1, - ["Route120_Text_LorenzoDefeat"] = 0x29E884, - ["Route120_Text_LorenzoIntro"] = 0x29E843, - ["Route120_Text_LorenzoPostBattle"] = 0x29E8C5, - ["Route120_Text_MakeYourOwnImpressions"] = 0x2A7183, - ["Route120_Text_RileyDefeat"] = 0x29EF63, - ["Route120_Text_RileyIntro"] = 0x29EF08, - ["Route120_Text_RileyPostBattle"] = 0x29EF89, - ["Route120_Text_RobertDefeat"] = 0x29E70A, - ["Route120_Text_RobertIntro"] = 0x29E6E3, - ["Route120_Text_RobertPostBattle"] = 0x29E726, - ["Route120_Text_RobertPostRematch"] = 0x29E804, - ["Route120_Text_RobertRegister"] = 0x29E75D, - ["Route120_Text_RobertRematchDefeat"] = 0x29E7E1, - ["Route120_Text_RobertRematchIntro"] = 0x29E7AA, - ["Route120_Text_YesYouUnderstand"] = 0x2A7147, - ["Route121_SafariZoneEntrance_Text_ComeInAndEnjoy"] = 0x2A4E7E, - ["Route121_SafariZoneEntrance_Text_FirstTimeInfo"] = 0x2A4EA1, - ["Route121_SafariZoneEntrance_Text_HereAreYourSafariBalls"] = 0x2A5036, - ["Route121_SafariZoneEntrance_Text_NotEnoughMoney"] = 0x2A4FF7, - ["Route121_SafariZoneEntrance_Text_PCIsFull"] = 0x2A50E5, - ["Route121_SafariZoneEntrance_Text_PlayAnotherTime"] = 0x2A4FD7, - ["Route121_SafariZoneEntrance_Text_PleaseEnjoyYourself"] = 0x2A506F, - ["Route121_SafariZoneEntrance_Text_Received30SafariBalls"] = 0x2A5052, - ["Route121_SafariZoneEntrance_Text_ThatWillBe500Please"] = 0x2A501B, - ["Route121_SafariZoneEntrance_Text_TrainerTip"] = 0x2A55BB, - ["Route121_SafariZoneEntrance_Text_WelcomeFirstTime"] = 0x2A4E46, - ["Route121_SafariZoneEntrance_Text_WelcomeToSafariZone"] = 0x2A4D12, - ["Route121_SafariZoneEntrance_Text_WouldYouLikeToPlay"] = 0x2A4F74, - ["Route121_SafariZoneEntrance_Text_YouNeedPokeblockCase"] = 0x2A5105, - ["Route121_Text_CaleDefeat"] = 0x29F97B, - ["Route121_Text_CaleIntro"] = 0x29F91A, - ["Route121_Text_CalePostBattle"] = 0x29F9AE, - ["Route121_Text_CristinDefeat"] = 0x29F7C1, - ["Route121_Text_CristinIntro"] = 0x29F770, - ["Route121_Text_CristinPostBattle"] = 0x29F7D4, - ["Route121_Text_CristinPostRematch"] = 0x29F8D0, - ["Route121_Text_CristinRegister"] = 0x29F80D, - ["Route121_Text_CristinRematchDefeat"] = 0x29F8A8, - ["Route121_Text_CristinRematchIntro"] = 0x29F855, - ["Route121_Text_JessicaDefeat"] = 0x29F641, - ["Route121_Text_JessicaIntro"] = 0x29F612, - ["Route121_Text_JessicaPostBattle"] = 0x29F66F, - ["Route121_Text_JessicaPostRematch"] = 0x29F740, - ["Route121_Text_JessicaRegister"] = 0x29F69F, - ["Route121_Text_JessicaRematchDefeat"] = 0x29F710, - ["Route121_Text_JessicaRematchIntro"] = 0x29F6E4, - ["Route121_Text_JoyDefeat"] = 0x29F564, - ["Route121_Text_JoyIntro"] = 0x29F521, - ["Route121_Text_JoyNotEnoughMons"] = 0x29F5CE, - ["Route121_Text_JoyPostBattle"] = 0x29F582, - ["Route121_Text_KateDefeat"] = 0x29F43F, - ["Route121_Text_KateIntro"] = 0x29F3FB, - ["Route121_Text_KateNotEnoughMons"] = 0x29F4C9, - ["Route121_Text_KatePostBattle"] = 0x29F476, - ["Route121_Text_MarcelDefeat"] = 0x29FB8C, - ["Route121_Text_MarcelIntro"] = 0x29FB35, - ["Route121_Text_MarcelPostBattle"] = 0x29FBA8, - ["Route121_Text_MylesDefeat"] = 0x29FA64, - ["Route121_Text_MylesIntro"] = 0x29FA1E, - ["Route121_Text_MylesPostBattle"] = 0x29FA73, - ["Route121_Text_PatDefeat"] = 0x29FAD8, - ["Route121_Text_PatIntro"] = 0x29FAA7, - ["Route121_Text_PatPostBattle"] = 0x29FAEA, - ["Route121_Text_TammyDefeat"] = 0x29F3B9, - ["Route121_Text_TammyIntro"] = 0x29F381, - ["Route121_Text_TammyPostBattle"] = 0x29F3C6, - ["Route121_Text_VanessaDefeat"] = 0x29F11F, - ["Route121_Text_VanessaIntro"] = 0x29F0EC, - ["Route121_Text_VanessaPostBattle"] = 0x29F138, - ["Route121_Text_WalterDefeat"] = 0x29F20D, - ["Route121_Text_WalterIntro"] = 0x29F199, - ["Route121_Text_WalterPostBattle"] = 0x29F21E, - ["Route121_Text_WalterPostRematch"] = 0x29F32B, - ["Route121_Text_WalterRegister"] = 0x29F25B, - ["Route121_Text_WalterRematchDefeat"] = 0x29F31A, - ["Route121_Text_WalterRematchIntro"] = 0x29F2A6, - ["Route123_BerryMastersHouse_Text_Ah"] = 0x2A7682, - ["Route123_BerryMastersHouse_Text_DoneForToday"] = 0x2A749E, - ["Route123_BerryMastersHouse_Text_GoodSayingTakeThis"] = 0x2A75D0, - ["Route123_BerryMastersHouse_Text_HeardAGoodSayingLately"] = 0x2A74E6, - ["Route123_BerryMastersHouse_Text_InspirationalTakeThis"] = 0x2A7583, - ["Route123_BerryMastersHouse_Text_JoyNeverGoesOutOfMyLife"] = 0x2A761B, - ["Route123_BerryMastersHouse_Text_VisitPrettyPetalFlowerShop"] = 0x2A7445, - ["Route123_BerryMastersHouse_Text_WhyBeStingyTakeAnother"] = 0x2A7428, - ["Route123_BerryMastersHouse_Text_YoureDeservingOfBerry"] = 0x2A7386, - ["Route123_Text_AlbertoDefeat"] = 0x2A0704, - ["Route123_Text_AlbertoIntro"] = 0x2A06AF, - ["Route123_Text_AlbertoPostBattle"] = 0x2A072B, - ["Route123_Text_BraxtonDefeat"] = 0x29FCD6, - ["Route123_Text_BraxtonIntro"] = 0x29FC6F, - ["Route123_Text_BraxtonPostBattle"] = 0x29FCF4, - ["Route123_Text_CameronDefeat"] = 0x29FE1E, - ["Route123_Text_CameronIntro"] = 0x29FDBA, - ["Route123_Text_CameronPostBattle"] = 0x29FE2A, - ["Route123_Text_CameronPostRematch"] = 0x29FF27, - ["Route123_Text_CameronRegister"] = 0x29FE70, - ["Route123_Text_CameronRematchDefeat"] = 0x29FF1B, - ["Route123_Text_CameronRematchIntro"] = 0x29FED4, - ["Route123_Text_DavisDefeat"] = 0x2A04FE, - ["Route123_Text_DavisIntro"] = 0x2A04C0, - ["Route123_Text_DavisPostBattle"] = 0x2A0512, - ["Route123_Text_EdDefeat"] = 0x2A07F0, - ["Route123_Text_EdIntro"] = 0x2A079B, - ["Route123_Text_EdPostBattle"] = 0x2A080D, - ["Route123_Text_FernandoDefeat"] = 0x2A035C, - ["Route123_Text_FernandoIntro"] = 0x2A0323, - ["Route123_Text_FernandoPostBattle"] = 0x2A0389, - ["Route123_Text_FernandoPostRematch"] = 0x2A0487, - ["Route123_Text_FernandoRegister"] = 0x2A03C1, - ["Route123_Text_FernandoRematchDefeat"] = 0x2A045A, - ["Route123_Text_FernandoRematchIntro"] = 0x2A03FB, - ["Route123_Text_FrederickDefeat"] = 0x2A0631, - ["Route123_Text_FrederickIntro"] = 0x2A060A, - ["Route123_Text_FrederickPostBattle"] = 0x2A0672, - ["Route123_Text_JackiDefeat"] = 0x29FFD8, - ["Route123_Text_JackiIntro"] = 0x29FF61, - ["Route123_Text_JackiPostBattle"] = 0x29FFE5, - ["Route123_Text_JackiPostRematch"] = 0x2A0099, - ["Route123_Text_JackiRegister"] = 0x2A0027, - ["Route123_Text_JackiRematchDefeat"] = 0x2A008D, - ["Route123_Text_JackiRematchIntro"] = 0x2A005E, - ["Route123_Text_JazmynDefeat"] = 0x2A0592, - ["Route123_Text_JazmynIntro"] = 0x2A054E, - ["Route123_Text_JazmynPostBattle"] = 0x2A05AC, - ["Route123_Text_JonasDefeat"] = 0x2A088A, - ["Route123_Text_JonasIntro"] = 0x2A0854, - ["Route123_Text_JonasPostBattle"] = 0x2A08CA, - ["Route123_Text_KayleyDefeat"] = 0x2A0943, - ["Route123_Text_KayleyIntro"] = 0x2A0902, - ["Route123_Text_KayleyPostBattle"] = 0x2A096E, - ["Route123_Text_KindraDefeat"] = 0x2A02A6, - ["Route123_Text_KindraIntro"] = 0x2A025F, - ["Route123_Text_KindraPostBattle"] = 0x2A02C1, - ["Route123_Text_MiuDefeat"] = 0x2A0119, - ["Route123_Text_MiuIntro"] = 0x2A00D6, - ["Route123_Text_MiuNotEnoughMons"] = 0x2A016D, - ["Route123_Text_MiuPostBattle"] = 0x2A012E, - ["Route123_Text_VioletDefeat"] = 0x29FD53, - ["Route123_Text_VioletIntro"] = 0x29FD1D, - ["Route123_Text_VioletPostBattle"] = 0x29FD7B, - ["Route123_Text_WendyDefeat"] = 0x29FC23, - ["Route123_Text_WendyIntro"] = 0x29FBEB, - ["Route123_Text_WendyPostBattle"] = 0x29FC42, - ["Route123_Text_YukiDefeat"] = 0x2A01D8, - ["Route123_Text_YukiIntro"] = 0x2A01A7, - ["Route123_Text_YukiNotEnoughMons"] = 0x2A0224, - ["Route123_Text_YukiPostBattle"] = 0x2A01EE, - ["Route124_Text_ChadDefeat"] = 0x2A0DFF, - ["Route124_Text_ChadIntro"] = 0x2A0DA0, - ["Route124_Text_ChadPostBattle"] = 0x2A0E1E, - ["Route124_Text_DeclanDefeat"] = 0x2A142C, - ["Route124_Text_DeclanIntro"] = 0x2A13BD, - ["Route124_Text_DeclanPostBattle"] = 0x2A144F, - ["Route124_Text_GraceDefeat"] = 0x2A0D3D, - ["Route124_Text_GraceIntro"] = 0x2A0D0A, - ["Route124_Text_GracePostBattle"] = 0x2A0D66, - ["Route124_Text_IsabellaDefeat"] = 0x2A14BC, - ["Route124_Text_IsabellaIntro"] = 0x2A148E, - ["Route124_Text_IsabellaPostBattle"] = 0x2A14ED, - ["Route124_Text_JennyDefeat"] = 0x2A0B7C, - ["Route124_Text_JennyIntro"] = 0x2A0B37, - ["Route124_Text_JennyPostBattle"] = 0x2A0B9A, - ["Route124_Text_JennyPostRematch"] = 0x2A0CBE, - ["Route124_Text_JennyRegister"] = 0x2A0C14, - ["Route124_Text_JennyRematchDefeat"] = 0x2A0CA0, - ["Route124_Text_JennyRematchIntro"] = 0x2A0C60, - ["Route124_Text_LilaDefeat"] = 0x2A0EFE, - ["Route124_Text_LilaIntro"] = 0x2A0E87, - ["Route124_Text_LilaNotEnoughMons"] = 0x2A0F8C, - ["Route124_Text_LilaPostBattle"] = 0x2A0F3A, - ["Route124_Text_LilaPostRematch"] = 0x2A1203, - ["Route124_Text_LilaRematchDefeat"] = 0x2A11B2, - ["Route124_Text_LilaRematchIntro"] = 0x2A111E, - ["Route124_Text_LilaRematchNotEnoughMons"] = 0x2A1255, - ["Route124_Text_LilaRoyRegister"] = 0x2A109F, - ["Route124_Text_RolandDefeat"] = 0x2A0ACC, - ["Route124_Text_RolandIntro"] = 0x2A0A84, - ["Route124_Text_RolandPostBattle"] = 0x2A0AD9, - ["Route124_Text_RoyDefeat"] = 0x2A1012, - ["Route124_Text_RoyIntro"] = 0x2A0FD1, - ["Route124_Text_RoyNotEnoughMons"] = 0x2A10E5, - ["Route124_Text_RoyPostBattle"] = 0x2A103E, - ["Route124_Text_RoyPostRematch"] = 0x2A130F, - ["Route124_Text_RoyRematchDefeat"] = 0x2A12DD, - ["Route124_Text_RoyRematchIntro"] = 0x2A129A, - ["Route124_Text_RoyRematchNotEnoughMons"] = 0x2A1384, - ["Route124_Text_SpencerDefeat"] = 0x2A0A02, - ["Route124_Text_SpencerIntro"] = 0x2A09B1, - ["Route124_Text_SpencerPostBattle"] = 0x2A0A20, - ["Route125_Text_AuronDefeat"] = 0x2A1BFD, - ["Route125_Text_AuronIntro"] = 0x2A1BCE, - ["Route125_Text_AuronPostBattle"] = 0x2A1C2A, - ["Route125_Text_ErnestDefeat"] = 0x2A173A, - ["Route125_Text_ErnestIntro"] = 0x2A16FC, - ["Route125_Text_ErnestPostBattle"] = 0x2A1755, - ["Route125_Text_ErnestRegister"] = 0x2A17CF, - ["Route125_Text_ErnestRematchDefeat"] = 0x2A1851, - ["Route125_Text_ErnestRematchIntro"] = 0x2A180E, - ["Route125_Text_ErnestRematchPostBattle"] = 0x2A187A, - ["Route125_Text_IrisDefeat"] = 0x2A1A71, - ["Route125_Text_IrisIntro"] = 0x2A1A35, - ["Route125_Text_IrisNotEnoughMons"] = 0x2A1AD9, - ["Route125_Text_IrisPostBattle"] = 0x2A1A92, - ["Route125_Text_KimDefeat"] = 0x2A1989, - ["Route125_Text_KimIntro"] = 0x2A192B, - ["Route125_Text_KimNotEnoughMons"] = 0x2A19F8, - ["Route125_Text_KimPostBattle"] = 0x2A19A6, - ["Route125_Text_NolenDefeat"] = 0x2A155A, - ["Route125_Text_NolenIntro"] = 0x2A1527, - ["Route125_Text_NolenPostBattle"] = 0x2A1567, - ["Route125_Text_PresleyDefeat"] = 0x2A1B4E, - ["Route125_Text_PresleyIntro"] = 0x2A1B1A, - ["Route125_Text_PresleyPostBattle"] = 0x2A1B70, - ["Route125_Text_SharonDefeat"] = 0x2A16C5, - ["Route125_Text_SharonIntro"] = 0x2A1686, - ["Route125_Text_SharonPostBattle"] = 0x2A16CE, - ["Route125_Text_StanDefeat"] = 0x2A15C7, - ["Route125_Text_StanIntro"] = 0x2A159F, - ["Route125_Text_StanPostBattle"] = 0x2A15D5, - ["Route125_Text_TanyaDefeat"] = 0x2A164D, - ["Route125_Text_TanyaIntro"] = 0x2A1615, - ["Route125_Text_TanyaPostBattle"] = 0x2A165E, - ["Route126_Text_BarryDefeat"] = 0x2A1CC8, - ["Route126_Text_BarryIntro"] = 0x2A1C95, - ["Route126_Text_BarryPostBattle"] = 0x2A1CDD, - ["Route126_Text_BrendaDefeat"] = 0x2A1E63, - ["Route126_Text_BrendaIntro"] = 0x2A1E3E, - ["Route126_Text_BrendaPostBattle"] = 0x2A1E70, - ["Route126_Text_DeanDefeat"] = 0x2A1D63, - ["Route126_Text_DeanIntro"] = 0x2A1D2A, - ["Route126_Text_DeanPostBattle"] = 0x2A1D72, - ["Route126_Text_IsobelDefeat"] = 0x2A21A3, - ["Route126_Text_IsobelIntro"] = 0x2A216A, - ["Route126_Text_IsobelPostBattle"] = 0x2A21B8, - ["Route126_Text_LeonardoDefeat"] = 0x2A20C6, - ["Route126_Text_LeonardoIntro"] = 0x2A2063, - ["Route126_Text_LeonardoPostBattle"] = 0x2A20F7, - ["Route126_Text_NikkiDefeat"] = 0x2A1DC8, - ["Route126_Text_NikkiIntro"] = 0x2A1DAE, - ["Route126_Text_NikkiPostBattle"] = 0x2A1E03, - ["Route126_Text_PabloDefeat"] = 0x2A1EE3, - ["Route126_Text_PabloIntro"] = 0x2A1EA5, - ["Route126_Text_PabloPostBattle"] = 0x2A1F10, - ["Route126_Text_PabloPostRematch"] = 0x2A200C, - ["Route126_Text_PabloRegister"] = 0x2A1F5A, - ["Route126_Text_PabloRematchDefeat"] = 0x2A1FD8, - ["Route126_Text_PabloRematchIntro"] = 0x2A1F98, - ["Route126_Text_SiennaDefeat"] = 0x2A2216, - ["Route126_Text_SiennaIntro"] = 0x2A21EF, - ["Route126_Text_SiennaPostBattle"] = 0x2A222A, - ["Route127_Text_AidanDefeat"] = 0x2A25C1, - ["Route127_Text_AidanIntro"] = 0x2A257A, - ["Route127_Text_AidanPostBattle"] = 0x2A25D2, - ["Route127_Text_AthenaDefeat"] = 0x2A27FC, - ["Route127_Text_AthenaIntro"] = 0x2A27D0, - ["Route127_Text_AthenaPostBattle"] = 0x2A2832, - ["Route127_Text_CamdenDefeat"] = 0x2A2294, - ["Route127_Text_CamdenIntro"] = 0x2A225F, - ["Route127_Text_CamdenPostBattle"] = 0x2A22A1, - ["Route127_Text_DonnyDefeat"] = 0x2A2315, - ["Route127_Text_DonnyIntro"] = 0x2A22DD, - ["Route127_Text_DonnyPostBattle"] = 0x2A232C, - ["Route127_Text_HenryDefeat"] = 0x2A2483, - ["Route127_Text_HenryIntro"] = 0x2A2450, - ["Route127_Text_HenryPostBattle"] = 0x2A2494, - ["Route127_Text_JonahDefeat"] = 0x2A23E1, - ["Route127_Text_JonahIntro"] = 0x2A2381, - ["Route127_Text_JonahPostBattle"] = 0x2A240C, - ["Route127_Text_KojiDefeat"] = 0x2A2685, - ["Route127_Text_KojiIntro"] = 0x2A264D, - ["Route127_Text_KojiPostBattle"] = 0x2A26AC, - ["Route127_Text_KojiPostRematch"] = 0x2A278E, - ["Route127_Text_KojiRegister"] = 0x2A26EE, - ["Route127_Text_KojiRematchDefeat"] = 0x2A276B, - ["Route127_Text_KojiRematchIntro"] = 0x2A2734, - ["Route127_Text_RogerDefeat"] = 0x2A250B, - ["Route127_Text_RogerIntro"] = 0x2A24C6, - ["Route127_Text_RogerPostBattle"] = 0x2A2538, - ["Route128_Text_AlexaDefeat"] = 0x2A2C79, - ["Route128_Text_AlexaIntro"] = 0x2A2C1C, - ["Route128_Text_AlexaPostBattle"] = 0x2A2C95, - ["Route128_Text_CarleeDefeat"] = 0x2A2EFA, - ["Route128_Text_CarleeIntro"] = 0x2A2EC8, - ["Route128_Text_CarleePostBattle"] = 0x2A2F2F, - ["Route128_Text_HarrisonDefeat"] = 0x2A2E44, - ["Route128_Text_HarrisonIntro"] = 0x2A2E0F, - ["Route128_Text_HarrisonPostBattle"] = 0x2A2E6C, - ["Route128_Text_IsaiahDefeat"] = 0x2A28AB, - ["Route128_Text_IsaiahIntro"] = 0x2A287F, - ["Route128_Text_IsaiahPostBattle"] = 0x2A28D7, - ["Route128_Text_IsaiahPostRematch"] = 0x2A29DC, - ["Route128_Text_IsaiahRegister"] = 0x2A2916, - ["Route128_Text_IsaiahRematchDefeat"] = 0x2A29B8, - ["Route128_Text_IsaiahRematchIntro"] = 0x2A2975, - ["Route128_Text_KatelynDefeat"] = 0x2A2A94, - ["Route128_Text_KatelynIntro"] = 0x2A2A1F, - ["Route128_Text_KatelynPostBattle"] = 0x2A2AB7, - ["Route128_Text_KatelynPostRematch"] = 0x2A2BDD, - ["Route128_Text_KatelynRegister"] = 0x2A2AF9, - ["Route128_Text_KatelynRematchDefeat"] = 0x2A2BAB, - ["Route128_Text_KatelynRematchIntro"] = 0x2A2B41, - ["Route128_Text_RubenDefeat"] = 0x2A2CFE, - ["Route128_Text_RubenIntro"] = 0x2A2CD9, - ["Route128_Text_RubenPostBattle"] = 0x2A2D0D, - ["Route128_Text_WayneDefeat"] = 0x2A2DA9, - ["Route128_Text_WayneIntro"] = 0x2A2D3D, - ["Route128_Text_WaynePostBattle"] = 0x2A2DBA, - ["Route129_Text_AllisonDefeat"] = 0x2A3034, - ["Route129_Text_AllisonIntro"] = 0x2A2FF2, - ["Route129_Text_AllisonPostBattle"] = 0x2A3048, - ["Route129_Text_ChaseDefeat"] = 0x2A2F9D, - ["Route129_Text_ChaseIntro"] = 0x2A2F66, - ["Route129_Text_ChasePostBattle"] = 0x2A2FB9, - ["Route129_Text_ClarenceDefeat"] = 0x2A3239, - ["Route129_Text_ClarenceIntro"] = 0x2A3204, - ["Route129_Text_ClarencePostBattle"] = 0x2A3252, - ["Route129_Text_ReedDefeat"] = 0x2A30F1, - ["Route129_Text_ReedIntro"] = 0x2A30CD, - ["Route129_Text_ReedPostBattle"] = 0x2A3114, - ["Route129_Text_TishaDefeat"] = 0x2A317D, - ["Route129_Text_TishaIntro"] = 0x2A314E, - ["Route129_Text_TishaPostBattle"] = 0x2A31A6, - ["Route130_Text_KatieDefeat"] = 0x2A33AC, - ["Route130_Text_KatieIntro"] = 0x2A3363, - ["Route130_Text_KatiePostBattle"] = 0x2A33F5, - ["Route130_Text_RodneyDefeat"] = 0x2A32E6, - ["Route130_Text_RodneyIntro"] = 0x2A328A, - ["Route130_Text_RodneyPostBattle"] = 0x2A3300, - ["Route130_Text_SantiagoDefeat"] = 0x2A346D, - ["Route130_Text_SantiagoIntro"] = 0x2A343A, - ["Route130_Text_SantiagoPostBattle"] = 0x2A3494, - ["Route131_Text_HermanDefeat"] = 0x2A3626, - ["Route131_Text_HermanIntro"] = 0x2A35C6, - ["Route131_Text_HermanPostBattle"] = 0x2A362D, - ["Route131_Text_IanDefeat"] = 0x2A3925, - ["Route131_Text_IanIntro"] = 0x2A38F8, - ["Route131_Text_IanNotEnoughMons"] = 0x2A39CD, - ["Route131_Text_IanPostBattle"] = 0x2A3960, - ["Route131_Text_KaraDefeat"] = 0x2A378A, - ["Route131_Text_KaraIntro"] = 0x2A3751, - ["Route131_Text_KaraPostBattle"] = 0x2A379F, - ["Route131_Text_KevinDefeat"] = 0x2A3AFC, - ["Route131_Text_KevinIntro"] = 0x2A3AA0, - ["Route131_Text_KevinPostBattle"] = 0x2A3B02, - ["Route131_Text_ReliDefeat"] = 0x2A381F, - ["Route131_Text_ReliIntro"] = 0x2A37E9, - ["Route131_Text_ReliNotEnoughMons"] = 0x2A38B7, - ["Route131_Text_ReliPostBattle"] = 0x2A3855, - ["Route131_Text_RichardDefeat"] = 0x2A350D, - ["Route131_Text_RichardIntro"] = 0x2A34C8, - ["Route131_Text_RichardPostBattle"] = 0x2A353D, - ["Route131_Text_SusieDefeat"] = 0x2A36AB, - ["Route131_Text_SusieIntro"] = 0x2A367B, - ["Route131_Text_SusiePostBattle"] = 0x2A36D6, - ["Route131_Text_TaliaDefeat"] = 0x2A3A38, - ["Route131_Text_TaliaIntro"] = 0x2A39FE, - ["Route131_Text_TaliaPostBattle"] = 0x2A3A48, - ["Route132_Text_DanaDefeat"] = 0x2A3C39, - ["Route132_Text_DanaIntro"] = 0x2A3C04, - ["Route132_Text_DanaPostBattle"] = 0x2A3C49, - ["Route132_Text_DarcyDefeat"] = 0x2A406E, - ["Route132_Text_DarcyIntro"] = 0x2A4026, - ["Route132_Text_DarcyPostBattle"] = 0x2A40A4, - ["Route132_Text_GilbertDefeat"] = 0x2A3BAC, - ["Route132_Text_GilbertIntro"] = 0x2A3B4C, - ["Route132_Text_GilbertPostBattle"] = 0x2A3BC0, - ["Route132_Text_JonathanDefeat"] = 0x2A3EDD, - ["Route132_Text_JonathanIntro"] = 0x2A3EAC, - ["Route132_Text_JonathanPostBattle"] = 0x2A3F03, - ["Route132_Text_KiyoDefeat"] = 0x2A3D6C, - ["Route132_Text_KiyoIntro"] = 0x2A3D26, - ["Route132_Text_KiyoPostBattle"] = 0x2A3D8B, - ["Route132_Text_MakaylaDefeat"] = 0x2A3E2C, - ["Route132_Text_MakaylaIntro"] = 0x2A3DF0, - ["Route132_Text_MakaylaPostBattle"] = 0x2A3E4E, - ["Route132_Text_PaxtonDefeat"] = 0x2A3FA8, - ["Route132_Text_PaxtonIntro"] = 0x2A3F4F, - ["Route132_Text_PaxtonPostBattle"] = 0x2A3FE5, - ["Route132_Text_RonaldDefeat"] = 0x2A3CAD, - ["Route132_Text_RonaldIntro"] = 0x2A3C7F, - ["Route132_Text_RonaldPostBattle"] = 0x2A3CC5, - ["Route133_Text_BeckDefeat"] = 0x2A439C, - ["Route133_Text_BeckIntro"] = 0x2A436A, - ["Route133_Text_BeckPostBattle"] = 0x2A43B9, - ["Route133_Text_ConorDefeat"] = 0x2A4505, - ["Route133_Text_ConorIntro"] = 0x2A44BA, - ["Route133_Text_ConorPostBattle"] = 0x2A4527, - ["Route133_Text_DebraDefeat"] = 0x2A4200, - ["Route133_Text_DebraIntro"] = 0x2A41A8, - ["Route133_Text_DebraPostBattle"] = 0x2A420E, - ["Route133_Text_FranklinDefeat"] = 0x2A413F, - ["Route133_Text_FranklinIntro"] = 0x2A40F0, - ["Route133_Text_FranklinPostBattle"] = 0x2A4154, - ["Route133_Text_LindaDefeat"] = 0x2A4258, - ["Route133_Text_LindaIntro"] = 0x2A4236, - ["Route133_Text_LindaPostBattle"] = 0x2A4264, - ["Route133_Text_MollieDefeat"] = 0x2A443A, - ["Route133_Text_MollieIntro"] = 0x2A4401, - ["Route133_Text_MolliePostBattle"] = 0x2A4477, - ["Route133_Text_WarrenDefeat"] = 0x2A42E0, - ["Route133_Text_WarrenIntro"] = 0x2A4290, - ["Route133_Text_WarrenPostBattle"] = 0x2A4304, - ["Route134_Text_AaronDefeat"] = 0x2A47E1, - ["Route134_Text_AaronIntro"] = 0x2A479E, - ["Route134_Text_AaronPostBattle"] = 0x2A47FD, - ["Route134_Text_AlexDefeat"] = 0x2A46BE, - ["Route134_Text_AlexIntro"] = 0x2A4682, - ["Route134_Text_AlexPostBattle"] = 0x2A46D2, - ["Route134_Text_HitoshiDefeat"] = 0x2A4745, - ["Route134_Text_HitoshiIntro"] = 0x2A4709, - ["Route134_Text_HitoshiPostBattle"] = 0x2A475D, - ["Route134_Text_HudsonDefeat"] = 0x2A4AC1, - ["Route134_Text_HudsonIntro"] = 0x2A4A8F, - ["Route134_Text_HudsonPostBattle"] = 0x2A4AD8, - ["Route134_Text_JackDefeat"] = 0x2A45BB, - ["Route134_Text_JackIntro"] = 0x2A4571, - ["Route134_Text_JackPostBattle"] = 0x2A45C5, - ["Route134_Text_KelvinDefeat"] = 0x2A489B, - ["Route134_Text_KelvinIntro"] = 0x2A4875, - ["Route134_Text_KelvinPostBattle"] = 0x2A48BD, - ["Route134_Text_LaurelDefeat"] = 0x2A4643, - ["Route134_Text_LaurelIntro"] = 0x2A4609, - ["Route134_Text_LaurelPostBattle"] = 0x2A464B, - ["Route134_Text_MarleyDefeat"] = 0x2A496B, - ["Route134_Text_MarleyIntro"] = 0x2A4937, - ["Route134_Text_MarleyPostBattle"] = 0x2A49B1, - ["Route134_Text_ReynaDefeat"] = 0x2A4A10, - ["Route134_Text_ReynaIntro"] = 0x2A49E9, - ["Route134_Text_ReynaPostBattle"] = 0x2A4A34, - ["SafariZone_North_Text_Fisherman"] = 0x2A5489, - ["SafariZone_North_Text_Man"] = 0x2A54F0, - ["SafariZone_Northeast_Text_Boy"] = 0x2A59A4, - ["SafariZone_Northeast_Text_Girl"] = 0x2A5A44, - ["SafariZone_Northeast_Text_Woman"] = 0x2A5A09, - ["SafariZone_Northwest_Text_Man"] = 0x2A542C, - ["SafariZone_RestHouse_Text_FatMan"] = 0x2A5764, - ["SafariZone_RestHouse_Text_PsychicM"] = 0x2A56E1, - ["SafariZone_RestHouse_Text_Youngster"] = 0x2A5639, - ["SafariZone_South_Text_AreaOffLimits1"] = 0x2A57EE, - ["SafariZone_South_Text_AreaOffLimits2"] = 0x2A5887, - ["SafariZone_South_Text_Boy"] = 0x2A52EF, - ["SafariZone_South_Text_EnjoyTheRestOfYourAdventure"] = 0x2A521A, - ["SafariZone_South_Text_ExitEarlyThankYouForPlaying"] = 0x2A5248, - ["SafariZone_South_Text_GoodLuck"] = 0x2A52AB, - ["SafariZone_South_Text_Man"] = 0x2A533B, - ["SafariZone_South_Text_StillHaveTimeExit"] = 0x2A51D4, - ["SafariZone_South_Text_Youngster"] = 0x2A553E, - ["SafariZone_Southeast_Text_ExpansionIsFinished"] = 0x2A582D, - ["SafariZone_Southeast_Text_FatMan"] = 0x2A58FD, - ["SafariZone_Southeast_Text_LittleGirl"] = 0x2A58C6, - ["SafariZone_Southeast_Text_RichBoy"] = 0x2A5960, - ["SafariZone_Southwest_Text_RestHouseSign"] = 0x2A5613, - ["SafariZone_Southwest_Text_Woman"] = 0x2A53B7, - ["SafariZone_Text_OutOfBalls"] = 0x2A4C56, - ["SafariZone_Text_PlacePokeblockOnFeeder"] = 0x2A4C90, - ["SafariZone_Text_PokeblockStillHere"] = 0x2A4CC5, - ["SafariZone_Text_PokeblockWasPlaced"] = 0x2A4CEB, - ["SafariZone_Text_TimesUp"] = 0x2A4C26, - ["SafariZone_Text_WouldYouLikeToExit"] = 0x2A4BF4, - ["SecretBase_Text_AllDecorationsWillBeReturned"] = 0x2766AA, - ["SecretBase_Text_AlreadyRegisteredDelete"] = 0x276731, - ["SecretBase_Text_BattleTowerShield"] = 0x27692B, - ["SecretBase_Text_BootUpPC"] = 0x276805, - ["SecretBase_Text_ClumpOfGrass"] = 0x2747DD, - ["SecretBase_Text_ClumpUseSecretPower"] = 0x274825, - ["SecretBase_Text_DataUnregistered"] = 0x2767E9, - ["SecretBase_Text_DiscoveredSmallCavern"] = 0x23B73E, - ["SecretBase_Text_DiscoveredSmallEntrance"] = 0x274883, - ["SecretBase_Text_IndentUseSecretPower"] = 0x23B704, - ["SecretBase_Text_MovingCompletedUseSecretPower"] = 0x276A95, - ["SecretBase_Text_RegistrationCompleted"] = 0x2767D1, - ["SecretBase_Text_RegistryInfo"] = 0x276835, - ["SecretBase_Text_SeedotTV"] = 0x2769B8, - ["SecretBase_Text_SkittyTV"] = 0x2769FF, - ["SecretBase_Text_SmallIndentInWall"] = 0x23B6E0, - ["SecretBase_Text_TooManyBasesDeleteSome"] = 0x27676F, - ["SecretBase_Text_ToyTV"] = 0x276974, - ["SecretBase_Text_Trainer0AcceptBattle"] = 0x274939, - ["SecretBase_Text_Trainer0DeclineBattle"] = 0x27494D, - ["SecretBase_Text_Trainer0Defeated"] = 0x274966, - ["SecretBase_Text_Trainer0Intro"] = 0x2748A0, - ["SecretBase_Text_Trainer0PostBattle"] = 0x2749AA, - ["SecretBase_Text_Trainer0PreChampion"] = 0x2749ED, - ["SecretBase_Text_Trainer1AcceptBattle"] = 0x274CB0, - ["SecretBase_Text_Trainer1DeclineBattle"] = 0x274CDA, - ["SecretBase_Text_Trainer1Defeated"] = 0x274D13, - ["SecretBase_Text_Trainer1Intro"] = 0x274C13, - ["SecretBase_Text_Trainer1PostBattle"] = 0x274D34, - ["SecretBase_Text_Trainer1PreChampion"] = 0x274D69, - ["SecretBase_Text_Trainer2AcceptBattle"] = 0x274FCA, - ["SecretBase_Text_Trainer2DeclineBattle"] = 0x274FDA, - ["SecretBase_Text_Trainer2Defeated"] = 0x274FFE, - ["SecretBase_Text_Trainer2Intro"] = 0x274F39, - ["SecretBase_Text_Trainer2PostBattle"] = 0x27502A, - ["SecretBase_Text_Trainer2PreChampion"] = 0x2750A4, - ["SecretBase_Text_Trainer3AcceptBattle"] = 0x275315, - ["SecretBase_Text_Trainer3DeclineBattle"] = 0x275330, - ["SecretBase_Text_Trainer3Defeated"] = 0x275367, - ["SecretBase_Text_Trainer3Intro"] = 0x275287, - ["SecretBase_Text_Trainer3PostBattle"] = 0x275374, - ["SecretBase_Text_Trainer3PreChampion"] = 0x2753AB, - ["SecretBase_Text_Trainer4AcceptBattle"] = 0x275679, - ["SecretBase_Text_Trainer4DeclineBattle"] = 0x275696, - ["SecretBase_Text_Trainer4Defeated"] = 0x2756C7, - ["SecretBase_Text_Trainer4Intro"] = 0x2755D2, - ["SecretBase_Text_Trainer4PostBattle"] = 0x2756EF, - ["SecretBase_Text_Trainer4PreChampion"] = 0x27571E, - ["SecretBase_Text_Trainer5AcceptBattle"] = 0x274AFA, - ["SecretBase_Text_Trainer5DeclineBattle"] = 0x274B0B, - ["SecretBase_Text_Trainer5Defeated"] = 0x274B24, - ["SecretBase_Text_Trainer5Intro"] = 0x274A64, - ["SecretBase_Text_Trainer5PostBattle"] = 0x274B6C, - ["SecretBase_Text_Trainer5PreChampion"] = 0x274BA2, - ["SecretBase_Text_Trainer6AcceptBattle"] = 0x274E41, - ["SecretBase_Text_Trainer6DeclineBattle"] = 0x274E5A, - ["SecretBase_Text_Trainer6Defeated"] = 0x274E75, - ["SecretBase_Text_Trainer6Intro"] = 0x274DD2, - ["SecretBase_Text_Trainer6PostBattle"] = 0x274EB3, - ["SecretBase_Text_Trainer6PreChampion"] = 0x274EF1, - ["SecretBase_Text_Trainer7AcceptBattle"] = 0x2751AF, - ["SecretBase_Text_Trainer7DeclineBattle"] = 0x2751CA, - ["SecretBase_Text_Trainer7Defeated"] = 0x2751E1, - ["SecretBase_Text_Trainer7Intro"] = 0x275114, - ["SecretBase_Text_Trainer7PostBattle"] = 0x2751EC, - ["SecretBase_Text_Trainer7PreChampion"] = 0x275226, - ["SecretBase_Text_Trainer8AcceptBattle"] = 0x2754B2, - ["SecretBase_Text_Trainer8DeclineBattle"] = 0x2754D8, - ["SecretBase_Text_Trainer8Defeated"] = 0x2754F6, - ["SecretBase_Text_Trainer8Intro"] = 0x275405, - ["SecretBase_Text_Trainer8PostBattle"] = 0x27550C, - ["SecretBase_Text_Trainer8PreChampion"] = 0x275546, - ["SecretBase_Text_Trainer9AcceptBattle"] = 0x275884, - ["SecretBase_Text_Trainer9DeclineBattle"] = 0x27589D, - ["SecretBase_Text_Trainer9Defeated"] = 0x2758CC, - ["SecretBase_Text_Trainer9Intro"] = 0x2757B5, - ["SecretBase_Text_Trainer9PostBattle"] = 0x275909, - ["SecretBase_Text_Trainer9PreChampion"] = 0x275944, - ["SecretBase_Text_TreeCanBeClimbed"] = 0x274746, - ["SecretBase_Text_TreeUseSecretPower"] = 0x274779, - ["SecretBase_Text_VineDroppedDown"] = 0x2747C2, - ["SecretBase_Text_WantToMakeYourSecretBaseHere"] = 0x23B759, - ["SecretBase_Text_WantToRegisterSecretBase"] = 0x276707, - ["SecretBase_Text_WhatWouldYouLikeToDo"] = 0x27681A, - ["SecretBase_Text_WouldYouLikeToMoveBases"] = 0x276A3D, - ["ShoalCave_LowTideEntranceRoom_Text_AreYouPlanningOnGoingInThere"] = 0x2A7E0E, - ["ShoalCave_LowTideEntranceRoom_Text_BringMe4ShoalSaltAndShells"] = 0x2A7EB3, - ["ShoalCave_LowTideEntranceRoom_Text_ExplainShellBell"] = 0x2A8012, - ["ShoalCave_LowTideEntranceRoom_Text_MakeShellBellRightAway"] = 0x2A7FAC, - ["ShoalCave_LowTideEntranceRoom_Text_NoSpaceInYourBag"] = 0x2A80F5, - ["ShoalCave_LowTideEntranceRoom_Text_WantedToMakeShellBell"] = 0x2A80C6, - ["ShoalCave_LowTideEntranceRoom_Text_WouldYouLikeShellBell"] = 0x2A7F37, - ["ShoalCave_Text_WasShoalSaltNowNothing"] = 0x2A8169, - ["ShoalCave_Text_WasShoallShellNowNothing"] = 0x2A81A8, - ["SlateportCity_BattleTentLobby_Text_AwardYouThisPrize"] = 0x2C5D14, - ["SlateportCity_BattleTentLobby_Text_BeenWaitingForYou"] = 0x2C5DA7, - ["SlateportCity_BattleTentLobby_Text_DidntSaveBeforeQuitting"] = 0x2C5DFA, - ["SlateportCity_BattleTentLobby_Text_ExplainBasicRules"] = 0x2C5F08, - ["SlateportCity_BattleTentLobby_Text_ExplainMonRules"] = 0x2C61FE, - ["SlateportCity_BattleTentLobby_Text_ExplainSlateportTent"] = 0x2C589C, - ["SlateportCity_BattleTentLobby_Text_ExplainSwapNotes"] = 0x2C60C0, - ["SlateportCity_BattleTentLobby_Text_ExplainSwapNumberRules"] = 0x2C6020, - ["SlateportCity_BattleTentLobby_Text_ExplainSwapPartnerRules"] = 0x2C5F9D, - ["SlateportCity_BattleTentLobby_Text_HoldMonsForSafekeeping"] = 0x2C5B4A, - ["SlateportCity_BattleTentLobby_Text_LookForwardToNextVisit"] = 0x2C5AA5, - ["SlateportCity_BattleTentLobby_Text_NoRoomInBagMakeRoom"] = 0x2C5D52, - ["SlateportCity_BattleTentLobby_Text_ReadWhichHeading"] = 0x2C5EE5, - ["SlateportCity_BattleTentLobby_Text_ReceivedPrize"] = 0x2C5EA9, - ["SlateportCity_BattleTentLobby_Text_ReturnMonsExchangeRentals"] = 0x2C5C30, - ["SlateportCity_BattleTentLobby_Text_ReturnPersonalMons"] = 0x2C5E85, - ["SlateportCity_BattleTentLobby_Text_ReturnRentalMonsSaveResults"] = 0x2C5BA2, - ["SlateportCity_BattleTentLobby_Text_RulesAreListed"] = 0x2C5EC3, - ["SlateportCity_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C5B06, - ["SlateportCity_BattleTentLobby_Text_StepThisWay"] = 0x2C5B8C, - ["SlateportCity_BattleTentLobby_Text_TakeChallenge"] = 0x2C586A, - ["SlateportCity_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C5810, - ["SlateportCity_BattleTentLobby_Text_WhichLevelMode"] = 0x2C5AC9, - ["SlateportCity_BattleTentLobby_Text_WonThreeMatchesReturnMons"] = 0x2C5C6F, - ["SlateportCity_OceanicMuseum_1F_Text_BetterWriteUpStory"] = 0x28144D, - ["SlateportCity_OceanicMuseum_1F_Text_InterviewRequest"] = 0x2811A0, - ["SlateportCity_OceanicMuseum_1F_Text_InterviewRequestShort"] = 0x28126D, - ["SlateportCity_OceanicMuseum_1F_Text_LetMeKnowIfYouHaveStory"] = 0x281367, - ["SlateportCity_OceanicMuseum_1F_Text_TellMeExperienceInvolvingPokemon"] = 0x2812F2, - ["SlateportCity_OceanicMuseum_1F_Text_ThatsAllForInterview"] = 0x2813B9, - ["SlateportCity_PokemonFanClub_Text_DescribeFeelingsFirstMetMon"] = 0x280393, - ["SlateportCity_PokemonFanClub_Text_EnjoyDoingInterviews"] = 0x28062E, - ["SlateportCity_PokemonFanClub_Text_HereGoesQuickAnswers"] = 0x28034F, - ["SlateportCity_PokemonFanClub_Text_HereIfYouGetUrgeToTellMe"] = 0x2805E2, - ["SlateportCity_PokemonFanClub_Text_InterviewRequest"] = 0x280674, - ["SlateportCity_PokemonFanClub_Text_InterviewRequestHasName"] = 0x280270, - ["SlateportCity_PokemonFanClub_Text_LikenMonToSomethingYouLike"] = 0x2803EF, - ["SlateportCity_PokemonFanClub_Text_TellMeAnythingAboutYourMon"] = 0x28073B, - ["SlateportCity_PokemonFanClub_Text_ThatsAllForInterview"] = 0x280523, - ["SlateportCity_PokemonFanClub_Text_ThatsAllForInterview2"] = 0x280789, - ["SlateportCity_PokemonFanClub_Text_WhatAttractedYouAboutMon"] = 0x280454, - ["SlateportCity_PokemonFanClub_Text_WhatDoPokemonMeanToYou"] = 0x2804AC, - ["SmartShopper_Text_ClerkMax"] = 0x2840CE, - ["SmartShopper_Text_ClerkNormal"] = 0x283BAF, - ["SmartShopper_Text_DuringSale"] = 0x283FA9, - ["SmartShopper_Text_Intro"] = 0x283B05, - ["SmartShopper_Text_IsVIP"] = 0x28409E, - ["SmartShopper_Text_OutroMax"] = 0x2842E6, - ["SmartShopper_Text_OutroNormal"] = 0x283FE7, - ["SmartShopper_Text_RandomComment1"] = 0x283C81, - ["SmartShopper_Text_RandomComment2"] = 0x283D32, - ["SmartShopper_Text_RandomComment3"] = 0x283D99, - ["SmartShopper_Text_RandomComment4"] = 0x283E28, - ["SmartShopper_Text_SecondItem"] = 0x283F01, - ["SmartShopper_Text_ThirdItem"] = 0x283F72, - ["SootopolisCity_Text_GiveYouThisBerryToo"] = 0x2A7C7C, - ["SootopolisCity_Text_LikeSeasonBornIn"] = 0x2A7CEC, - ["SootopolisCity_Text_NameIsKiriHaveOneOfThese"] = 0x2A7BB0, - ["SootopolisCity_Text_OhDoesntMatter"] = 0x2A7DD2, - ["SootopolisCity_Text_ThenILoveAutumn"] = 0x2A7D80, - ["SootopolisCity_Text_WhatKindOfWishInYourName"] = 0x2A7CB7, - ["SouthernIsland_Exterior_Text_Sign"] = 0x2A6AD5, - ["SouthernIsland_Interior_Text_Sign"] = 0x2A6AA1, - ["TVSecretBaseSecrets_Text_BaseFailedToInterestPlayer"] = 0x288739, - ["TVSecretBaseSecrets_Text_BattledDraw"] = 0x2890F0, - ["TVSecretBaseSecrets_Text_BattledLost"] = 0x288EC9, - ["TVSecretBaseSecrets_Text_BattledWon"] = 0x288E1E, - ["TVSecretBaseSecrets_Text_BrokeDoor"] = 0x2895EB, - ["TVSecretBaseSecrets_Text_DeclinedBattle"] = 0x288F58, - ["TVSecretBaseSecrets_Text_HitCushion"] = 0x288DA0, - ["TVSecretBaseSecrets_Text_HuggedCushion"] = 0x288DDA, - ["TVSecretBaseSecrets_Text_Intro"] = 0x288608, - ["TVSecretBaseSecrets_Text_Outro"] = 0x2887E9, - ["TVSecretBaseSecrets_Text_PlayerEnjoyedBase"] = 0x288777, - ["TVSecretBaseSecrets_Text_PlayerHugeFanOfBase"] = 0x2887AF, - ["TVSecretBaseSecrets_Text_StoppedMoving1"] = 0x288868, - ["TVSecretBaseSecrets_Text_StoppedMoving2"] = 0x2888CA, - ["TVSecretBaseSecrets_Text_TookXStepsBeforeLeaving"] = 0x2886F8, - ["TVSecretBaseSecrets_Text_UsedBag"] = 0x288CC8, - ["TVSecretBaseSecrets_Text_UsedBalloon"] = 0x2889C0, - ["TVSecretBaseSecrets_Text_UsedBrick"] = 0x289313, - ["TVSecretBaseSecrets_Text_UsedChair"] = 0x288930, - ["TVSecretBaseSecrets_Text_UsedCushion"] = 0x288D7F, - ["TVSecretBaseSecrets_Text_UsedDesk"] = 0x28927C, - ["TVSecretBaseSecrets_Text_UsedDoll"] = 0x289645, - ["TVSecretBaseSecrets_Text_UsedFence"] = 0x2893FF, - ["TVSecretBaseSecrets_Text_UsedGlassOrnament"] = 0x288BC9, - ["TVSecretBaseSecrets_Text_UsedGlitterMat"] = 0x289465, - ["TVSecretBaseSecrets_Text_UsedGoldShield"] = 0x288B1E, - ["TVSecretBaseSecrets_Text_UsedJumpMat"] = 0x28977D, - ["TVSecretBaseSecrets_Text_UsedMudBall"] = 0x288C88, - ["TVSecretBaseSecrets_Text_UsedNoteMat"] = 0x2890A4, - ["TVSecretBaseSecrets_Text_UsedPlant"] = 0x288ACA, - ["TVSecretBaseSecrets_Text_UsedPoster"] = 0x289011, - ["TVSecretBaseSecrets_Text_UsedSandOrnament"] = 0x289204, - ["TVSecretBaseSecrets_Text_UsedSilverShield"] = 0x288B7A, - ["TVSecretBaseSecrets_Text_UsedSlide"] = 0x28967E, - ["TVSecretBaseSecrets_Text_UsedSlideButDidntGoDown"] = 0x2896FC, - ["TVSecretBaseSecrets_Text_UsedSolidBoard"] = 0x289375, - ["TVSecretBaseSecrets_Text_UsedSpinMat"] = 0x289193, - ["TVSecretBaseSecrets_Text_UsedStand"] = 0x289564, - ["TVSecretBaseSecrets_Text_UsedTV"] = 0x288C40, - ["TVSecretBaseSecrets_Text_UsedTent"] = 0x288A3A, - ["TVSecretBaseSecrets_Text_UsedTire"] = 0x2894F2, - ["TVSecretBaseSecrets_Text_WhatWillPlayerDoNext1"] = 0x2886C8, - ["TVSecretBaseSecrets_Text_WhatWillPlayerDoNext2"] = 0x2886DE, - ["TVSpotTheCuties_Text_Intro"] = 0x286D8F, - ["TVSpotTheCuties_Text_Outro"] = 0x287779, - ["TVSpotTheCuties_Text_RibbonArtist"] = 0x2875D9, - ["TVSpotTheCuties_Text_RibbonBeauty"] = 0x28715A, - ["TVSpotTheCuties_Text_RibbonChampion"] = 0x286FE4, - ["TVSpotTheCuties_Text_RibbonCool"] = 0x2870A3, - ["TVSpotTheCuties_Text_RibbonCute"] = 0x287215, - ["TVSpotTheCuties_Text_RibbonEffort"] = 0x2876A1, - ["TVSpotTheCuties_Text_RibbonIntro"] = 0x286FAA, - ["TVSpotTheCuties_Text_RibbonSmart"] = 0x2872CC, - ["TVSpotTheCuties_Text_RibbonTough"] = 0x287387, - ["TVSpotTheCuties_Text_RibbonVictory"] = 0x287508, - ["TVSpotTheCuties_Text_RibbonWinning"] = 0x287442, - ["TVSpotTheCuties_Text_RibbonsHigh"] = 0x286F54, - ["TVSpotTheCuties_Text_RibbonsLow"] = 0x286E9D, - ["TVSpotTheCuties_Text_RibbonsMid"] = 0x286EFC, - ["Text_Blueprint"] = 0x2A82F7, - ["Text_BookShelf"] = 0x2A820D, - ["Text_BootUpPC"] = 0x27265A, - ["Text_CantCut"] = 0x29077D, - ["Text_CantDive"] = 0x290BAA, - ["Text_CantSmash"] = 0x29087F, - ["Text_CantStrength"] = 0x2909D6, - ["Text_CantSurface"] = 0x290C28, - ["Text_CantWaterfall"] = 0x290A8E, - ["Text_EggHatchHuh"] = 0x292668, - ["Text_EmptyTrashCan"] = 0x2A82B3, - ["Text_FailSweetScent"] = 0x290CB7, - ["Text_LinkErrorPleaseReset"] = 0x27821C, - ["Text_MonUsedDive"] = 0x290C1A, - ["Text_MonUsedFieldMove"] = 0x290771, - ["Text_MonUsedStrength"] = 0x29098C, - ["Text_MonUsedWaterfall"] = 0x290AFC, - ["Text_PictureBookShelf"] = 0x2A81E5, - ["Text_PlayersMadeDifferentSelections"] = 0x278255, - ["Text_PokemonCenterBookShelf"] = 0x2A8232, - ["Text_RepelWoreOff"] = 0x2A4B33, - ["Text_ShopShelf"] = 0x2A82BF, - ["Text_SomeoneIsNotReadyToLink"] = 0x2781C7, - ["Text_StrengthActivated"] = 0x290A16, - ["Text_TerminateLinkConfirmation"] = 0x2783A8, - ["Text_TerminateLinkPleaseWait"] = 0x2783E9, - ["Text_ThisIsATestSignpostMsg"] = 0x2C840A, - ["Text_Vase"] = 0x2A8276, - ["Text_WantToCut"] = 0x29072E, - ["Text_WantToDive"] = 0x290BE8, - ["Text_WantToSmash"] = 0x29083A, - ["Text_WantToStrength"] = 0x29092C, - ["Text_WantToSurface"] = 0x290C6E, - ["Text_WantToWaterfall"] = 0x290AC3, - ["Text_WeHopeToSeeYouAgain"] = 0x272640, - ["Text_WouldYouLikeToMixRecords"] = 0x27260D, - ["TradeCenter_Text_TakeSeatStartTrade"] = 0x2784E2, - ["TrendWatcher_Text_FemaleTaughtMePhrase"] = 0x284C9B, - ["TrendWatcher_Text_FemaleTellMeBigger"] = 0x284DF5, - ["TrendWatcher_Text_Intro"] = 0x284A3E, - ["TrendWatcher_Text_MaleTaughtMePhrase"] = 0x284C55, - ["TrendWatcher_Text_MaleTellMeBigger"] = 0x284DB6, - ["TrendWatcher_Text_Outro"] = 0x284E34, - ["TrendWatcher_Text_PhraseWasHopeless"] = 0x284CE1, - ["VerdanturfTown_BattleTentLobby_Text_AchievedThreeWinStreak"] = 0x2C5731, - ["VerdanturfTown_BattleTentLobby_Text_ExplainBasicRules"] = 0x2C62B1, - ["VerdanturfTown_BattleTentLobby_Text_ExplainMoveRules"] = 0x2C64BA, - ["VerdanturfTown_BattleTentLobby_Text_ExplainNatureRules"] = 0x2C636D, - ["VerdanturfTown_BattleTentLobby_Text_ExplainUnderpoweredRules"] = 0x2C6612, - ["VerdanturfTown_BattleTentLobby_Text_ExplainVerdanturfTent"] = 0x2C5163, - ["VerdanturfTown_BattleTentLobby_Text_ExplainWhenInDangerRules"] = 0x2C671E, - ["VerdanturfTown_BattleTentLobby_Text_FeatWillBeRecorded"] = 0x2C5791, - ["VerdanturfTown_BattleTentLobby_Text_NotEnoughValidMonsLv50"] = 0x2C543D, - ["VerdanturfTown_BattleTentLobby_Text_NotEnoughValidMonsLvOpen"] = 0x2C5538, - ["VerdanturfTown_BattleTentLobby_Text_NowFollowMe"] = 0x2C56A2, - ["VerdanturfTown_BattleTentLobby_Text_PresentYouWithPrize"] = 0x2C57CD, - ["VerdanturfTown_BattleTentLobby_Text_ReadWhichHeading"] = 0x2C628E, - ["VerdanturfTown_BattleTentLobby_Text_ResultsWillBeRecorded"] = 0x2C56B8, - ["VerdanturfTown_BattleTentLobby_Text_ReturnFortified"] = 0x2C539A, - ["VerdanturfTown_BattleTentLobby_Text_RulesAreListed"] = 0x2C6878, - ["VerdanturfTown_BattleTentLobby_Text_RulesAreListed2"] = 0x2C6261, - ["VerdanturfTown_BattleTentLobby_Text_SaveBeforeChallenge"] = 0x2C5662, - ["VerdanturfTown_BattleTentLobby_Text_SelectThreeMons"] = 0x2C5633, - ["VerdanturfTown_BattleTentLobby_Text_TakeChallenge"] = 0x2C5129, - ["VerdanturfTown_BattleTentLobby_Text_WelcomeToBattleTent"] = 0x2C50C3, - ["VerdanturfTown_BattleTentLobby_Text_WhichLevelMode"] = 0x2C53DB, - ["gBerryFirmnessString_Hard"] = 0x5E9249, - ["gBerryFirmnessString_Soft"] = 0x5E9244, - ["gBerryFirmnessString_SuperHard"] = 0x5E9258, - ["gBerryFirmnessString_VeryHard"] = 0x5E924E, - ["gBerryFirmnessString_VerySoft"] = 0x5E923A, - ["gBirchDexRatingText_AreYouCurious"] = 0x2A5C9C, - ["gBirchDexRatingText_Cancel"] = 0x2A5CEB, - ["gBirchDexRatingText_DexCompleted"] = 0x2A6312, - ["gBirchDexRatingText_LessThan10"] = 0x2A5D6C, - ["gBirchDexRatingText_LessThan100"] = 0x2A5FB9, - ["gBirchDexRatingText_LessThan110"] = 0x2A6018, - ["gBirchDexRatingText_LessThan120"] = 0x2A6061, - ["gBirchDexRatingText_LessThan130"] = 0x2A609C, - ["gBirchDexRatingText_LessThan140"] = 0x2A60D5, - ["gBirchDexRatingText_LessThan150"] = 0x2A6124, - ["gBirchDexRatingText_LessThan160"] = 0x2A616F, - ["gBirchDexRatingText_LessThan170"] = 0x2A61D6, - ["gBirchDexRatingText_LessThan180"] = 0x2A623A, - ["gBirchDexRatingText_LessThan190"] = 0x2A6287, - ["gBirchDexRatingText_LessThan20"] = 0x2A5DAB, - ["gBirchDexRatingText_LessThan200"] = 0x2A62C9, - ["gBirchDexRatingText_LessThan30"] = 0x2A5DF1, - ["gBirchDexRatingText_LessThan40"] = 0x2A5E34, - ["gBirchDexRatingText_LessThan50"] = 0x2A5E83, - ["gBirchDexRatingText_LessThan60"] = 0x2A5EB9, - ["gBirchDexRatingText_LessThan70"] = 0x2A5EF4, - ["gBirchDexRatingText_LessThan80"] = 0x2A5F39, - ["gBirchDexRatingText_LessThan90"] = 0x2A5F82, - ["gBirchDexRatingText_OnANationwideBasis"] = 0x2A633D, - ["gBirchDexRatingText_SoYouveSeenAndCaught"] = 0x2A5D2C, - ["gContestBeauty"] = 0x27EA5B, - ["gContestCoolness"] = 0x27EA52, - ["gContestCuteness"] = 0x27EA62, - ["gContestHallPaintingCaption"] = 0x27EA0C, - ["gContestLink"] = 0x27EA4D, - ["gContestMoveTypeBeautyText"] = 0x27D4EF, - ["gContestMoveTypeCoolText"] = 0x27D4EA, - ["gContestMoveTypeCuteText"] = 0x27D4F6, - ["gContestMoveTypeSmartText"] = 0x27D4FB, - ["gContestMoveTypeToughText"] = 0x27D501, - ["gContestPaintingBeauty1"] = 0x27EAFA, - ["gContestPaintingBeauty2"] = 0x27EB27, - ["gContestPaintingBeauty3"] = 0x27EB47, - ["gContestPaintingContest"] = 0x27EA17, - ["gContestPaintingCool1"] = 0x27EA7F, - ["gContestPaintingCool2"] = 0x27EAA6, - ["gContestPaintingCool3"] = 0x27EACE, - ["gContestPaintingCute1"] = 0x27EB79, - ["gContestPaintingCute2"] = 0x27EB91, - ["gContestPaintingCute3"] = 0x27EBB2, - ["gContestPaintingSmart1"] = 0x27EBD7, - ["gContestPaintingSmart2"] = 0x27EC03, - ["gContestPaintingSmart3"] = 0x27EC28, - ["gContestPaintingTough1"] = 0x27EC4E, - ["gContestPaintingTough2"] = 0x27EC73, - ["gContestPaintingTough3"] = 0x27EC9A, - ["gContestRankHyper"] = 0x27EA36, - ["gContestRankMaster"] = 0x27EA41, - ["gContestRankNormal"] = 0x27EA1F, - ["gContestRankSuper"] = 0x27EA2B, - ["gContestSmartness"] = 0x27EA6B, - ["gContestToughness"] = 0x27EA75, - ["gDaycareText_DontLikeOther"] = 0x5EF81A, - ["gDaycareText_GetAlong"] = 0x5EF7FF, - ["gDaycareText_GetAlongVeryWell"] = 0x5EF7DA, - ["gDaycareText_PlayOther"] = 0x5EF846, - ["gEasyChatGroupName_Actions"] = 0x5EE9E6, - ["gEasyChatGroupName_Adjectives"] = 0x5EEA0B, - ["gEasyChatGroupName_Battle"] = 0x5EE9A4, - ["gEasyChatGroupName_Conditions"] = 0x5EE9DB, - ["gEasyChatGroupName_Endings"] = 0x5EE9CA, - ["gEasyChatGroupName_Events"] = 0x5EEA16, - ["gEasyChatGroupName_Feelings"] = 0x5EE9D2, - ["gEasyChatGroupName_Greetings"] = 0x5EE9AB, - ["gEasyChatGroupName_Hobbies"] = 0x5EE9F8, - ["gEasyChatGroupName_Lifestyle"] = 0x5EE9EE, - ["gEasyChatGroupName_Misc"] = 0x5EEA05, - ["gEasyChatGroupName_Move1"] = 0x5EEA1D, - ["gEasyChatGroupName_Move2"] = 0x5EEA24, - ["gEasyChatGroupName_People"] = 0x5EE9B5, - ["gEasyChatGroupName_Pokemon"] = 0x5EE98D, - ["gEasyChatGroupName_Pokemon2"] = 0x5EEA39, - ["gEasyChatGroupName_Speech"] = 0x5EE9C3, - ["gEasyChatGroupName_Status"] = 0x5EE99D, - ["gEasyChatGroupName_Time"] = 0x5EEA00, - ["gEasyChatGroupName_Trainer"] = 0x5EE995, - ["gEasyChatGroupName_TrendySaying"] = 0x5EEA2B, - ["gEasyChatGroupName_Voices"] = 0x5EE9BC, - ["gGiftRibbonDescriptionPart1_100StraightWin"] = 0x623C3C, - ["gGiftRibbonDescriptionPart1_2003GlobalCup"] = 0x623AB0, - ["gGiftRibbonDescriptionPart1_2003NationalTourney"] = 0x623A9A, - ["gGiftRibbonDescriptionPart1_2003RegionalTourney"] = 0x623A74, - ["gGiftRibbonDescriptionPart1_2004GlobalCup"] = 0x623B11, - ["gGiftRibbonDescriptionPart1_2004NationalTourney"] = 0x623AFB, - ["gGiftRibbonDescriptionPart1_2004RegionalTourney"] = 0x623AE5, - ["gGiftRibbonDescriptionPart1_2005GlobalCup"] = 0x623B4D, - ["gGiftRibbonDescriptionPart1_2005NationalTourney"] = 0x623B37, - ["gGiftRibbonDescriptionPart1_2005RegionalTourney"] = 0x623B21, - ["gGiftRibbonDescriptionPart1_AdvanceCup"] = 0x623B94, - ["gGiftRibbonDescriptionPart1_BlackironTower"] = 0x623C72, - ["gGiftRibbonDescriptionPart1_ClearingAllChallenges"] = 0x623C10, - ["gGiftRibbonDescriptionPart1_DarknessTower"] = 0x623C4D, - ["gGiftRibbonDescriptionPart1_DifficultyClearing"] = 0x623BE7, - ["gGiftRibbonDescriptionPart1_Evergreen"] = 0x623D40, - ["gGiftRibbonDescriptionPart1_FinalTower"] = 0x623C88, - ["gGiftRibbonDescriptionPart1_FullOfEnergy"] = 0x623D92, - ["gGiftRibbonDescriptionPart1_HardWorker"] = 0x623D68, - ["gGiftRibbonDescriptionPart1_LegendMaking"] = 0x623C9A, - ["gGiftRibbonDescriptionPart1_LotsOfFriends"] = 0x623D7B, - ["gGiftRibbonDescriptionPart1_LoveForPokemon"] = 0x623DD4, - ["gGiftRibbonDescriptionPart1_LovedPokemon"] = 0x623DA8, - ["gGiftRibbonDescriptionPart1_PokemonBattleCup"] = 0x623B5D, - ["gGiftRibbonDescriptionPart1_PokemonCenterNY"] = 0x623CE8, - ["gGiftRibbonDescriptionPart1_PokemonCenterNagoya"] = 0x623CD2, - ["gGiftRibbonDescriptionPart1_PokemonCenterOsaka"] = 0x623CBD, - ["gGiftRibbonDescriptionPart1_PokemonCenterTokyo"] = 0x623CA8, - ["gGiftRibbonDescriptionPart1_PokemonEvent"] = 0x623BC8, - ["gGiftRibbonDescriptionPart1_PokemonFestival"] = 0x623BD6, - ["gGiftRibbonDescriptionPart1_PokemonLeague"] = 0x623B85, - ["gGiftRibbonDescriptionPart1_PokemonTournament"] = 0x623BA0, - ["gGiftRibbonDescriptionPart1_RedTower"] = 0x623C62, - ["gGiftRibbonDescriptionPart1_SpecialHoliday"] = 0x623D51, - ["gGiftRibbonDescriptionPart1_SpringHolidays"] = 0x623D29, - ["gGiftRibbonDescriptionPart1_SummerHolidays"] = 0x623CFA, - ["gGiftRibbonDescriptionPart1_WinterHolidays"] = 0x623D12, - ["gGiftRibbonDescriptionPart2_Champion"] = 0x623A8A, - ["gGiftRibbonDescriptionPart2_ClearingAllChallenges"] = 0x623C23, - ["gGiftRibbonDescriptionPart2_Commemorative"] = 0x623BFB, - ["gGiftRibbonDescriptionPart2_EmptyString"] = 0x623D11, - ["gGiftRibbonDescriptionPart2_LoveForPokemon"] = 0x623DE6, - ["gGiftRibbonDescriptionPart2_LovedPokemon"] = 0x623DBF, - ["gGiftRibbonDescriptionPart2_Participation"] = 0x623B70, - ["gGiftRibbonDescriptionPart2_Participation2"] = 0x623BB3, - ["gGiftRibbonDescriptionPart2_RunnerUp"] = 0x623AC0, - ["gGiftRibbonDescriptionPart2_Semifinalist"] = 0x623AD1, - ["gJPText_AllowEReaderToLoadCard"] = 0x5EE0DC, - ["gJPText_AreYouSureYouWantToSpinTradeMon"] = 0x5EA1CD, - ["gJPText_BattlePoints"] = 0x5ECF9E, - ["gJPText_CardReadingHasBeenHalted"] = 0x5EE06B, - ["gJPText_Connecting"] = 0x5EE097, - ["gJPText_ConnectionComplete"] = 0x5EE0FA, - ["gJPText_ConnectionErrorCheckLink"] = 0x5EE0A3, - ["gJPText_ConnectionErrorTryAgain"] = 0x5EE0BF, - ["gJPText_DecideStop"] = 0x5EDFC9, - ["gJPText_GameFreak"] = 0x5EE8DC, - ["gJPText_LinkIsIncorrect"] = 0x5EE05C, - ["gJPText_MysteryGift"] = 0x5EDFBF, - ["gJPText_NewTrainerHasComeToHoenn"] = 0x5EE107, - ["gJPText_No1MSubCircuit"] = 0x5E8440, - ["gJPText_Player"] = 0x5EE846, - ["gJPText_PlayersXPokemon"] = 0x5ED2CF, - ["gJPText_PlayersXPokmonDoesNotExist"] = 0x5ED2D9, - ["gJPText_PleaseWaitAMoment"] = 0x5EE120, - ["gJPText_ReceiveMysteryGiftWithEReader"] = 0x5EDFD6, - ["gJPText_Sama"] = 0x5EE84C, - ["gJPText_SelectConnectAndPressA"] = 0x5EE035, - ["gJPText_SelectConnectFromEReaderMenu"] = 0x5EDFF5, - ["gJPText_SelectConnectWithGBA"] = 0x5EE014, - ["gJPText_UnableConnectWithEReader"] = 0x5EE080, - ["gJPText_WhatIsTheQuizAnswer"] = 0x5EC645, - ["gJPText_WriteErrorUnableToSaveData"] = 0x5EE12D, - ["gMenuText_Check"] = 0x5E8D20, - ["gMenuText_CheckTag"] = 0x5E8CD9, - ["gMenuText_Confirm"] = 0x5E8CE3, - ["gMenuText_Deselect"] = 0x5E8D2B, - ["gMenuText_Give"] = 0x5E8CD4, - ["gMenuText_Give2"] = 0x5E8D9B, - ["gMenuText_Register"] = 0x5E8CCB, - ["gMenuText_Show"] = 0x5E8CFE, - ["gMenuText_Toss"] = 0x5E8CC6, - ["gMenuText_Use"] = 0x5E8CC2, - ["gMenuText_Walk"] = 0x5E8CEB, - ["gPCText_Bag"] = 0x5EBAAF, - ["gPCText_Beach"] = 0x5EBB19, - ["gPCText_Cancel"] = 0x5EBA4E, - ["gPCText_Cave"] = 0x5EBB14, - ["gPCText_City"] = 0x5EBAEE, - ["gPCText_Crag"] = 0x5EBB02, - ["gPCText_Desert"] = 0x5EBAF3, - ["gPCText_Etcetera"] = 0x5EBAD6, - ["gPCText_Forest"] = 0x5EBAE7, - ["gPCText_Friends"] = 0x5EBADF, - ["gPCText_Give"] = 0x5EBAA3, - ["gPCText_Info"] = 0x5EBAB3, - ["gPCText_Jump"] = 0x5EBA8F, - ["gPCText_Machine"] = 0x5EBB47, - ["gPCText_Mark"] = 0x5EBA85, - ["gPCText_Move"] = 0x5EBA6A, - ["gPCText_Name"] = 0x5EBA8A, - ["gPCText_Place"] = 0x5EBA6F, - ["gPCText_Pokecenter"] = 0x5EBB3C, - ["gPCText_PolkaDot"] = 0x5EBB32, - ["gPCText_Release"] = 0x5EBA7D, - ["gPCText_River"] = 0x5EBB28, - ["gPCText_Savanna"] = 0x5EBAFA, - ["gPCText_Scenery1"] = 0x5EBAB8, - ["gPCText_Scenery2"] = 0x5EBAC2, - ["gPCText_Scenery3"] = 0x5EBACC, - ["gPCText_Seafloor"] = 0x5EBB1F, - ["gPCText_Shift"] = 0x5EBA64, - ["gPCText_Simple"] = 0x5EBB4F, - ["gPCText_Sky"] = 0x5EBB2E, - ["gPCText_Snow"] = 0x5EBB0F, - ["gPCText_Store"] = 0x5EBA55, - ["gPCText_Summary"] = 0x5EBA75, - ["gPCText_Switch"] = 0x5EBAA8, - ["gPCText_Take"] = 0x5EBA9E, - ["gPCText_Volcano"] = 0x5EBB07, - ["gPCText_Wallpaper"] = 0x5EBA94, - ["gPCText_Withdraw"] = 0x5EBA5B, - ["gPokeNewsTextBlendMaster_Ending"] = 0x28DF05, - ["gPokeNewsTextBlendMaster_Ongoing"] = 0x28DD9E, - ["gPokeNewsTextBlendMaster_Upcoming"] = 0x28DBEC, - ["gPokeNewsTextGameCorner_Ending"] = 0x28D768, - ["gPokeNewsTextGameCorner_Ongoing"] = 0x28D66F, - ["gPokeNewsTextGameCorner_Upcoming"] = 0x28D571, - ["gPokeNewsTextLilycove_Ending"] = 0x28DAC0, - ["gPokeNewsTextLilycove_Ongoing"] = 0x28D994, - ["gPokeNewsTextLilycove_Upcoming"] = 0x28D875, - ["gPokeNewsTextSlateport_Ending"] = 0x28D3F5, - ["gPokeNewsTextSlateport_Ongoing"] = 0x28D2A1, - ["gPokeNewsTextSlateport_Upcoming"] = 0x28D0F8, - ["gRibbonDescriptionPart1_Artist"] = 0x623950, - ["gRibbonDescriptionPart1_BeautyContest"] = 0x623876, - ["gRibbonDescriptionPart1_Champion"] = 0x62383C, - ["gRibbonDescriptionPart1_CoolContest"] = 0x623869, - ["gRibbonDescriptionPart1_CuteContest"] = 0x623885, - ["gRibbonDescriptionPart1_Effort"] = 0x623981, - ["gRibbonDescriptionPart1_SmartContest"] = 0x623892, - ["gRibbonDescriptionPart1_ToughContest"] = 0x6238A0, - ["gRibbonDescriptionPart1_Victory"] = 0x623923, - ["gRibbonDescriptionPart1_Winning"] = 0x6238FC, - ["gRibbonDescriptionPart2_Artist"] = 0x623968, - ["gRibbonDescriptionPart2_Champion"] = 0x623853, - ["gRibbonDescriptionPart2_Effort"] = 0x623994, - ["gRibbonDescriptionPart2_HyperRank"] = 0x6238D5, - ["gRibbonDescriptionPart2_MasterRank"] = 0x6238E8, - ["gRibbonDescriptionPart2_NormalRank"] = 0x6238AE, - ["gRibbonDescriptionPart2_SuperRank"] = 0x6238C2, - ["gRibbonDescriptionPart2_Victory"] = 0x62393B, - ["gRibbonDescriptionPart2_Winning"] = 0x62390E, - ["gStatusConditionString_BurnJpn"] = 0x31BC88, - ["gStatusConditionString_ConfusionJpn"] = 0x31BC98, - ["gStatusConditionString_IceJpn"] = 0x31BC90, - ["gStatusConditionString_LoveJpn"] = 0x31BCA0, - ["gStatusConditionString_ParalysisJpn"] = 0x31BC80, - ["gStatusConditionString_PoisonJpn"] = 0x31BC70, - ["gStatusConditionString_SleepJpn"] = 0x31BC78, - ["gTV3CheersForPokeblocksText00"] = 0x2818F4, - ["gTV3CheersForPokeblocksText01"] = 0x2819C7, - ["gTV3CheersForPokeblocksText02"] = 0x281A2F, - ["gTV3CheersForPokeblocksText03"] = 0x281AAC, - ["gTV3CheersForPokeblocksText04"] = 0x281B28, - ["gTV3CheersForPokeblocksText05"] = 0x281B93, - ["gTVBravoTrainerText00"] = 0x27F275, - ["gTVBravoTrainerText01"] = 0x27F304, - ["gTVBravoTrainerText02"] = 0x27F361, - ["gTVBravoTrainerText03"] = 0x27F3E4, - ["gTVBravoTrainerText04"] = 0x27F49F, - ["gTVBravoTrainerText05"] = 0x27F565, - ["gTVBravoTrainerText06"] = 0x27F624, - ["gTVBravoTrainerText07"] = 0x27F65C, - ["gTVBravoTrainerText08"] = 0x27F6E6, - ["gTVBreakingNewsText00"] = 0x2855BF, - ["gTVBreakingNewsText01"] = 0x2855D1, - ["gTVBreakingNewsText02"] = 0x28563C, - ["gTVBreakingNewsText03"] = 0x285682, - ["gTVBreakingNewsText04"] = 0x285705, - ["gTVBreakingNewsText05"] = 0x2857B0, - ["gTVBreakingNewsText06"] = 0x285824, - ["gTVBreakingNewsText07"] = 0x28586A, - ["gTVBreakingNewsText08"] = 0x28596F, - ["gTVBreakingNewsText09"] = 0x2859AC, - ["gTVBreakingNewsText10"] = 0x285A50, - ["gTVBreakingNewsText11"] = 0x285AB3, - ["gTVBreakingNewsText12"] = 0x2858D0, - ["gTVFanClubOpinionsText00"] = 0x280886, - ["gTVFanClubOpinionsText01"] = 0x280A44, - ["gTVFanClubOpinionsText02"] = 0x280AFC, - ["gTVFanClubOpinionsText03"] = 0x280BC4, - ["gTVFanClubOpinionsText04"] = 0x280C7A, - ["gTVFanClubText00"] = 0x280DEE, - ["gTVFanClubText01"] = 0x280F69, - ["gTVFanClubText02"] = 0x280F88, - ["gTVFanClubText03"] = 0x280FA9, - ["gTVFanClubText04"] = 0x280FD5, - ["gTVFanClubText05"] = 0x281040, - ["gTVFanClubText06"] = 0x281073, - ["gTVFanClubText07"] = 0x2810E7, - ["gTVFindThatGamerText00"] = 0x285240, - ["gTVFindThatGamerText01"] = 0x2852F4, - ["gTVFindThatGamerText02"] = 0x285463, - ["gTVFindThatGamerText03"] = 0x285500, - ["gTVHoennTreasureInvestigatorsText00"] = 0x284EDF, - ["gTVHoennTreasureInvestigatorsText01"] = 0x284FA1, - ["gTVHoennTreasureInvestigatorsText02"] = 0x2850F5, - ["gTVInSearchOfTrainersText00"] = 0x28B92C, - ["gTVInSearchOfTrainersText01"] = 0x28BA20, - ["gTVInSearchOfTrainersText02"] = 0x28BAC8, - ["gTVInSearchOfTrainersText03"] = 0x28BC18, - ["gTVInSearchOfTrainersText04"] = 0x28BD20, - ["gTVInSearchOfTrainersText05"] = 0x28BE01, - ["gTVInSearchOfTrainersText06"] = 0x28BEEE, - ["gTVInSearchOfTrainersText07"] = 0x28BF50, - ["gTVInSearchOfTrainersText08"] = 0x28C011, - ["gTVMassOutbreakText00"] = 0x28178A, - ["gTVNameRaterText00"] = 0x28218A, - ["gTVNameRaterText01"] = 0x28229E, - ["gTVNameRaterText02"] = 0x282317, - ["gTVNameRaterText03"] = 0x28239D, - ["gTVNameRaterText04"] = 0x282418, - ["gTVNameRaterText05"] = 0x2824A6, - ["gTVNameRaterText06"] = 0x282522, - ["gTVNameRaterText07"] = 0x2825A1, - ["gTVNameRaterText08"] = 0x282623, - ["gTVNameRaterText09"] = 0x2826B4, - ["gTVNameRaterText10"] = 0x28274D, - ["gTVNameRaterText11"] = 0x2827CB, - ["gTVNameRaterText12"] = 0x282849, - ["gTVNameRaterText13"] = 0x282883, - ["gTVNameRaterText14"] = 0x2828E4, - ["gTVNameRaterText15"] = 0x282912, - ["gTVNameRaterText16"] = 0x28296C, - ["gTVNameRaterText17"] = 0x282996, - ["gTVNameRaterText18"] = 0x282A36, - ["gTVPokemonAnglerText00"] = 0x282ACF, - ["gTVPokemonAnglerText01"] = 0x282D7C, - ["gTVPokemonBattleUpdateText00"] = 0x28ABCC, - ["gTVPokemonBattleUpdateText01"] = 0x28AC22, - ["gTVPokemonBattleUpdateText02"] = 0x28AC7E, - ["gTVPokemonBattleUpdateText03"] = 0x28ACB6, - ["gTVPokemonBattleUpdateText04"] = 0x28ACE3, - ["gTVPokemonBattleUpdateText05"] = 0x28AD80, - ["gTVPokemonBattleUpdateText06"] = 0x28ADE2, - ["gTVPokemonBattleUpdateText07"] = 0x28AE26, - ["gTVPokemonLotteryWinnerFlashReportText00"] = 0x2863CC, - ["gTVPokemonNewsBattleFrontierText00"] = 0x2877DE, - ["gTVPokemonNewsBattleFrontierText01"] = 0x28783D, - ["gTVPokemonNewsBattleFrontierText02"] = 0x2878B3, - ["gTVPokemonNewsBattleFrontierText03"] = 0x287929, - ["gTVPokemonNewsBattleFrontierText04"] = 0x28799E, - ["gTVPokemonNewsBattleFrontierText05"] = 0x287A18, - ["gTVPokemonNewsBattleFrontierText06"] = 0x287A97, - ["gTVPokemonNewsBattleFrontierText07"] = 0x287B16, - ["gTVPokemonNewsBattleFrontierText08"] = 0x287B8E, - ["gTVPokemonNewsBattleFrontierText09"] = 0x287C06, - ["gTVPokemonNewsBattleFrontierText10"] = 0x287C7D, - ["gTVPokemonNewsBattleFrontierText11"] = 0x287CF3, - ["gTVPokemonNewsBattleFrontierText12"] = 0x287D6A, - ["gTVPokemonNewsBattleFrontierText13"] = 0x287DE1, - ["gTVPokemonNewsBattleFrontierText14"] = 0x287E5B, - ["gTVPokemonNewsBattleFrontierText15"] = 0x287EBB, - ["gTVPokemonNewsBattleFrontierText16"] = 0x287F14, - ["gTVPokemonNewsBattleFrontierText17"] = 0x287F39, - ["gTVPokemonNewsBattleFrontierText18"] = 0x287F77, - ["gTVPokemonTodayFailedText00"] = 0x282F9B, - ["gTVPokemonTodayFailedText01"] = 0x28304D, - ["gTVPokemonTodayFailedText02"] = 0x283135, - ["gTVPokemonTodayFailedText03"] = 0x2831DF, - ["gTVPokemonTodayFailedText04"] = 0x283294, - ["gTVPokemonTodayFailedText05"] = 0x283337, - ["gTVPokemonTodayFailedText06"] = 0x2833C6, - ["gTVPokemonTodaySuccessfulText00"] = 0x2834A0, - ["gTVPokemonTodaySuccessfulText01"] = 0x283552, - ["gTVPokemonTodaySuccessfulText02"] = 0x2835AE, - ["gTVPokemonTodaySuccessfulText03"] = 0x28361F, - ["gTVPokemonTodaySuccessfulText04"] = 0x283685, - ["gTVPokemonTodaySuccessfulText05"] = 0x28370C, - ["gTVPokemonTodaySuccessfulText06"] = 0x2837C2, - ["gTVPokemonTodaySuccessfulText07"] = 0x283848, - ["gTVPokemonTodaySuccessfulText08"] = 0x2838C2, - ["gTVPokemonTodaySuccessfulText09"] = 0x28394A, - ["gTVPokemonTodaySuccessfulText10"] = 0x2839EA, - ["gTVPokemonTodaySuccessfulText11"] = 0x283A5F, - ["gTVRecentHappeningsText00"] = 0x28149E, - ["gTVRecentHappeningsText01"] = 0x2815AF, - ["gTVRecentHappeningsText02"] = 0x281636, - ["gTVRecentHappeningsText03"] = 0x281666, - ["gTVRecentHappeningsText04"] = 0x2816BA, - ["gTVRecentHappeningsText05"] = 0x2816EB, - ["gTVSafariFanClubText00"] = 0x289813, - ["gTVSafariFanClubText01"] = 0x28992F, - ["gTVSafariFanClubText02"] = 0x28999D, - ["gTVSafariFanClubText03"] = 0x2899DC, - ["gTVSafariFanClubText04"] = 0x289A29, - ["gTVSafariFanClubText05"] = 0x289AC2, - ["gTVSafariFanClubText06"] = 0x289B42, - ["gTVSafariFanClubText07"] = 0x289BC5, - ["gTVSafariFanClubText08"] = 0x289C3B, - ["gTVSafariFanClubText09"] = 0x289C99, - ["gTVSafariFanClubText10"] = 0x289D33, - ["gTVSecretBaseVisitText00"] = 0x285B27, - ["gTVSecretBaseVisitText01"] = 0x285BCA, - ["gTVSecretBaseVisitText02"] = 0x285C13, - ["gTVSecretBaseVisitText03"] = 0x285CFF, - ["gTVSecretBaseVisitText04"] = 0x285D5D, - ["gTVSecretBaseVisitText05"] = 0x285DE0, - ["gTVSecretBaseVisitText06"] = 0x285E3B, - ["gTVSecretBaseVisitText07"] = 0x285E9D, - ["gTVSecretBaseVisitText08"] = 0x285EFE, - ["gTVSecretBaseVisitText09"] = 0x285F46, - ["gTVSecretBaseVisitText10"] = 0x286049, - ["gTVSecretBaseVisitText11"] = 0x28613A, - ["gTVSecretBaseVisitText12"] = 0x286248, - ["gTVSecretBaseVisitText13"] = 0x286340, - ["gTVThePokemonBattleSeminarText00"] = 0x286616, - ["gTVThePokemonBattleSeminarText01"] = 0x2866B6, - ["gTVThePokemonBattleSeminarText02"] = 0x286700, - ["gTVThePokemonBattleSeminarText03"] = 0x28673B, - ["gTVThePokemonBattleSeminarText04"] = 0x286755, - ["gTVThePokemonBattleSeminarText05"] = 0x28676A, - ["gTVThePokemonBattleSeminarText06"] = 0x286777, - ["gTVTodaysRivalTrainerText00"] = 0x284641, - ["gTVTodaysRivalTrainerText01"] = 0x28485F, - ["gTVTodaysRivalTrainerText02"] = 0x28489A, - ["gTVTodaysRivalTrainerText03"] = 0x2848CC, - ["gTVTodaysRivalTrainerText04"] = 0x28490B, - ["gTVTodaysRivalTrainerText05"] = 0x28496B, - ["gTVTodaysRivalTrainerText06"] = 0x2849AE, - ["gTVTodaysRivalTrainerText07"] = 0x2846D2, - ["gTVTodaysRivalTrainerText08"] = 0x284738, - ["gTVTodaysRivalTrainerText09"] = 0x2847A5, - ["gTVTodaysRivalTrainerText10"] = 0x2847F7, - ["gTVTrainerFanClubSpecialText00"] = 0x281DFB, - ["gTVTrainerFanClubSpecialText01"] = 0x281F90, - ["gTVTrainerFanClubSpecialText02"] = 0x281FFA, - ["gTVTrainerFanClubSpecialText03"] = 0x282052, - ["gTVTrainerFanClubSpecialText04"] = 0x2820B2, - ["gTVTrainerFanClubSpecialText05"] = 0x28210E, - ["gTVTrainerFanClubText00"] = 0x286866, - ["gTVTrainerFanClubText01"] = 0x28695E, - ["gTVTrainerFanClubText02"] = 0x28698B, - ["gTVTrainerFanClubText03"] = 0x2869B0, - ["gTVTrainerFanClubText04"] = 0x2869ED, - ["gTVTrainerFanClubText05"] = 0x286A17, - ["gTVTrainerFanClubText06"] = 0x286A48, - ["gTVTrainerFanClubText07"] = 0x286A7C, - ["gTVTrainerFanClubText08"] = 0x286AAE, - ["gTVTrainerFanClubText09"] = 0x286AE6, - ["gTVTrainerFanClubText10"] = 0x286B23, - ["gTVTrainerFanClubText11"] = 0x286B4F, - ["gTVWhatsNo1InHoennTodayText00"] = 0x287FE0, - ["gTVWhatsNo1InHoennTodayText01"] = 0x2880C0, - ["gTVWhatsNo1InHoennTodayText02"] = 0x28815F, - ["gTVWhatsNo1InHoennTodayText03"] = 0x28821A, - ["gTVWhatsNo1InHoennTodayText04"] = 0x2882BE, - ["gTVWhatsNo1InHoennTodayText05"] = 0x288355, - ["gTVWhatsNo1InHoennTodayText06"] = 0x28842B, - ["gTVWhatsNo1InHoennTodayText07"] = 0x2884A5, - ["gTVWhatsNo1InHoennTodayText08"] = 0x28858B, - ["gTVWorldOfMastersText00"] = 0x2843BA, - ["gTVWorldOfMastersText01"] = 0x2844FD, - ["gTVWorldOfMastersText02"] = 0x28455B, - ["gTest_MissedTurn"] = 0x27E8CE, - ["gText_0Pts"] = 0x5ECB8D, - ["gText_100Pts"] = 0x5ECBD2, - ["gText_10BP"] = 0x5EB290, - ["gText_10F"] = 0x5EB685, - ["gText_10P30P50P50P"] = 0x5EDD6B, - ["gText_10Pts"] = 0x5ECB93, - ["gText_11F"] = 0x5EB689, - ["gText_12PoofForgotMove"] = 0x5E9BB8, - ["gText_15BP"] = 0x5EB295, - ["gText_1Colon"] = 0x5EDDAE, - ["gText_1DotBlueF700"] = 0x5ED7D4, - ["gText_1DotF700"] = 0x5ED7E0, - ["gText_1F"] = 0x5EB66A, - ["gText_1MinutePlus"] = 0x5EB5DA, - ["gText_1st"] = 0x5EDFA0, - ["gText_20Pts"] = 0x5ECB9A, - ["gText_234Players"] = 0x5EE473, - ["gText_2Colon"] = 0x5EDDB1, - ["gText_2F"] = 0x5EB66D, - ["gText_2nd"] = 0x5EDFA4, - ["gText_30Pts"] = 0x5ECBA1, - ["gText_3Colon"] = 0x5EDDB4, - ["gText_3Dashes"] = 0x5EE154, - ["gText_3F"] = 0x5EB670, - ["gText_3QuestionMarks"] = 0x27E8CA, - ["gText_3rd"] = 0x5EDFA8, - ["gText_40Pts"] = 0x5ECBA8, - ["gText_4Colon"] = 0x5EDDB7, - ["gText_4F"] = 0x5EB673, - ["gText_4Spaces"] = 0x5EED05, - ["gText_4th"] = 0x5EDFAC, - ["gText_500CoinsAndPrice"] = 0x5EB017, - ["gText_50CoinsAndPrice"] = 0x5EB002, - ["gText_50Pts"] = 0x5ECBAF, - ["gText_5BP"] = 0x5EB28A, - ["gText_5Colon"] = 0x5EDDBA, - ["gText_5F"] = 0x5EB676, - ["gText_5MarksPokemon"] = 0x5E86B8, - ["gText_60Pts"] = 0x5ECBB6, - ["gText_6F"] = 0x5EB679, - ["gText_70Pts"] = 0x5ECBBD, - ["gText_7F"] = 0x5EB67C, - ["gText_80Pts"] = 0x5ECBC4, - ["gText_8F"] = 0x5EB67F, - ["gText_90Pts"] = 0x5ECBCB, - ["gText_99TimesPlus"] = 0x5EB5CF, - ["gText_9F"] = 0x5EB682, - ["gText_ABtnRegistrationCounter"] = 0x5EE553, - ["gText_ABtnTitleScreen"] = 0x5EE572, - ["gText_AButton"] = 0x5E84DA, - ["gText_AButtonExit"] = 0x5E8588, - ["gText_AFormerChampion"] = 0x5CCD6E, - ["gText_Able"] = 0x5EA099, - ["gText_Able2"] = 0x5EA0B1, - ["gText_Able3"] = 0x5EA0BF, - ["gText_AbnormalWeatherEnded_Rain"] = 0x273656, - ["gText_AbnormalWeatherEnded_Sun"] = 0x273684, - ["gText_AboutE_Mode"] = 0x5EB234, - ["gText_AboutG_Mode"] = 0x5EB241, - ["gText_Accept"] = 0x5EECE9, - ["gText_AcceptThisPkmn"] = 0x5EECF0, - ["gText_AccessedLanettesPC"] = 0x2726D4, - ["gText_AccessedPlayersPC"] = 0x2726C2, - ["gText_AccessedSomeonesPC"] = 0x27268C, - ["gText_Accuracy2"] = 0x5EA384, - ["gText_Acro"] = 0x5EAE2C, - ["gText_AffectedByAppealInFront"] = 0x27D23F, - ["gText_AfterYouHaveReadTheQuiz"] = 0x5EC6B7, - ["gText_AllOutOfAppealTime"] = 0x27D597, - ["gText_AllTextBeingEditedWill"] = 0x5EC410, - ["gText_AlreadyHadCard"] = 0x5EF1B4, - ["gText_AlreadyHadNews"] = 0x5EF1D8, - ["gText_AlreadyHadStamp"] = 0x5EF200, - ["gText_AlreadyHoldingOne"] = 0x5EA073, - ["gText_AlreadySavedFile"] = 0x2C87D5, - ["gText_AlreadySavedFile_Chat"] = 0x5ED368, - ["gText_AlreadySurfing"] = 0x5E9EE9, - ["gText_Amy"] = 0x5EFA77, - ["gText_AnabelDefeatGold"] = 0x2C8480, - ["gText_AnabelDefeatSilver"] = 0x2C8458, - ["gText_AnabelWonGold"] = 0x2C846C, - ["gText_AnabelWonSilver"] = 0x2C843F, - ["gText_AndCreateAQuiz"] = 0x5EC339, - ["gText_AndFillOutTheQuestionnaire"] = 0x5EC3D2, - ["gText_AndImproveTheBardsSong"] = 0x5EC168, - ["gText_AndMakeAMessage"] = 0x5EC0BE, - ["gText_AndMakeAMessage2"] = 0x5EC135, - ["gText_AndMakeATrendySaying"] = 0x5EC252, - ["gText_AndMakeYourProfile"] = 0x5EC08A, - ["gText_Anna"] = 0x5EFA8A, - ["gText_AnnouncingPrizes"] = 0x5EDD9B, - ["gText_AnnouncingRankings"] = 0x5EDD86, - ["gText_AnnouncingResults"] = 0x5ED1B8, - ["gText_Answer"] = 0x5EFCCD, - ["gText_AnticipationRoseForUpcomingAppeals"] = 0x27DAB2, - ["gText_AnticipationSwelledForMonsAppealNext"] = 0x27D986, - ["gText_AnticipationSwelledForMonsAppealNext2"] = 0x27D805, - ["gText_AnythingElseICanHelp"] = 0x5E959B, - ["gText_ApostropheSBase"] = 0x5EA72F, - ["gText_Appeal"] = 0x5EA38D, - ["gText_AppealAsGoodAsOneBeforeIt"] = 0x27D13F, - ["gText_AppealAsGoodAsThoseBeforeIt"] = 0x27D112, - ["gText_AppealBestMoreCrowdExcited"] = 0x27D401, - ["gText_AppealBetterLaterItsPerformed"] = 0x27D16E, - ["gText_AppealComboWentOverExcellently"] = 0x27E38D, - ["gText_AppealComboWentOverVeryWell"] = 0x27E35B, - ["gText_AppealComboWentOverWell"] = 0x27E32E, - ["gText_AppealExcitesAudienceInAnyContest"] = 0x27D39A, - ["gText_AppealGreatIfPerformedFirst"] = 0x27D0BD, - ["gText_AppealGreatIfPerformedLast"] = 0x27D0E8, - ["gText_AppealNumButItCantParticipate"] = 0x27D531, - ["gText_AppealNumWhichMoveWillBePlayed"] = 0x27D507, - ["gText_AppealTriedToUnnerveWaitingMons"] = 0x27DD8E, - ["gText_AppealVariesDependingOnTiming"] = 0x27D1A1, - ["gText_AppealWorksWellIfConditionGood"] = 0x27D2A3, - ["gText_AppealsOfOthersCheapenedByHalf"] = 0x27DA03, - ["gText_ApprenticeChallenge0"] = 0x2B6EA5, - ["gText_ApprenticeChallenge1"] = 0x2B6EEC, - ["gText_ApprenticeChallenge10"] = 0x2B70CC, - ["gText_ApprenticeChallenge11"] = 0x2B710A, - ["gText_ApprenticeChallenge12"] = 0x2B714D, - ["gText_ApprenticeChallenge13"] = 0x2B7185, - ["gText_ApprenticeChallenge14"] = 0x2B71C1, - ["gText_ApprenticeChallenge15"] = 0x2B71F9, - ["gText_ApprenticeChallenge2"] = 0x2B6F16, - ["gText_ApprenticeChallenge3"] = 0x2B6F4C, - ["gText_ApprenticeChallenge4"] = 0x2B6F92, - ["gText_ApprenticeChallenge5"] = 0x2B6FC9, - ["gText_ApprenticeChallenge6"] = 0x2B700C, - ["gText_ApprenticeChallenge7"] = 0x2B703A, - ["gText_ApprenticeChallenge8"] = 0x2B706A, - ["gText_ApprenticeChallenge9"] = 0x2B709C, - ["gText_ApprenticeHoldNothing0"] = 0x2BA34E, - ["gText_ApprenticeHoldNothing1"] = 0x2BA58C, - ["gText_ApprenticeHoldNothing10"] = 0x2BBC1C, - ["gText_ApprenticeHoldNothing11"] = 0x2BBEE5, - ["gText_ApprenticeHoldNothing12"] = 0x2BC213, - ["gText_ApprenticeHoldNothing13"] = 0x2BC514, - ["gText_ApprenticeHoldNothing14"] = 0x2BC808, - ["gText_ApprenticeHoldNothing15"] = 0x2BCB75, - ["gText_ApprenticeHoldNothing2"] = 0x2BA742, - ["gText_ApprenticeHoldNothing3"] = 0x2BA96B, - ["gText_ApprenticeHoldNothing4"] = 0x2BAC43, - ["gText_ApprenticeHoldNothing5"] = 0x2BAF4E, - ["gText_ApprenticeHoldNothing6"] = 0x2BB18C, - ["gText_ApprenticeHoldNothing7"] = 0x2BB4C3, - ["gText_ApprenticeHoldNothing8"] = 0x2BB6E5, - ["gText_ApprenticeHoldNothing9"] = 0x2BB970, - ["gText_ApprenticeItemAlreadyRecommended0"] = 0x2BA448, - ["gText_ApprenticeItemAlreadyRecommended1"] = 0x2BA635, - ["gText_ApprenticeItemAlreadyRecommended10"] = 0x2BBD90, - ["gText_ApprenticeItemAlreadyRecommended11"] = 0x2BC024, - ["gText_ApprenticeItemAlreadyRecommended12"] = 0x2BC373, - ["gText_ApprenticeItemAlreadyRecommended13"] = 0x2BC666, - ["gText_ApprenticeItemAlreadyRecommended14"] = 0x2BC984, - ["gText_ApprenticeItemAlreadyRecommended15"] = 0x2BCCA4, - ["gText_ApprenticeItemAlreadyRecommended2"] = 0x2BA7D8, - ["gText_ApprenticeItemAlreadyRecommended3"] = 0x2BAA81, - ["gText_ApprenticeItemAlreadyRecommended4"] = 0x2BADB6, - ["gText_ApprenticeItemAlreadyRecommended5"] = 0x2BB05F, - ["gText_ApprenticeItemAlreadyRecommended6"] = 0x2BB2D9, - ["gText_ApprenticeItemAlreadyRecommended7"] = 0x2BB5E1, - ["gText_ApprenticeItemAlreadyRecommended8"] = 0x2BB84A, - ["gText_ApprenticeItemAlreadyRecommended9"] = 0x2BBA6C, - ["gText_ApprenticeLevelModeThanks0"] = 0x2B7423, - ["gText_ApprenticeLevelModeThanks1"] = 0x2B763F, - ["gText_ApprenticeLevelModeThanks10"] = 0x2B9204, - ["gText_ApprenticeLevelModeThanks11"] = 0x2B9564, - ["gText_ApprenticeLevelModeThanks12"] = 0x2B989A, - ["gText_ApprenticeLevelModeThanks13"] = 0x2B9B76, - ["gText_ApprenticeLevelModeThanks14"] = 0x2B9EAA, - ["gText_ApprenticeLevelModeThanks15"] = 0x2BA1F3, - ["gText_ApprenticeLevelModeThanks2"] = 0x2B7871, - ["gText_ApprenticeLevelModeThanks3"] = 0x2B7D18, - ["gText_ApprenticeLevelModeThanks4"] = 0x2B7FE8, - ["gText_ApprenticeLevelModeThanks5"] = 0x2B8356, - ["gText_ApprenticeLevelModeThanks6"] = 0x2B8656, - ["gText_ApprenticeLevelModeThanks7"] = 0x2B8957, - ["gText_ApprenticeLevelModeThanks8"] = 0x2B8C20, - ["gText_ApprenticeLevelModeThanks9"] = 0x2B8ED5, - ["gText_ApprenticeMonFirstThanks0"] = 0x2BCE64, - ["gText_ApprenticeMonFirstThanks1"] = 0x2BCF61, - ["gText_ApprenticeMonFirstThanks10"] = 0x2BDC6B, - ["gText_ApprenticeMonFirstThanks11"] = 0x2BDDEC, - ["gText_ApprenticeMonFirstThanks12"] = 0x2BDF4D, - ["gText_ApprenticeMonFirstThanks13"] = 0x2BE0FD, - ["gText_ApprenticeMonFirstThanks14"] = 0x2BE2A5, - ["gText_ApprenticeMonFirstThanks15"] = 0x2BE46C, - ["gText_ApprenticeMonFirstThanks2"] = 0x2BD03C, - ["gText_ApprenticeMonFirstThanks3"] = 0x2BD18A, - ["gText_ApprenticeMonFirstThanks4"] = 0x2BD325, - ["gText_ApprenticeMonFirstThanks5"] = 0x2BD493, - ["gText_ApprenticeMonFirstThanks6"] = 0x2BD609, - ["gText_ApprenticeMonFirstThanks7"] = 0x2BD797, - ["gText_ApprenticeMonFirstThanks8"] = 0x2BD8F5, - ["gText_ApprenticeMonFirstThanks9"] = 0x2BDAE1, - ["gText_ApprenticeMonThanks0"] = 0x2BE5F5, - ["gText_ApprenticeMonThanks1"] = 0x2BE71E, - ["gText_ApprenticeMonThanks10"] = 0x2BF3CF, - ["gText_ApprenticeMonThanks11"] = 0x2BF551, - ["gText_ApprenticeMonThanks12"] = 0x2BF6E5, - ["gText_ApprenticeMonThanks13"] = 0x2BF869, - ["gText_ApprenticeMonThanks14"] = 0x2BF9BA, - ["gText_ApprenticeMonThanks15"] = 0x2BFB4E, - ["gText_ApprenticeMonThanks2"] = 0x2BE7F8, - ["gText_ApprenticeMonThanks3"] = 0x2BE99C, - ["gText_ApprenticeMonThanks4"] = 0x2BEAE9, - ["gText_ApprenticeMonThanks5"] = 0x2BEC8E, - ["gText_ApprenticeMonThanks6"] = 0x2BEE29, - ["gText_ApprenticeMonThanks7"] = 0x2BEFE2, - ["gText_ApprenticeMonThanks8"] = 0x2BF11D, - ["gText_ApprenticeMonThanks9"] = 0x2BF268, - ["gText_ApprenticeMoveThanks0"] = 0x2BFCAE, - ["gText_ApprenticeMoveThanks1"] = 0x2BFDB1, - ["gText_ApprenticeMoveThanks10"] = 0x2C0982, - ["gText_ApprenticeMoveThanks11"] = 0x2C0AFD, - ["gText_ApprenticeMoveThanks12"] = 0x2C0C7D, - ["gText_ApprenticeMoveThanks13"] = 0x2C0DFE, - ["gText_ApprenticeMoveThanks14"] = 0x2C0F6D, - ["gText_ApprenticeMoveThanks15"] = 0x2C1122, - ["gText_ApprenticeMoveThanks2"] = 0x2BFEAD, - ["gText_ApprenticeMoveThanks3"] = 0x2C0032, - ["gText_ApprenticeMoveThanks4"] = 0x2C016E, - ["gText_ApprenticeMoveThanks5"] = 0x2C034C, - ["gText_ApprenticeMoveThanks6"] = 0x2C046E, - ["gText_ApprenticeMoveThanks7"] = 0x2C0598, - ["gText_ApprenticeMoveThanks8"] = 0x2C06D8, - ["gText_ApprenticeMoveThanks9"] = 0x2C0809, - ["gText_ApprenticePhrase"] = 0x5EC391, - ["gText_ApprenticePickWinSpeech0"] = 0x2C11D1, - ["gText_ApprenticePickWinSpeech1"] = 0x2C13AB, - ["gText_ApprenticePickWinSpeech10"] = 0x2C24B5, - ["gText_ApprenticePickWinSpeech11"] = 0x2C2707, - ["gText_ApprenticePickWinSpeech12"] = 0x2C28D6, - ["gText_ApprenticePickWinSpeech13"] = 0x2C2B50, - ["gText_ApprenticePickWinSpeech14"] = 0x2C2D67, - ["gText_ApprenticePickWinSpeech15"] = 0x2C2EF5, - ["gText_ApprenticePickWinSpeech2"] = 0x2C1501, - ["gText_ApprenticePickWinSpeech3"] = 0x2C165E, - ["gText_ApprenticePickWinSpeech4"] = 0x2C1862, - ["gText_ApprenticePickWinSpeech5"] = 0x2C1A76, - ["gText_ApprenticePickWinSpeech6"] = 0x2C1CF5, - ["gText_ApprenticePickWinSpeech7"] = 0x2C1EDC, - ["gText_ApprenticePickWinSpeech8"] = 0x2C20D1, - ["gText_ApprenticePickWinSpeech9"] = 0x2C231C, - ["gText_ApprenticePleaseTeach0"] = 0x2B7229, - ["gText_ApprenticePleaseTeach1"] = 0x2B74C1, - ["gText_ApprenticePleaseTeach10"] = 0x2B8F45, - ["gText_ApprenticePleaseTeach11"] = 0x2B929C, - ["gText_ApprenticePleaseTeach12"] = 0x2B95D8, - ["gText_ApprenticePleaseTeach13"] = 0x2B992D, - ["gText_ApprenticePleaseTeach14"] = 0x2B9BF2, - ["gText_ApprenticePleaseTeach15"] = 0x2B9F55, - ["gText_ApprenticePleaseTeach2"] = 0x2B76AC, - ["gText_ApprenticePleaseTeach3"] = 0x2B78D4, - ["gText_ApprenticePleaseTeach4"] = 0x2B7DD4, - ["gText_ApprenticePleaseTeach5"] = 0x2B8087, - ["gText_ApprenticePleaseTeach6"] = 0x2B83CE, - ["gText_ApprenticePleaseTeach7"] = 0x2B86EA, - ["gText_ApprenticePleaseTeach8"] = 0x2B89C6, - ["gText_ApprenticePleaseTeach9"] = 0x2B8CAA, - ["gText_ApprenticeRejectTeaching0"] = 0x2B731C, - ["gText_ApprenticeRejectTeaching1"] = 0x2B756F, - ["gText_ApprenticeRejectTeaching10"] = 0x2B905F, - ["gText_ApprenticeRejectTeaching11"] = 0x2B9438, - ["gText_ApprenticeRejectTeaching12"] = 0x2B9763, - ["gText_ApprenticeRejectTeaching13"] = 0x2B9A84, - ["gText_ApprenticeRejectTeaching14"] = 0x2B9D83, - ["gText_ApprenticeRejectTeaching15"] = 0x2BA084, - ["gText_ApprenticeRejectTeaching2"] = 0x2B7772, - ["gText_ApprenticeRejectTeaching3"] = 0x2B7B1A, - ["gText_ApprenticeRejectTeaching4"] = 0x2B7EE5, - ["gText_ApprenticeRejectTeaching5"] = 0x2B822B, - ["gText_ApprenticeRejectTeaching6"] = 0x2B84FC, - ["gText_ApprenticeRejectTeaching7"] = 0x2B87DA, - ["gText_ApprenticeRejectTeaching8"] = 0x2B8ACF, - ["gText_ApprenticeRejectTeaching9"] = 0x2B8DD3, - ["gText_ApprenticeThanksHeldItem0"] = 0x2BA3D2, - ["gText_ApprenticeThanksHeldItem1"] = 0x2BA5F3, - ["gText_ApprenticeThanksHeldItem10"] = 0x2BBCF6, - ["gText_ApprenticeThanksHeldItem11"] = 0x2BBFA4, - ["gText_ApprenticeThanksHeldItem12"] = 0x2BC2DD, - ["gText_ApprenticeThanksHeldItem13"] = 0x2BC5CE, - ["gText_ApprenticeThanksHeldItem14"] = 0x2BC8EA, - ["gText_ApprenticeThanksHeldItem15"] = 0x2BCBFC, - ["gText_ApprenticeThanksHeldItem2"] = 0x2BA78F, - ["gText_ApprenticeThanksHeldItem3"] = 0x2BAA1B, - ["gText_ApprenticeThanksHeldItem4"] = 0x2BAD17, - ["gText_ApprenticeThanksHeldItem5"] = 0x2BAFDB, - ["gText_ApprenticeThanksHeldItem6"] = 0x2BB242, - ["gText_ApprenticeThanksHeldItem7"] = 0x2BB575, - ["gText_ApprenticeThanksHeldItem8"] = 0x2BB7A2, - ["gText_ApprenticeThanksHeldItem9"] = 0x2BBA05, - ["gText_ApprenticeThanksNoHeldItem0"] = 0x2BA380, - ["gText_ApprenticeThanksNoHeldItem1"] = 0x2BA5BF, - ["gText_ApprenticeThanksNoHeldItem10"] = 0x2BBC4B, - ["gText_ApprenticeThanksNoHeldItem11"] = 0x2BBF25, - ["gText_ApprenticeThanksNoHeldItem12"] = 0x2BC247, - ["gText_ApprenticeThanksNoHeldItem13"] = 0x2BC555, - ["gText_ApprenticeThanksNoHeldItem14"] = 0x2BC84D, - ["gText_ApprenticeThanksNoHeldItem15"] = 0x2BCBA6, - ["gText_ApprenticeThanksNoHeldItem2"] = 0x2BA770, - ["gText_ApprenticeThanksNoHeldItem3"] = 0x2BA9B7, - ["gText_ApprenticeThanksNoHeldItem4"] = 0x2BAC78, - ["gText_ApprenticeThanksNoHeldItem5"] = 0x2BAF8F, - ["gText_ApprenticeThanksNoHeldItem6"] = 0x2BB1CE, - ["gText_ApprenticeThanksNoHeldItem7"] = 0x2BB4FB, - ["gText_ApprenticeThanksNoHeldItem8"] = 0x2BB72C, - ["gText_ApprenticeThanksNoHeldItem9"] = 0x2BB9AE, - ["gText_ApprenticeWhatHeldItem0"] = 0x2BA2A3, - ["gText_ApprenticeWhatHeldItem1"] = 0x2BA4D3, - ["gText_ApprenticeWhatHeldItem10"] = 0x2BBB01, - ["gText_ApprenticeWhatHeldItem11"] = 0x2BBE0B, - ["gText_ApprenticeWhatHeldItem12"] = 0x2BC0C8, - ["gText_ApprenticeWhatHeldItem13"] = 0x2BC40E, - ["gText_ApprenticeWhatHeldItem14"] = 0x2BC714, - ["gText_ApprenticeWhatHeldItem15"] = 0x2BCA4D, - ["gText_ApprenticeWhatHeldItem2"] = 0x2BA6E6, - ["gText_ApprenticeWhatHeldItem3"] = 0x2BA867, - ["gText_ApprenticeWhatHeldItem4"] = 0x2BAB22, - ["gText_ApprenticeWhatHeldItem5"] = 0x2BAE36, - ["gText_ApprenticeWhatHeldItem6"] = 0x2BB0D4, - ["gText_ApprenticeWhatHeldItem7"] = 0x2BB370, - ["gText_ApprenticeWhatHeldItem8"] = 0x2BB656, - ["gText_ApprenticeWhatHeldItem9"] = 0x2BB8CD, - ["gText_ApprenticeWhichLevelMode0"] = 0x2B735B, - ["gText_ApprenticeWhichLevelMode1"] = 0x2B75B2, - ["gText_ApprenticeWhichLevelMode10"] = 0x2B910E, - ["gText_ApprenticeWhichLevelMode11"] = 0x2B9488, - ["gText_ApprenticeWhichLevelMode12"] = 0x2B97E5, - ["gText_ApprenticeWhichLevelMode13"] = 0x2B9AB9, - ["gText_ApprenticeWhichLevelMode14"] = 0x2B9DF9, - ["gText_ApprenticeWhichLevelMode15"] = 0x2BA11D, - ["gText_ApprenticeWhichLevelMode2"] = 0x2B77CE, - ["gText_ApprenticeWhichLevelMode3"] = 0x2B7C13, - ["gText_ApprenticeWhichLevelMode4"] = 0x2B7F35, - ["gText_ApprenticeWhichLevelMode5"] = 0x2B8286, - ["gText_ApprenticeWhichLevelMode6"] = 0x2B8559, - ["gText_ApprenticeWhichLevelMode7"] = 0x2B887C, - ["gText_ApprenticeWhichLevelMode8"] = 0x2B8B66, - ["gText_ApprenticeWhichLevelMode9"] = 0x2B8E24, - ["gText_ApprenticeWhichMon0"] = 0x2BE50D, - ["gText_ApprenticeWhichMon1"] = 0x2BE679, - ["gText_ApprenticeWhichMon10"] = 0x2BF2D1, - ["gText_ApprenticeWhichMon11"] = 0x2BF46A, - ["gText_ApprenticeWhichMon12"] = 0x2BF5C3, - ["gText_ApprenticeWhichMon13"] = 0x2BF773, - ["gText_ApprenticeWhichMon14"] = 0x2BF8DD, - ["gText_ApprenticeWhichMon15"] = 0x2BFA5A, - ["gText_ApprenticeWhichMon2"] = 0x2BE762, - ["gText_ApprenticeWhichMon3"] = 0x2BE850, - ["gText_ApprenticeWhichMon4"] = 0x2BEA1B, - ["gText_ApprenticeWhichMon5"] = 0x2BEB72, - ["gText_ApprenticeWhichMon6"] = 0x2BED16, - ["gText_ApprenticeWhichMon7"] = 0x2BEEB4, - ["gText_ApprenticeWhichMon8"] = 0x2BF04E, - ["gText_ApprenticeWhichMon9"] = 0x2BF1A8, - ["gText_ApprenticeWhichMonFirst0"] = 0x2BCD68, - ["gText_ApprenticeWhichMonFirst1"] = 0x2BCEF2, - ["gText_ApprenticeWhichMonFirst10"] = 0x2BDB4E, - ["gText_ApprenticeWhichMonFirst11"] = 0x2BDD0D, - ["gText_ApprenticeWhichMonFirst12"] = 0x2BDE68, - ["gText_ApprenticeWhichMonFirst13"] = 0x2BDFD8, - ["gText_ApprenticeWhichMonFirst14"] = 0x2BE189, - ["gText_ApprenticeWhichMonFirst15"] = 0x2BE33E, - ["gText_ApprenticeWhichMonFirst2"] = 0x2BCFA1, - ["gText_ApprenticeWhichMonFirst3"] = 0x2BD06D, - ["gText_ApprenticeWhichMonFirst4"] = 0x2BD222, - ["gText_ApprenticeWhichMonFirst5"] = 0x2BD3B1, - ["gText_ApprenticeWhichMonFirst6"] = 0x2BD51C, - ["gText_ApprenticeWhichMonFirst7"] = 0x2BD697, - ["gText_ApprenticeWhichMonFirst8"] = 0x2BD806, - ["gText_ApprenticeWhichMonFirst9"] = 0x2BD9BE, - ["gText_ApprenticeWhichMove0"] = 0x2BFBF2, - ["gText_ApprenticeWhichMove1"] = 0x2BFD26, - ["gText_ApprenticeWhichMove10"] = 0x2C086E, - ["gText_ApprenticeWhichMove11"] = 0x2C0A1D, - ["gText_ApprenticeWhichMove12"] = 0x2C0B6F, - ["gText_ApprenticeWhichMove13"] = 0x2C0D0B, - ["gText_ApprenticeWhichMove14"] = 0x2C0E71, - ["gText_ApprenticeWhichMove15"] = 0x2C1003, - ["gText_ApprenticeWhichMove2"] = 0x2BFE24, - ["gText_ApprenticeWhichMove3"] = 0x2BFF0A, - ["gText_ApprenticeWhichMove4"] = 0x2C0090, - ["gText_ApprenticeWhichMove5"] = 0x2C01F7, - ["gText_ApprenticeWhichMove6"] = 0x2C03CA, - ["gText_ApprenticeWhichMove7"] = 0x2C04F9, - ["gText_ApprenticeWhichMove8"] = 0x2C0602, - ["gText_ApprenticeWhichMove9"] = 0x2C074A, - ["gText_ApprenticeWinSpeechThanks0"] = 0x2C12D5, - ["gText_ApprenticeWinSpeechThanks1"] = 0x2C1444, - ["gText_ApprenticeWinSpeechThanks10"] = 0x2C25B1, - ["gText_ApprenticeWinSpeechThanks11"] = 0x2C27D4, - ["gText_ApprenticeWinSpeechThanks12"] = 0x2C2A0B, - ["gText_ApprenticeWinSpeechThanks13"] = 0x2C2C77, - ["gText_ApprenticeWinSpeechThanks14"] = 0x2C2E41, - ["gText_ApprenticeWinSpeechThanks15"] = 0x2C3023, - ["gText_ApprenticeWinSpeechThanks2"] = 0x2C15B6, - ["gText_ApprenticeWinSpeechThanks3"] = 0x2C174F, - ["gText_ApprenticeWinSpeechThanks4"] = 0x2C19A0, - ["gText_ApprenticeWinSpeechThanks5"] = 0x2C1C16, - ["gText_ApprenticeWinSpeechThanks6"] = 0x2C1DC1, - ["gText_ApprenticeWinSpeechThanks7"] = 0x2C1FEC, - ["gText_ApprenticeWinSpeechThanks8"] = 0x2C21FF, - ["gText_ApprenticeWinSpeechThanks9"] = 0x2C2407, - ["gText_ApprenticesPhrase"] = 0x5EC650, - ["gText_Are"] = 0x5CCB88, - ["gText_Are2"] = 0x5CCB8C, - ["gText_AtTheBattlesStart"] = 0x5EC5B2, - ["gText_Attack"] = 0x5EEA4E, - ["gText_Attack3"] = 0x5EA33C, - ["gText_AttractedCrowdsAttention"] = 0x27E7BA, - ["gText_AvoidStartledByOthers"] = 0x27CC5D, - ["gText_AvoidStartledByOthersLittle"] = 0x27CC81, - ["gText_AvoidStartledByOthersOnce"] = 0x27CC34, - ["gText_AwaitingLinkup"] = 0x278131, - ["gText_AwesomeWonF701F700"] = 0x5EDED3, - ["gText_B1F"] = 0x5EB68D, - ["gText_B2F"] = 0x5EB691, - ["gText_B3F"] = 0x5EB695, - ["gText_B4F"] = 0x5EB699, - ["gText_BButton"] = 0x5E84E3, - ["gText_BDot"] = 0x5ED1B5, - ["gText_BP"] = 0x5EB6AD, - ["gText_BackupMemoryDamaged"] = 0x5EC86F, - ["gText_Bad"] = 0x5ECB3A, - ["gText_BadConditionResultedInWeakAppeal"] = 0x27E68B, - ["gText_BadEgg"] = 0x5CCB90, - ["gText_Badges"] = 0x5E84D3, - ["gText_BadlyStartleAppealed"] = 0x27CDCB, - ["gText_BadlyStartleBeautyAppeals"] = 0x27CF4D, - ["gText_BadlyStartleCoolAppeals"] = 0x27CF1E, - ["gText_BadlyStartleCuteAppeals"] = 0x27CF7E, - ["gText_BadlyStartleFrontMon"] = 0x27CDA6, - ["gText_BadlyStartleMonsGoodCondition"] = 0x27D093, - ["gText_BadlyStartleSmartAppeals"] = 0x27CFAD, - ["gText_BadlyStartleToughAppeals"] = 0x27CFDD, - ["gText_BadlyStartlesMonsGoodAppeals"] = 0x27D3CE, - ["gText_BagFullCouldNotRemoveItem"] = 0x5E9A69, - ["gText_BagIsFull"] = 0x5EACB5, - ["gText_BagIsFull2"] = 0x5EB9EE, - ["gText_BasicRules"] = 0x5EB4AD, - ["gText_BatteryRunDry"] = 0x5E8453, - ["gText_Battle"] = 0x5ED423, - ["gText_BattleArena"] = 0x5ED13A, - ["gText_BattleArena2"] = 0x5EDAD8, - ["gText_BattleArenaDesc"] = 0x5EDBC2, - ["gText_BattleArenaGutsSymbol"] = 0x5EDA21, - ["gText_BattleBag"] = 0x5EB212, - ["gText_BattleBag2"] = 0x5EB563, - ["gText_BattleBasics"] = 0x5EB4EB, - ["gText_BattleChoiceResults"] = 0x5EE261, - ["gText_BattleDome"] = 0x5ED111, - ["gText_BattleDome2"] = 0x5EDABE, - ["gText_BattleDomeDesc"] = 0x5EDB4E, - ["gText_BattleDomeTacticsSymbol"] = 0x5ED9E5, - ["gText_BattleFactory"] = 0x5ED12B, - ["gText_BattleFactory2"] = 0x5EDAE5, - ["gText_BattleFactoryDesc"] = 0x5EDC00, - ["gText_BattleFactoryKnowledgeSymbol"] = 0x5EDA3C, - ["gText_BattleFrontier"] = 0x5EB06E, - ["gText_BattleMenu"] = 0x5CCA3A, - ["gText_BattleMon"] = 0x5EB43A, - ["gText_BattleMoves"] = 0x5EA3EB, - ["gText_BattlePalace"] = 0x5ED11D, - ["gText_BattlePalace2"] = 0x5EDACA, - ["gText_BattlePalaceDesc"] = 0x5EDB8B, - ["gText_BattlePalaceSpiritsSymbol"] = 0x5EDA02, - ["gText_BattlePike"] = 0x5ED147, - ["gText_BattlePike2"] = 0x5EDAF4, - ["gText_BattlePikeDesc"] = 0x5EDC45, - ["gText_BattlePikeLuckSymbol"] = 0x5EDA5E, - ["gText_BattlePoints"] = 0x5ED91C, - ["gText_BattlePokemon"] = 0x5EB597, - ["gText_BattlePts"] = 0x5EB424, - ["gText_BattlePtsWon"] = 0x5ED0B6, - ["gText_BattlePyramid"] = 0x5ED153, - ["gText_BattlePyramid2"] = 0x5EDB00, - ["gText_BattlePyramidBraveSymbol"] = 0x5EDA78, - ["gText_BattlePyramidDesc"] = 0x5EDC84, - ["gText_BattlePyramidFloor"] = 0x5EE716, - ["gText_BattleQuestResults"] = 0x5EE2D8, - ["gText_BattleRecord"] = 0x5ED90E, - ["gText_BattleRules"] = 0x5EB45C, - ["gText_BattleSalon"] = 0x5EB444, - ["gText_BattleScene"] = 0x5EE59B, - ["gText_BattleSceneOff"] = 0x5EE5FD, - ["gText_BattleSceneOn"] = 0x5EE5F4, - ["gText_BattleStyle"] = 0x5EE5A8, - ["gText_BattleStyleSet"] = 0x5EE613, - ["gText_BattleStyleShift"] = 0x5EE607, - ["gText_BattleSwapDoubleResults"] = 0x5EE2B8, - ["gText_BattleSwapSingleResults"] = 0x5EE298, - ["gText_BattleSwitchWhich"] = 0x5CCACA, - ["gText_BattleSwitchWhich2"] = 0x5CCAE0, - ["gText_BattleSwitchWhich3"] = 0x5CCAE9, - ["gText_BattleSwitchWhich4"] = 0x5CCAEB, - ["gText_BattleSwitchWhich5"] = 0x5CCAEF, - ["gText_BattleTourney"] = 0x5CCCEE, - ["gText_BattleTower"] = 0x5ED0D3, - ["gText_BattleTower2"] = 0x5ED104, - ["gText_BattleTower3"] = 0x5EDAB1, - ["gText_BattleTowerAbilitySymbol"] = 0x5ED9C7, - ["gText_BattleTowerDesc"] = 0x5EDB0F, - ["gText_BattleTrainers"] = 0x5EB5A6, - ["gText_BattleWallyName"] = 0x5CCB98, - ["gText_BattleWasRefused"] = 0x5EFC0C, - ["gText_BattleYesNoChoice"] = 0x5CCABB, - ["gText_BeDeletedThatOkay"] = 0x5EC42F, - ["gText_Beauty"] = 0x5EB0ED, - ["gText_Beauty2"] = 0x5EB124, - ["gText_Beauty3"] = 0x5EC7B9, - ["gText_BeautyContest"] = 0x5EADB5, - ["gText_BeautyMove"] = 0x27E89E, - ["gText_BecameLessAwareOfOtherMons"] = 0x27DC45, - ["gText_BecameMoreConsciousOfOtherMons"] = 0x27DB75, - ["gText_BecameObliviousToOtherMons"] = 0x27DC0F, - ["gText_BecomeLeader"] = 0x5EB3C6, - ["gText_Berries"] = 0x5EFCE5, - ["gText_Berries2"] = 0x5EB161, - ["gText_BerriesInRowFivePlayers"] = 0x5EDD1E, - ["gText_BerriesPicked"] = 0x5EDD02, - ["gText_BerriesPocket"] = 0x5E91E9, - ["gText_Berry"] = 0x5EFCDF, - ["gText_Berry2"] = 0x5E8DA4, - ["gText_BerryCrush"] = 0x5ED036, - ["gText_BerryCrush2"] = 0x5ED8D0, - ["gText_BerryCrush3"] = 0x5EB397, - ["gText_BerryPickingRecords"] = 0x5EDCE5, - ["gText_BerryPickingResults"] = 0x5EDD49, - ["gText_BerryTag"] = 0x5E926B, - ["gText_BestScore"] = 0x5EDD12, - ["gText_BestScore2"] = 0x5EDEB2, - ["gText_BigGirl"] = 0x5EB601, - ["gText_BigGuy"] = 0x5EB5F9, - ["gText_BigHoleInTheWall"] = 0x27304E, - ["gText_BirchBoy"] = 0x5E858F, - ["gText_BirchGirl"] = 0x5E8593, - ["gText_BirchInTrouble"] = 0x5E8C53, - ["gText_Birch_AndYouAre"] = 0x2C8BD0, - ["gText_Birch_AreYouReady"] = 0x2C8C7A, - ["gText_Birch_BoyOrGirl"] = 0x2C8BDD, - ["gText_Birch_MainSpeech"] = 0x2C8A1F, - ["gText_Birch_Pokemon"] = 0x2C89FB, - ["gText_Birch_SoItsPlayer"] = 0x2C8C1C, - ["gText_Birch_Welcome"] = 0x2C897B, - ["gText_Birch_WhatsYourName"] = 0x2C8BFF, - ["gText_Birch_YourePlayer"] = 0x2C8C2A, - ["gText_BirthIsland"] = 0x5EB2BD, - ["gText_Bitter"] = 0x5E932D, - ["gText_Bitter2"] = 0x5ECB58, - ["gText_BlackFlute"] = 0x5EB645, - ["gText_BlackFlute2"] = 0x5EAF93, - ["gText_BlackPokeblock"] = 0x5E92FA, - ["gText_Blank"] = 0x5EB278, - ["gText_BlastoiseDoll256BP"] = 0x5ECCFC, - ["gText_BlenderMaxSpeedRecord"] = 0x5EE44F, - ["gText_Blue"] = 0x5EE14F, - ["gText_BlueFlute"] = 0x5EB617, - ["gText_BlueFlute2"] = 0x5EAF65, - ["gText_BluePokeblock"] = 0x5E927F, - ["gText_BlueShard"] = 0x5EB057, - ["gText_BlueTent"] = 0x5EB2A3, - ["gText_Body"] = 0x5CCDC6, - ["gText_BodySlam48BP"] = 0x5ECE60, - ["gText_BoostPP"] = 0x5E9F2A, - ["gText_BootedUpHM"] = 0x5E9047, - ["gText_BootedUpTM"] = 0x5E9037, - ["gText_Box"] = 0x5EBCC1, - ["gText_BoxFull"] = 0x5E9125, - ["gText_BoxIsFull2"] = 0x5EB88E, - ["gText_BoxName"] = 0x5EEAC9, - ["gText_BrandonDefeatGold"] = 0x2C877B, - ["gText_BrandonDefeatSilver"] = 0x2C86FE, - ["gText_BrandonWonGold"] = 0x2C8739, - ["gText_BrandonWonSilver"] = 0x2C86C3, - ["gText_Brawly"] = 0x5EAD1B, - ["gText_BrawlyMatchCallDesc"] = 0x5EFB6F, - ["gText_BrightPowder64BP"] = 0x5ECDA6, - ["gText_Brn"] = 0x5EAE3D, - ["gText_BronzeCard"] = 0x5EE670, - ["gText_BrownPokeblock"] = 0x5E92C8, - ["gText_BunnyHops"] = 0x5EAF24, - ["gText_Burn"] = 0x5CCB57, - ["gText_ButAppealWasJammed"] = 0x27D5C1, - ["gText_ButItFailedToMakeAnyoneNervous"] = 0x27E606, - ["gText_ButItFailedToMakeTargetNervous"] = 0x27E5D0, - ["gText_ButItMessedUp"] = 0x27D600, - ["gText_ButItMessedUp2"] = 0x27E5B2, - ["gText_ButItWasIgnored"] = 0x27E638, - ["gText_ButtonMode"] = 0x5EE5C8, - ["gText_ButtonTypeLEqualsA"] = 0x5EE65D, - ["gText_ButtonTypeLR"] = 0x5EE654, - ["gText_ButtonTypeNormal"] = 0x5EE647, - ["gText_ByeBye"] = 0x5ED44A, - ["gText_ByeByePkmn"] = 0x5EB8C6, - ["gText_ByeByeVar1"] = 0x5EE959, - ["gText_CDot"] = 0x5ED1B2, - ["gText_Calcium1000"] = 0x5EB738, - ["gText_Calcium1BP"] = 0x5ECD21, - ["gText_Call"] = 0x5EC017, - ["gText_Call2"] = 0x5EBE84, - ["gText_CallCantBeMadeHere"] = 0x5EEB0B, - ["gText_CallRegisteredTrainer"] = 0x5EBD01, - ["gText_CanIHelpWithAnythingElse"] = 0x5E95C7, - ["gText_Cancel"] = 0x5E8CF0, - ["gText_Cancel2"] = 0x5E8CF7, - ["gText_Cancel3"] = 0x5EECDD, - ["gText_Cancel4"] = 0x5EFAE8, - ["gText_Cancel5"] = 0x5EFCBE, - ["gText_Cancel6"] = 0x5EC022, - ["gText_Cancel7"] = 0x5E8D04, - ["gText_CancelBattle"] = 0x5EA126, - ["gText_CancelChallenge"] = 0x5EA155, - ["gText_CancelDecorating"] = 0x5EA8BF, - ["gText_CancelParticipation"] = 0x5EA110, - ["gText_CancelSelection"] = 0x5EC594, - ["gText_CantAcceptCardFromTrainer"] = 0x5EF280, - ["gText_CantAcceptNewsFromTrainer"] = 0x5EF2B4, - ["gText_CantBePlacedHere"] = 0x5EA8A6, - ["gText_CantBeUsedOnPkmn"] = 0x5E9712, - ["gText_CantBuyKeyItem"] = 0x5E960F, - ["gText_CantCallOpponentHere"] = 0x5EBE8E, - ["gText_CantDismountBike"] = 0x5E8F6E, - ["gText_CantHoldAnyMore"] = 0x5EDDE2, - ["gText_CantHoldMore"] = 0x5EDF11, - ["gText_CantMakeAnyMoreMoves"] = 0x27D67E, - ["gText_CantParticipateInAppealsAnyMore"] = 0x27DA5B, - ["gText_CantPlaceInRoom"] = 0x5EA9AE, - ["gText_CantSelectSamePkmn"] = 0x5EEC50, - ["gText_CantSendGiftToTrainer"] = 0x5EF4F0, - ["gText_CantStoreImportantItems"] = 0x5E8E8E, - ["gText_CantSurfHere"] = 0x5E9ED4, - ["gText_CantSwitchWithAlly"] = 0x5E97B2, - ["gText_CantThrowAwayInUse"] = 0x5EA9E0, - ["gText_CantTradeWithTrainer"] = 0x5EA2B8, - ["gText_CantUseHere"] = 0x5E9F01, - ["gText_CantUseUntilNewBadge"] = 0x5E97FC, - ["gText_CantWriteMail"] = 0x5E8DCE, - ["gText_Carbos1000"] = 0x5EB726, - ["gText_Carbos1BP"] = 0x5ECD45, - ["gText_CaveOfOrigin"] = 0x5EB7B8, - ["gText_Chair"] = 0x5EA85E, - ["gText_Challenge"] = 0x5EAEA2, - ["gText_ChallengeQuestionMark"] = 0x5EC727, - ["gText_ChampionMatchCallDesc"] = 0x5EFBC0, - ["gText_Championships"] = 0x5EE349, - ["gText_ChangeJustOneWordOrPhrase"] = 0x5EC149, - ["gText_ChangedToNewItem"] = 0x5EBA29, - ["gText_CharizardDoll256BP"] = 0x5ECCE5, - ["gText_CheapenedAppealOfThoseAhead"] = 0x27D8FE, - ["gText_CheapenedAppealOfThoseAhead2"] = 0x27D9B1, - ["gText_CheapenedJudgesFavoriteAppeal"] = 0x27D9D9, - ["gText_CheapenedMonsAppeal"] = 0x27D8E4, - ["gText_CheapenedMonsAppeal2"] = 0x27D947, - ["gText_Check"] = 0x5EC01C, - ["gText_CheckAllPokemonInDetail"] = 0x5EBDA2, - ["gText_CheckCompleted"] = 0x5EC917, - ["gText_CheckFrontierMap"] = 0x5ED932, - ["gText_CheckMapOfHoenn"] = 0x5EBCC5, - ["gText_CheckObtainedRibbons"] = 0x5EBD1C, - ["gText_CheckPartyPokemonInDetail"] = 0x5EBD83, - ["gText_CheckPokemonInDetail"] = 0x5EBCE8, - ["gText_CheckTrainerCard"] = 0x5ED94D, - ["gText_ChikoritaDoll80BP"] = 0x5ECC7B, - ["gText_ChoiceBand64BP"] = 0x5ECDBA, - ["gText_ChoosePokemon"] = 0x5E9E43, - ["gText_ChoosePokemon2"] = 0x5E9F6F, - ["gText_ChoosePokemonCancel"] = 0x5EA010, - ["gText_ChoosePokemonConfirm"] = 0x5EA02A, - ["gText_ClawFossil"] = 0x5EB2E4, - ["gText_ClearAllSaveData"] = 0x5EFA8F, - ["gText_ClearStreak"] = 0x5EE338, - ["gText_ClearingData"] = 0x5EFAAA, - ["gText_ClefairyDoll48BP"] = 0x5ECC3E, - ["gText_ClockHasBeenReset"] = 0x5ECA4F, - ["gText_CloseBag"] = 0x5E8DB4, - ["gText_Cloudy"] = 0x5EFC92, - ["gText_CoinCase"] = 0x5E9026, - ["gText_Coins"] = 0x5E8DAB, - ["gText_CollectedSymbols"] = 0x5ED9AF, - ["gText_Colon2"] = 0x5ECF8F, - ["gText_Colon3"] = 0x5EE68E, - ["gText_Color161Shadow161"] = 0x5EA7C8, - ["gText_ColorBlue"] = 0x5ED1A7, - ["gText_ColorDarkGray"] = 0x5ED21E, - ["gText_ColorDynamic6WhiteDynamic5"] = 0x5ED222, - ["gText_ColorLightShadowDarkGray"] = 0x5ED1A0, - ["gText_ColorTransparent"] = 0x5ED1AB, - ["gText_Colosseum"] = 0x5EB37F, - ["gText_CombineFourWordsOrPhrases"] = 0x5EC06C, - ["gText_CombineNineWordsOrPhrases"] = 0x5EC117, - ["gText_CombineSixWordsOrPhrases"] = 0x5EC0A1, - ["gText_CombineTwoWordsOrPhrases"] = 0x5EC235, - ["gText_CombineTwoWordsOrPhrases2"] = 0x5EC291, - ["gText_CombineTwoWordsOrPhrases3"] = 0x5EC520, - ["gText_ComeBackWithSecretPower"] = 0x272E30, - ["gText_CommErrorCheckConnections"] = 0x5EE4A4, - ["gText_CommErrorEllipsis"] = 0x5EE4F7, - ["gText_CommStandbyAwaitingOtherPlayer"] = 0x5EFBD2, - ["gText_CommaSpace"] = 0x5CCB7F, - ["gText_Communicating"] = 0x5EEFE8, - ["gText_CommunicationCanceled"] = 0x5EF02C, - ["gText_CommunicationCompleted"] = 0x5EEFF8, - ["gText_CommunicationError"] = 0x5EF014, - ["gText_CommunicationStandby"] = 0x5ED207, - ["gText_CommunicationStandby2"] = 0x5ED7BC, - ["gText_CommunicationStandby3"] = 0x5EDE65, - ["gText_CommunicationStandby4"] = 0x5EDF6F, - ["gText_CommunicationStandby5"] = 0x5EF6D2, - ["gText_CommunicationStandbyBButtonCancel"] = 0x5EEFC0, - ["gText_Confirm2"] = 0x5EE690, - ["gText_Confirm3"] = 0x5EFAE0, - ["gText_ConfirmLinkWhenPlayersReady"] = 0x2780B3, - ["gText_ConfirmSave"] = 0x2C87B4, - ["gText_ConfirmStartLinkWithXPlayers"] = 0x2780F2, - ["gText_ConfirmStarterChoice"] = 0x5E8C90, - ["gText_ConfirmTossItems"] = 0x5E8F0E, - ["gText_Confusion"] = 0x5CCB6A, - ["gText_CongratsPkmnEvolved"] = 0x5CC9A6, - ["gText_ContestLady_Beauty"] = 0x5EEB5A, - ["gText_ContestLady_Coolness"] = 0x5EEB51, - ["gText_ContestLady_Cuteness"] = 0x5EEB61, - ["gText_ContestLady_Handsome"] = 0x5EEB2B, - ["gText_ContestLady_Ironhard"] = 0x5EEB41, - ["gText_ContestLady_Moreme"] = 0x5EEB3A, - ["gText_ContestLady_Muscle"] = 0x5EEB4A, - ["gText_ContestLady_Smartness"] = 0x5EEB6A, - ["gText_ContestLady_Toughness"] = 0x5EEB74, - ["gText_ContestLady_Vinny"] = 0x5EEB34, - ["gText_ContestMoves"] = 0x5EA3F8, - ["gText_Contest_Anxiety"] = 0x27E83F, - ["gText_Contest_Beauty"] = 0x27E868, - ["gText_Contest_Coolness"] = 0x27E85F, - ["gText_Contest_Cuteness"] = 0x27E86F, - ["gText_Contest_Fear"] = 0x27E85A, - ["gText_Contest_Hesitancy"] = 0x27E850, - ["gText_Contest_Laziness"] = 0x27E847, - ["gText_Contest_Shyness"] = 0x27E837, - ["gText_Contest_Smartness"] = 0x27E878, - ["gText_Contest_Toughness"] = 0x27E882, - ["gText_ContestantsMonWon"] = 0x5ED1FA, - ["gText_ContinueBoxOperations"] = 0x5EB960, - ["gText_ContinueMenuBadges"] = 0x5EDCD7, - ["gText_ContinueMenuPlayer"] = 0x5EDCC3, - ["gText_ContinueMenuPokedex"] = 0x5EDCCF, - ["gText_ContinueMenuTime"] = 0x5EDCCA, - ["gText_ControlPad"] = 0x5E850B, - ["gText_Controls"] = 0x5E852C, - ["gText_Conversion"] = 0x27D4B2, - ["gText_Cool"] = 0x5EB0E8, - ["gText_Cool2"] = 0x5EB11F, - ["gText_CoolMove"] = 0x27E894, - ["gText_Coolness"] = 0x5EC7AF, - ["gText_CoolnessContest"] = 0x5EADA4, - ["gText_CoopRankings"] = 0x5ED8A0, - ["gText_CopperCard"] = 0x5EE677, - ["gText_CouldntImproveItsCondition"] = 0x27E658, - ["gText_Count"] = 0x5EAE0C, - ["gText_Counter48BP"] = 0x5ECE83, - ["gText_CreateAQuiz"] = 0x5EC743, - ["gText_CreateAQuiz2"] = 0x5EC575, - ["gText_CrowdContinuesToWatchMon"] = 0x27E7EA, - ["gText_CrushingResults"] = 0x5ED878, - ["gText_CryOf"] = 0x5E871B, - ["gText_Current"] = 0x5EE311, - ["gText_CurrentBattlePoints"] = 0x5ED993, - ["gText_CurrentIsTooFast"] = 0x5E9FDB, - ["gText_Cushion"] = 0x5EA883, - ["gText_Cute"] = 0x5EB0F4, - ["gText_Cute2"] = 0x5EB12B, - ["gText_CuteMove"] = 0x27E8AA, - ["gText_Cuteness"] = 0x5EC7C1, - ["gText_CutenessContest"] = 0x5EADC4, - ["gText_CyndaquilDoll80BP"] = 0x5ECC65, - ["gText_Dad"] = 0x5EAD04, - ["gText_DadsAdvice"] = 0x5E8F31, - ["gText_Dash"] = 0x5EEA76, - ["gText_DataWillBeSaved"] = 0x5EF5BC, - ["gText_Daughter"] = 0x5EB60E, - ["gText_Day"] = 0x5EE68A, - ["gText_Days"] = 0x5EE698, - ["gText_DecimalPoint"] = 0x5EED24, - ["gText_Decorate"] = 0x5EA7B1, - ["gText_Decoration"] = 0x5EAB36, - ["gText_Decoration2"] = 0x5EADF9, - ["gText_DecorationReturnedToPC"] = 0x5EAAC5, - ["gText_DecorationThrownAway"] = 0x5EAA3D, - ["gText_DecorationWillBeDiscarded"] = 0x5EAA14, - ["gText_DeepSeaScale"] = 0x5EAF58, - ["gText_DeepSeaTooth"] = 0x5EAF4B, - ["gText_DefaultNameAllie"] = 0x5E862B, - ["gText_DefaultNameAubree"] = 0x5E864B, - ["gText_DefaultNameBella"] = 0x5E861F, - ["gText_DefaultNameBrett"] = 0x5E85D8, - ["gText_DefaultNameCamila"] = 0x5E8644, - ["gText_DefaultNameCasey"] = 0x5E85E9, - ["gText_DefaultNameCollin"] = 0x5E85FD, - ["gText_DefaultNameDarren"] = 0x5E85EF, - ["gText_DefaultNameEaston"] = 0x5E85BE, - ["gText_DefaultNameHalie"] = 0x5E868C, - ["gText_DefaultNameHazel"] = 0x5E8659, - ["gText_DefaultNameJaxson"] = 0x5E85B7, - ["gText_DefaultNameJayla"] = 0x5E8625, - ["gText_DefaultNameJohnny"] = 0x5E85D1, - ["gText_DefaultNameJude"] = 0x5E85B2, - ["gText_DefaultNameKenny"] = 0x5E85A7, - ["gText_DefaultNameKimmy"] = 0x5E8613, - ["gText_DefaultNameLandon"] = 0x5E85F6, - ["gText_DefaultNameLianna"] = 0x5E8631, - ["gText_DefaultNameLillie"] = 0x5E867A, - ["gText_DefaultNameLucy"] = 0x5E8687, - ["gText_DefaultNameMilton"] = 0x5E859C, - ["gText_DefaultNameMonica"] = 0x5E863D, - ["gText_DefaultNameNadine"] = 0x5E865F, - ["gText_DefaultNameNicola"] = 0x5E8673, - ["gText_DefaultNameQuincy"] = 0x5E860C, - ["gText_DefaultNameReid"] = 0x5E85AD, - ["gText_DefaultNameRuthie"] = 0x5E8652, - ["gText_DefaultNameSara"] = 0x5E8638, - ["gText_DefaultNameSeth"] = 0x5E85DE, - ["gText_DefaultNameStanley"] = 0x5E8604, - ["gText_DefaultNameStu"] = 0x5E8598, - ["gText_DefaultNameTanja"] = 0x5E8666, - ["gText_DefaultNameTerra"] = 0x5E8681, - ["gText_DefaultNameTerry"] = 0x5E85E3, - ["gText_DefaultNameTeru"] = 0x5E85CC, - ["gText_DefaultNameTiara"] = 0x5E8619, - ["gText_DefaultNameTom"] = 0x5E85A3, - ["gText_DefaultNameWalker"] = 0x5E85C5, - ["gText_DefaultNameYasmin"] = 0x5E866C, - ["gText_Defense"] = 0x5EEA55, - ["gText_Defense3"] = 0x5EA343, - ["gText_DefenseCurl"] = 0x27D4D5, - ["gText_DefenseCurl16BP"] = 0x5ECEBA, - ["gText_DelAll"] = 0x5EFCB5, - ["gText_DelRegist"] = 0x5EA79D, - ["gText_Dennis"] = 0x5EAB1E, - ["gText_DepositHowManyVar1"] = 0x5E8E42, - ["gText_DepositInWhichBox"] = 0x5EB866, - ["gText_DepositItem"] = 0x5EAB56, - ["gText_DepositMonDescription"] = 0x5EBBDE, - ["gText_DepositPokemon"] = 0x5EBB82, - ["gText_DepositedVar2Var1s"] = 0x5E8E5A, - ["gText_Deselect"] = 0x5EEC24, - ["gText_Desk"] = 0x5EA859, - ["gText_Detail"] = 0x5EBE7D, - ["gText_Dewford"] = 0x5EAD5F, - ["gText_DexEmptyString"] = 0x5E8B25, - ["gText_DexHoenn"] = 0x5EE84F, - ["gText_DexHoennDescription"] = 0x5E89A4, - ["gText_DexHoennTitle"] = 0x5E88DF, - ["gText_DexNatDescription"] = 0x5E89BB, - ["gText_DexNatTitle"] = 0x5E88E9, - ["gText_DexNational"] = 0x5EE855, - ["gText_DexSearchAlphaABC"] = 0x5E8948, - ["gText_DexSearchAlphaDEF"] = 0x5E894C, - ["gText_DexSearchAlphaGHI"] = 0x5E8950, - ["gText_DexSearchAlphaJKL"] = 0x5E8954, - ["gText_DexSearchAlphaMNO"] = 0x5E8958, - ["gText_DexSearchAlphaPQR"] = 0x5E895C, - ["gText_DexSearchAlphaSTU"] = 0x5E8960, - ["gText_DexSearchAlphaVWX"] = 0x5E8964, - ["gText_DexSearchAlphaYZ"] = 0x5E8968, - ["gText_DexSearchColorBlack"] = 0x5E8981, - ["gText_DexSearchColorBlue"] = 0x5E896F, - ["gText_DexSearchColorBrown"] = 0x5E8987, - ["gText_DexSearchColorGray"] = 0x5E8994, - ["gText_DexSearchColorGreen"] = 0x5E897B, - ["gText_DexSearchColorPink"] = 0x5E899F, - ["gText_DexSearchColorPurple"] = 0x5E898D, - ["gText_DexSearchColorRed"] = 0x5E896B, - ["gText_DexSearchColorWhite"] = 0x5E8999, - ["gText_DexSearchColorYellow"] = 0x5E8974, - ["gText_DexSearchDontSpecify"] = 0x5E8B26, - ["gText_DexSearchTypeNone"] = 0x5E8B35, - ["gText_DexSortAtoZDescription"] = 0x5E8A02, - ["gText_DexSortAtoZTitle"] = 0x5E8905, - ["gText_DexSortHeaviestDescription"] = 0x5E8A37, - ["gText_DexSortHeaviestTitle"] = 0x5E8911, - ["gText_DexSortLightestDescription"] = 0x5E8A73, - ["gText_DexSortLightestTitle"] = 0x5E891F, - ["gText_DexSortNumericalDescription"] = 0x5E89D4, - ["gText_DexSortNumericalTitle"] = 0x5E88F6, - ["gText_DexSortSmallestDescription"] = 0x5E8AEA, - ["gText_DexSortSmallestTitle"] = 0x5E893A, - ["gText_DexSortTallestDescription"] = 0x5E8AAF, - ["gText_DexSortTallestTitle"] = 0x5E892D, - ["gText_DidntDoAsWellAsMonBefore"] = 0x27E1BB, - ["gText_DifferentSaveFile"] = 0x2C8845, - ["gText_DiplomaEmpty"] = 0x5EE8EA, - ["gText_Dissatisfied"] = 0x5EAF3E, - ["gText_DittoDoll48BP"] = 0x5ECC53, - ["gText_DoWhatWithItem"] = 0x5E9F42, - ["gText_DoWhatWithMail"] = 0x5E9FF9, - ["gText_DoWhatWithPokemon"] = 0x5E9EA6, - ["gText_DodrioBerryPicking"] = 0x5EB3B1, - ["gText_Doll"] = 0x5EA87E, - ["gText_DontHave"] = 0x5EA0DC, - ["gText_DontHaveCardNewOneInput"] = 0x5EEEF0, - ["gText_DontHaveNewsNewOneInput"] = 0x5EEF2C, - ["gText_DontRemember"] = 0x5EB7DB, - ["gText_DontRemoveCableTurnOff"] = 0x5EE7CE, - ["gText_DoorOpenedFarAway"] = 0x27301B, - ["gText_Dot"] = 0x5EB5F7, - ["gText_Double"] = 0x5ECB6B, - ["gText_Double2"] = 0x5EB1FA, - ["gText_DoubleBattle"] = 0x5EAE7C, - ["gText_DoubleBattleHallResults"] = 0x5EE241, - ["gText_DoubleBattleRoomResults"] = 0x5EE178, - ["gText_DoubleBattleTourneyResults"] = 0x5EE1FE, - ["gText_DoubleKO"] = 0x5EB4A3, - ["gText_Draw"] = 0x5CCBAD, - ["gText_DreamEater24BP"] = 0x5ECE2A, - ["gText_Dry"] = 0x5E9323, - ["gText_Dry2"] = 0x5ECB4E, - ["gText_E_Mode"] = 0x5EB24E, - ["gText_EasyChatKeyboard_ABCDEFothers"] = 0x62B810, - ["gText_EasyChatKeyboard_GHIJKL"] = 0x62B832, - ["gText_EasyChatKeyboard_MNOPQRS"] = 0x62B84B, - ["gText_EasyChatKeyboard_TUVWXYZ"] = 0x62B86C, - ["gText_EditedTextWillNotBeSaved"] = 0x5EC457, - ["gText_EggAboutToHatch"] = 0x5EA4A2, - ["gText_EggCantBattle"] = 0x5E97E5, - ["gText_EggCantBeTradedNow"] = 0x5EA234, - ["gText_EggFromHotSprings"] = 0x5EA6D7, - ["gText_EggFromTraveler"] = 0x5EA702, - ["gText_EggNickname"] = 0x5E8264, - ["gText_EggWillHatchSoon"] = 0x5EA475, - ["gText_EggWillTakeALongTime"] = 0x5EA40B, - ["gText_EggWillTakeSomeTime"] = 0x5EA442, - ["gText_ElevatorNowOn"] = 0x5EB6A5, - ["gText_EliteFourMatchCallDesc"] = 0x5EFBB5, - ["gText_EllipsisQuestionMark"] = 0x5CC9EE, - ["gText_EmptyContestString"] = 0x27D830, - ["gText_EmptyHeight"] = 0x5E86FB, - ["gText_EmptyLinkService"] = 0x5EB3A3, - ["gText_EmptyPkmnCategory"] = 0x5E86DC, - ["gText_EmptyPokedexInfo1"] = 0x5E871A, - ["gText_EmptyPokedexInfo2"] = 0x5E8722, - ["gText_EmptySpace"] = 0x5E8B3C, - ["gText_EmptySpace2"] = 0x5ED22C, - ["gText_EmptyString2"] = 0x5E8D03, - ["gText_EmptyString3"] = 0x5CC23E, - ["gText_EmptyString5"] = 0x5EA3C0, - ["gText_EmptyString6"] = 0x5ECF8E, - ["gText_EmptyString7"] = 0x5ED931, - ["gText_EmptyString8"] = 0x5EA364, - ["gText_EmptyTextInput1"] = 0x62BA71, - ["gText_EmptyTextInput2"] = 0x62BA72, - ["gText_EmptyTextInput3"] = 0x62BA73, - ["gText_EmptyTextInput4"] = 0x62BA74, - ["gText_EmptyTextInput5"] = 0x62BA75, - ["gText_EmptyTextInput6"] = 0x62BA76, - ["gText_EmptyTextInput7"] = 0x62BA77, - ["gText_EmptyTextInput8"] = 0x62BA78, - ["gText_EmptyWeight"] = 0x5E8709, - ["gText_EndedWithXUnitsPowder"] = 0x5ED65C, - ["gText_Endure48BP"] = 0x5ECF08, - ["gText_EnergyPowder50"] = 0x5EB6B0, - ["gText_EnergyRoot80"] = 0x5EB6C5, - ["gText_EnjoyCycling"] = 0x5EA046, - ["gText_Enter"] = 0x5E96C4, - ["gText_Enter2"] = 0x5EAD67, - ["gText_EntireTextCantBeDeleted"] = 0x5EC4A7, - ["gText_EscapeFromHere"] = 0x5EA16B, - ["gText_EventSafelyLoaded"] = 0x5EE80A, - ["gText_Events"] = 0x5EA3C1, - ["gText_EveryonesAppealsMadeSame"] = 0x27DB4E, - ["gText_EveryonesAppealsMadeToLookSame"] = 0x27D8B5, - ["gText_Excellent"] = 0x5ECB2A, - ["gText_Excellent2"] = 0x5EB02A, - ["gText_ExcellentsInARow"] = 0x5EDEBE, - ["gText_ExchangeService"] = 0x5EB78A, - ["gText_ExecuteSearchSwitch"] = 0x5E88C8, - ["gText_Exit"] = 0x5EB7EA, - ["gText_Exit2"] = 0x5ED260, - ["gText_Exit3"] = 0x5EEE28, - ["gText_Exit4"] = 0x5EF883, - ["gText_ExitFromBox"] = 0x5EB7EF, - ["gText_ExitingChat"] = 0x5ED2E8, - ["gText_ExpPoints"] = 0x5EA39F, - ["gText_ExpandedPlaceholder_Aqua"] = 0x5E8231, - ["gText_ExpandedPlaceholder_Archie"] = 0x5E823C, - ["gText_ExpandedPlaceholder_Brendan"] = 0x5E8258, - ["gText_ExpandedPlaceholder_Chan"] = 0x5E821A, - ["gText_ExpandedPlaceholder_Emerald"] = 0x5E8229, - ["gText_ExpandedPlaceholder_Empty"] = 0x5E8218, - ["gText_ExpandedPlaceholder_Groudon"] = 0x5E8250, - ["gText_ExpandedPlaceholder_Kun"] = 0x5E8219, - ["gText_ExpandedPlaceholder_Kyogre"] = 0x5E8249, - ["gText_ExpandedPlaceholder_Magma"] = 0x5E8236, - ["gText_ExpandedPlaceholder_Maxie"] = 0x5E8243, - ["gText_ExpandedPlaceholder_May"] = 0x5E8260, - ["gText_ExpandedPlaceholder_Ruby"] = 0x5E8224, - ["gText_ExpandedPlaceholder_Sapphire"] = 0x5E821B, - ["gText_ExpertTagMatch"] = 0x5EB361, - ["gText_F700JoinedChat"] = 0x5ED2A9, - ["gText_F700LeftChat"] = 0x5ED2BD, - ["gText_F700Players"] = 0x5EEDB0, - ["gText_F700sQuiz"] = 0x5EC6A8, - ["gText_F701Players"] = 0x5EEDBB, - ["gText_F702Players"] = 0x5EEDC6, - ["gText_F703Players"] = 0x5EEDD1, - ["gText_Facility"] = 0x5ED190, - ["gText_FacilityDouble"] = 0x5ED170, - ["gText_FacilityLink"] = 0x5ED188, - ["gText_FacilityMulti"] = 0x5ED17C, - ["gText_FacilitySingle"] = 0x5ED164, - ["gText_FansQuestion"] = 0x5EC636, - ["gText_FarawayIsland"] = 0x5EB2CA, - ["gText_FavorLady_Pointy"] = 0x5EEBAB, - ["gText_FavorLady_Roundish"] = 0x5EEB8C, - ["gText_FavorLady_Shiny"] = 0x5EEB9E, - ["gText_FavorLady_Slippery"] = 0x5EEB83, - ["gText_FavorLady_Sticky"] = 0x5EEBA4, - ["gText_FavorLady_Whamish"] = 0x5EEB95, - ["gText_Feel"] = 0x5E933F, - ["gText_FeelingsRightNow"] = 0x5EC0F0, - ["gText_FemaleSymbol"] = 0x5E8D4B, - ["gText_FemaleSymbol4"] = 0x32DAC9, - ["gText_Ferry"] = 0x5EC9D2, - ["gText_FilledStorageSpace"] = 0x5EDDFB, - ["gText_FilledStorageSpace2"] = 0x5EDEF4, - ["gText_FindBeautifulPokemon"] = 0x5EBDEE, - ["gText_FindCoolPokemon"] = 0x5EBDDB, - ["gText_FindCutePokemon"] = 0x5EBE06, - ["gText_FindSmartPokemon"] = 0x5EBE19, - ["gText_FindToughPokemon"] = 0x5EBE2D, - ["gText_FindWordsThatDescribeYour"] = 0x5EC0D2, - ["gText_FindWordsWhichFit"] = 0x5EC2CA, - ["gText_FirePunch48BP"] = 0x5ECF4B, - ["gText_FirmSlash"] = 0x5E922C, - ["gText_First"] = 0x5ECB7A, - ["gText_FirstPlacePrize"] = 0x5EDDBD, - ["gText_First_PM"] = 0x5EA09E, - ["gText_FiveMarks"] = 0x5E8D38, - ["gText_FlanneryMatchCallDesc"] = 0x5EFB87, - ["gText_Floor1"] = 0x5EE728, - ["gText_Floor2"] = 0x5EE730, - ["gText_Floor3"] = 0x5EE738, - ["gText_Floor4"] = 0x5EE740, - ["gText_Floor5"] = 0x5EE748, - ["gText_Floor6"] = 0x5EE750, - ["gText_Floor7"] = 0x5EE758, - ["gText_FloorsCleared"] = 0x5EE39C, - ["gText_FlyToWhere"] = 0x5E8CB4, - ["gText_FocusBand64BP"] = 0x5ECDE0, - ["gText_FocusEnergy"] = 0x27D478, - ["gText_Fog"] = 0x5EFC6A, - ["gText_Fog2"] = 0x5EFC84, - ["gText_FollowedAnotherMonsLead"] = 0x27D5DC, - ["gText_FourEllipsesExclamation"] = 0x5EB9A1, - ["gText_Fourth"] = 0x5EA0E7, - ["gText_Frame"] = 0x5EE5BB, - ["gText_FrameType"] = 0x5EE635, - ["gText_FrameTypeNumber"] = 0x5EE640, - ["gText_FreshWaterAndPrice"] = 0x5EAEC3, - ["gText_Friend"] = 0x5EDFB0, - ["gText_Friend2"] = 0x5EEE20, - ["gText_FromSpace"] = 0x5EEA78, - ["gText_FrontierFacilityClearStreak"] = 0x5EE3D4, - ["gText_FrontierFacilityFloorsCleared"] = 0x5EE410, - ["gText_FrontierFacilityKOsStreak"] = 0x5EE3FC, - ["gText_FrontierFacilityRoomsCleared"] = 0x5EE3E8, - ["gText_FrontierFacilityWinStreak"] = 0x5EE3C4, - ["gText_Frz"] = 0x5EAE41, - ["gText_G_Mode"] = 0x5EB255, - ["gText_Gabby"] = 0x5EFA84, - ["gText_GamePlayCannotBeContinued"] = 0x5EC8D4, - ["gText_GameTime"] = 0x5EE6A3, - ["gText_GenderlessSymbol"] = 0x32DACB, - ["gText_GetsAPokeBlockQuestion"] = 0x5EC7A0, - ["gText_GiftSentTo"] = 0x5EF410, - ["gText_Give"] = 0x5ED193, - ["gText_Give2"] = 0x5EAC46, - ["gText_GiveToAPkmn"] = 0x5EB9C3, - ["gText_GiveToWhichPokemon"] = 0x5E9E8F, - ["gText_Glacia"] = 0x5EAD30, - ["gText_GlassChair"] = 0x5EAF9F, - ["gText_GlassDesk"] = 0x5EAFAB, - ["gText_GoBackPrevMenu"] = 0x5E8D62, - ["gText_GoOn"] = 0x5EB5B6, - ["gText_Gold"] = 0x5EA88B, - ["gText_GoldCard"] = 0x5EE685, - ["gText_GoldPokeblock"] = 0x5E9312, - ["gText_Good"] = 0x5ECB1B, - ["gText_GoodSaying"] = 0x5EC62A, - ["gText_GrayPokeblock"] = 0x5E92EF, - ["gText_GreatAppealButNoMoreToEnd"] = 0x27CBCE, - ["gText_GreenPokeblock"] = 0x5E9295, - ["gText_GreenShard"] = 0x5EB062, - ["gText_GretaDefeatGold"] = 0x2C8611, - ["gText_GretaDefeatSilver"] = 0x2C85E3, - ["gText_GretaWonGold"] = 0x2C85F5, - ["gText_GretaWonSilver"] = 0x2C85B4, - ["gText_HMMovesCantBeForgotten2"] = 0x5EA4CB, - ["gText_HOFCorrupted"] = 0x5E8BDC, - ["gText_HOFDexRating"] = 0x5E8B5B, - ["gText_HOFDexSaving"] = 0x5E8BBA, - ["gText_HOFNumber"] = 0x5E8C00, - ["gText_HP3"] = 0x5E96E3, - ["gText_HP4"] = 0x5EA361, - ["gText_HPUp1000"] = 0x5EB75B, - ["gText_HTHeight"] = 0x5E8755, - ["gText_HallOfFame"] = 0x5EB18B, - ["gText_HallOfFameDebut"] = 0x5ECFB8, - ["gText_HatchedFromEgg"] = 0x5ED5A5, - ["gText_Have"] = 0x5EA0D7, - ["gText_HaventReceivedCardsGift"] = 0x5EF084, - ["gText_HaventReceivedGiftOkayToDiscard"] = 0x5EF584, - ["gText_HealPowder50"] = 0x5EB6D9, - ["gText_HealthboxGender_Female"] = 0x5ED233, - ["gText_HealthboxGender_Male"] = 0x5ED22E, - ["gText_HealthboxGender_None"] = 0x5ED238, - ["gText_HealthboxNickname"] = 0x5ED228, - ["gText_HeavyRain"] = 0x5EFCA0, - ["gText_HeldItem"] = 0x5EB21D, - ["gText_HeldItems"] = 0x5EB57E, - ["gText_Hello"] = 0x5ED40F, - ["gText_HereYouGoThankYou"] = 0x5E94C5, - ["gText_Hideout"] = 0x5EC9E4, - ["gText_HighlyAppealingMove"] = 0x27CB82, - ["gText_Hoenn"] = 0x5EE8F1, - ["gText_HoldItems"] = 0x5EB40A, - ["gText_HornAttack"] = 0x27D499, - ["gText_HowManyToSell"] = 0x5E962F, - ["gText_HowMayIServeYou"] = 0x272A21, - ["gText_HowToRide"] = 0x5EAEF6, - ["gText_HowToTurn"] = 0x5EAF02, - ["gText_HpUp1BP"] = 0x5ECD52, - ["gText_Hyper"] = 0x5EB112, - ["gText_HyperRank"] = 0x5EB1DC, - ["gText_Hypnosis"] = 0x27D485, - ["gText_ICanPayVar1"] = 0x5E9654, - ["gText_IDNumber"] = 0x5E8C4D, - ["gText_IDNumber2"] = 0x5E8D59, - ["gText_ILost"] = 0x5EB317, - ["gText_IWon"] = 0x5EB310, - ["gText_IWontTell"] = 0x5EB31F, - ["gText_Ice"] = 0x5CCB66, - ["gText_IcePunch48BP"] = 0x5ECF26, - ["gText_IcyWind24BP"] = 0x5ECEF8, - ["gText_IdNumberSlash"] = 0x5E8C40, - ["gText_IfLeaderLeavesChatEnds"] = 0x5ED3D8, - ["gText_IfThrowAwayCardEventWontHappen"] = 0x5EF520, - ["gText_IllBattleNow"] = 0x5EB2FF, - ["gText_IllTakeYourPkmn"] = 0x272768, - ["gText_IllTakeYourPkmn2"] = 0x2729C0, - ["gText_InBagVar1"] = 0x5E93D5, - ["gText_InGameClockUsable"] = 0x5ECAD8, - ["gText_InParty"] = 0x5EBFCE, - ["gText_InUseAlready"] = 0x5EA8D2, - ["gText_InUseAlready_PM"] = 0x5EA05B, - ["gText_IndigoPokeblock"] = 0x5E92BB, - ["gText_Info"] = 0x5EA406, - ["gText_Info2"] = 0x5EAD6D, - ["gText_Info3"] = 0x5EAEAC, - ["gText_Info4"] = 0x5EAE69, - ["gText_Information"] = 0x5EAE1B, - ["gText_InputText"] = 0x5ED29D, - ["gText_Interview"] = 0x5EC620, - ["gText_Iron1000"] = 0x5EB716, - ["gText_Iron1BP"] = 0x5ECD2F, - ["gText_IsAsShownOkay"] = 0x5EC27E, - ["gText_IsThatOkay"] = 0x5EC47A, - ["gText_IsThisQuizOK"] = 0x5EC732, - ["gText_IsThisTheCorrectTime"] = 0x5EFAC6, - ["gText_ItGotAway"] = 0x5EE933, - ["gText_Item"] = 0x5E8D0B, - ["gText_ItemFinderNearby"] = 0x5E8F93, - ["gText_ItemFinderNothing"] = 0x5E9002, - ["gText_ItemFinderOnTop"] = 0x5E8FDB, - ["gText_ItemIsNowHeld"] = 0x5EBA19, - ["gText_ItemStorage"] = 0x5EAB41, - ["gText_ItemThrownAway"] = 0x5E9E14, - ["gText_Items"] = 0x5EB13C, - ["gText_ItemsPocket"] = 0x5E91CE, - ["gText_Jackpot"] = 0x5ECB72, - ["gText_Jam"] = 0x5EA394, - ["gText_JamOthersMissesTurn"] = 0x27CEBE, - ["gText_JammedAppealsMadeEvenLessNoticeable"] = 0x27DB1F, - ["gText_John"] = 0x5EFA7B, - ["gText_JoinGroup"] = 0x5EB3D4, - ["gText_JuanMatchCallDesc"] = 0x5EFBA9, - ["gText_JudgeBody"] = 0x5EB482, - ["gText_JudgeLookedAtMonExpectantly"] = 0x27E2FE, - ["gText_JudgeLookedAwayForSomeReason"] = 0x27D62D, - ["gText_JudgeLookedAwayFromMon"] = 0x27DD12, - ["gText_JudgeMind"] = 0x5EB469, - ["gText_JudgeSkill"] = 0x5EB475, - ["gText_JudgedLookedOnExpectantly"] = 0x27D6E5, - ["gText_JudgesViewsOnMonHeldFirm"] = 0x27D831, - ["gText_Judging"] = 0x5EAD9C, - ["gText_Jump"] = 0x5EAF2F, - ["gText_JumpToWhichBox"] = 0x5EB853, - ["gText_JumpsInARow"] = 0x5EDEA2, - ["gText_JustOnePkmn"] = 0x5EBC89, - ["gText_KOsInARow"] = 0x5EE381, - ["gText_KeyItemsPocket"] = 0x5E91F1, - ["gText_Key_Items"] = 0x5EB142, - ["gText_KingsRock64BP"] = 0x5ECDCD, - ["gText_Kira"] = 0x5EFA72, - ["gText_KissCushion32BP"] = 0x5ECBEF, - ["gText_KissPoster16BP"] = 0x5ECBDC, - ["gText_LButton"] = 0x5E84F5, - ["gText_LButtonRButton"] = 0x5E8519, - ["gText_Lady"] = 0x5EC6B2, - ["gText_LanettesPC"] = 0x5EB176, - ["gText_LaprasDoll128BP"] = 0x5ECCA6, - ["gText_LargelyUnnoticedMonWorkedHard"] = 0x27DE73, - ["gText_LeaderLeftEndingChat"] = 0x5ED2FA, - ["gText_LeagueChamp"] = 0x5E8C14, - ["gText_Learned"] = 0x5EA0CF, - ["gText_Left"] = 0x5EB084, - ["gText_Leftovers48BP"] = 0x5ECD5E, - ["gText_LegendaryFlewAway"] = 0x273204, - ["gText_LemonadeAndPrice"] = 0x5EAEE6, - ["gText_Lets"] = 0x5ED42A, - ["gText_LetsReplyToTheInterview"] = 0x5EC3F2, - ["gText_Level"] = 0x5E8C3B, - ["gText_LevelSymbol"] = 0x5E8D4D, - ["gText_Lightning"] = 0x5EFC60, - ["gText_LikeToQuitQuiz"] = 0x5EC706, - ["gText_Lilycove"] = 0x5EAD56, - ["gText_LilycoveCity"] = 0x5EB79B, - ["gText_LineBreak"] = 0x5CCB84, - ["gText_LinkBattles"] = 0x5ECFCD, - ["gText_LinkCableBattles"] = 0x5ECFDA, - ["gText_LinkContest"] = 0x5EB227, - ["gText_LinkContestResults"] = 0x5EDF86, - ["gText_LinkMultiBattleRoomResults"] = 0x5EE1B7, - ["gText_LinkStandby"] = 0x5CCA29, - ["gText_LinkStandby2"] = 0x5EE765, - ["gText_LinkStandby4"] = 0x27E8DA, - ["gText_LinkTVProgramWillNotBeMadeTrainerLost"] = 0x27E9CD, - ["gText_ListByBodyColor"] = 0x5E887C, - ["gText_ListByFirstLetter"] = 0x5E8840, - ["gText_ListByType"] = 0x5E88A6, - ["gText_LiteBluePokeblock"] = 0x5E92D4, - ["gText_Littleroot"] = 0x5EAD4B, - ["gText_LoadErrorEndingSession"] = 0x5EE827, - ["gText_LoadingEvent"] = 0x5EE7BF, - ["gText_LockOn"] = 0x27D4E2, - ["gText_LogOff"] = 0x5EB198, - ["gText_Loss"] = 0x5CCBA5, - ["gText_Love"] = 0x5CCB74, - ["gText_Lower"] = 0x5ED242, - ["gText_LucyDefeatGold"] = 0x2C85AE, - ["gText_LucyDefeatSilver"] = 0x2C85A4, - ["gText_LucyWonGold"] = 0x2C85A9, - ["gText_LucyWonSilver"] = 0x2C859D, - ["gText_Lv"] = 0x5EF888, - ["gText_Lv50"] = 0x5EAEB1, - ["gText_Lv502"] = 0x5EE2F2, - ["gText_Lv50_3"] = 0x5EB3EA, - ["gText_LvVar1"] = 0x5E934F, - ["gText_LyricsCantBeDeleted"] = 0x5EC765, - ["gText_Mach"] = 0x5EAE27, - ["gText_Mail"] = 0x5E8D10, - ["gText_MailCantBeStored"] = 0x5EBA38, - ["gText_MailMessageWillBeLost"] = 0x5E98B4, - ["gText_MailSentToPC"] = 0x5E9877, - ["gText_MailTakenFromPkmn"] = 0x5E99B8, - ["gText_MailToBagMessageErased"] = 0x5EACC8, - ["gText_MailTransferredFromMailbox"] = 0x5E9A40, - ["gText_Mailbox"] = 0x5EAB4E, - ["gText_MainMenuContinue"] = 0x5E8285, - ["gText_MainMenuMysteryEvents"] = 0x5E82AF, - ["gText_MainMenuMysteryGift"] = 0x5E8295, - ["gText_MainMenuMysteryGift2"] = 0x5E82A2, - ["gText_MainMenuNewGame"] = 0x5E827C, - ["gText_MainMenuOption"] = 0x5E828E, - ["gText_MakeAllMonsAfterUserNervous"] = 0x27D037, - ["gText_MakeMonAfterUserNervous"] = 0x27D00D, - ["gText_MaleSymbol"] = 0x5E8D49, - ["gText_MaleSymbol4"] = 0x32DAC7, - ["gText_Marco"] = 0x5ECF6B, - ["gText_MarkYourPkmn"] = 0x5EB8D3, - ["gText_Master"] = 0x5EB118, - ["gText_MasterRank"] = 0x5EB1E7, - ["gText_Mat"] = 0x5EA873, - ["gText_MatchCallAromaLady_Rose_Intro1"] = 0x6203C2, - ["gText_MatchCallAromaLady_Rose_Intro2"] = 0x6203DB, - ["gText_MatchCallAromaLady_Rose_Pokemon"] = 0x6203AA, - ["gText_MatchCallAromaLady_Rose_Strategy"] = 0x620390, - ["gText_MatchCallBattleGirl_Cyndy_Intro1"] = 0x621499, - ["gText_MatchCallBattleGirl_Cyndy_Intro2"] = 0x6214AD, - ["gText_MatchCallBattleGirl_Cyndy_Pokemon"] = 0x621483, - ["gText_MatchCallBattleGirl_Cyndy_Strategy"] = 0x62146C, - ["gText_MatchCallBeauty_Jessica_Intro1"] = 0x62081B, - ["gText_MatchCallBeauty_Jessica_Intro2"] = 0x620833, - ["gText_MatchCallBeauty_Jessica_Pokemon"] = 0x62080A, - ["gText_MatchCallBeauty_Jessica_Strategy"] = 0x6207F4, - ["gText_MatchCallBeauty_Thalia_Intro1"] = 0x6207BC, - ["gText_MatchCallBeauty_Thalia_Intro2"] = 0x6207D7, - ["gText_MatchCallBeauty_Thalia_Pokemon"] = 0x6207A9, - ["gText_MatchCallBeauty_Thalia_Strategy"] = 0x62078D, - ["gText_MatchCallBirdKeeper_Robert_Intro1"] = 0x6213DC, - ["gText_MatchCallBirdKeeper_Robert_Intro2"] = 0x6213F8, - ["gText_MatchCallBirdKeeper_Robert_Pokemon"] = 0x6213C2, - ["gText_MatchCallBirdKeeper_Robert_Strategy"] = 0x6213A6, - ["gText_MatchCallBlackBelt_Koji_Intro1"] = 0x6209E3, - ["gText_MatchCallBlackBelt_Koji_Intro2"] = 0x6209FD, - ["gText_MatchCallBlackBelt_Koji_Pokemon"] = 0x6209CA, - ["gText_MatchCallBlackBelt_Koji_Strategy"] = 0x6209B4, - ["gText_MatchCallBlackBelt_Nob_Intro1"] = 0x620988, - ["gText_MatchCallBlackBelt_Nob_Intro2"] = 0x6209A4, - ["gText_MatchCallBlackBelt_Nob_Pokemon"] = 0x620979, - ["gText_MatchCallBlackBelt_Nob_Strategy"] = 0x620963, - ["gText_MatchCallBrendan_Intro1"] = 0x5ED516, - ["gText_MatchCallBrendan_Intro2"] = 0x5ED52F, - ["gText_MatchCallBrendan_Pokemon"] = 0x5ED4FA, - ["gText_MatchCallBrendan_Strategy"] = 0x5ED4E3, - ["gText_MatchCallBugCatcher_James_Intro1"] = 0x62199D, - ["gText_MatchCallBugCatcher_James_Intro2"] = 0x6219B6, - ["gText_MatchCallBugCatcher_James_Pokemon"] = 0x621983, - ["gText_MatchCallBugCatcher_James_Strategy"] = 0x62196C, - ["gText_MatchCallBugManiac_Jeffrey_Intro1"] = 0x620C19, - ["gText_MatchCallBugManiac_Jeffrey_Intro2"] = 0x620C34, - ["gText_MatchCallBugManiac_Jeffrey_Pokemon"] = 0x620C03, - ["gText_MatchCallBugManiac_Jeffrey_Strategy"] = 0x620BF2, - ["gText_MatchCallCamper_Ethan_Intro1"] = 0x620B64, - ["gText_MatchCallCamper_Ethan_Intro2"] = 0x620B7F, - ["gText_MatchCallCamper_Ethan_Pokemon"] = 0x620B4C, - ["gText_MatchCallCamper_Ethan_Strategy"] = 0x620B32, - ["gText_MatchCallChampion_Wallace_Intro1"] = 0x621FF9, - ["gText_MatchCallChampion_Wallace_Intro2"] = 0x62200F, - ["gText_MatchCallChampion_Wallace_Pokemon"] = 0x621FDE, - ["gText_MatchCallChampion_Wallace_Strategy"] = 0x621FC9, - ["gText_MatchCallCollector_Edwin_Intro1"] = 0x621710, - ["gText_MatchCallCollector_Edwin_Intro2"] = 0x62172A, - ["gText_MatchCallCollector_Edwin_Pokemon"] = 0x6216FB, - ["gText_MatchCallCollector_Edwin_Strategy"] = 0x6216E0, - ["gText_MatchCallCooltrainer_Brooke_Intro1"] = 0x620668, - ["gText_MatchCallCooltrainer_Brooke_Intro2"] = 0x620681, - ["gText_MatchCallCooltrainer_Brooke_Pokemon"] = 0x620654, - ["gText_MatchCallCooltrainer_Brooke_Strategy"] = 0x620638, - ["gText_MatchCallCooltrainer_Cristin_Intro1"] = 0x620612, - ["gText_MatchCallCooltrainer_Cristin_Intro2"] = 0x62062F, - ["gText_MatchCallCooltrainer_Cristin_Pokemon"] = 0x6205F8, - ["gText_MatchCallCooltrainer_Cristin_Strategy"] = 0x6205DD, - ["gText_MatchCallCooltrainer_Wilton_Intro1"] = 0x6206BC, - ["gText_MatchCallCooltrainer_Wilton_Intro2"] = 0x6206D5, - ["gText_MatchCallCooltrainer_Wilton_Pokemon"] = 0x6206A7, - ["gText_MatchCallCooltrainer_Wilton_Strategy"] = 0x620693, - ["gText_MatchCallDandyCharm_Juan_Intro1"] = 0x621E00, - ["gText_MatchCallDandyCharm_Juan_Intro2"] = 0x621E1B, - ["gText_MatchCallDandyCharm_Juan_Pokemon"] = 0x621DEB, - ["gText_MatchCallDandyCharm_Juan_Strategy"] = 0x621DD0, - ["gText_MatchCallDragonTamer_Nicolas_Intro1"] = 0x621376, - ["gText_MatchCallDragonTamer_Nicolas_Intro2"] = 0x621393, - ["gText_MatchCallDragonTamer_Nicolas_Pokemon"] = 0x62135C, - ["gText_MatchCallDragonTamer_Nicolas_Strategy"] = 0x621342, - ["gText_MatchCallEliteFour_Drake_Intro1"] = 0x621F99, - ["gText_MatchCallEliteFour_Drake_Intro2"] = 0x621FB2, - ["gText_MatchCallEliteFour_Drake_Pokemon"] = 0x621F7F, - ["gText_MatchCallEliteFour_Drake_Strategy"] = 0x621F65, - ["gText_MatchCallEliteFour_Glacia_Intro1"] = 0x621F30, - ["gText_MatchCallEliteFour_Glacia_Intro2"] = 0x621F4B, - ["gText_MatchCallEliteFour_Glacia_Pokemon"] = 0x621F13, - ["gText_MatchCallEliteFour_Glacia_Strategy"] = 0x621EFD, - ["gText_MatchCallEliteFour_Phoebe_Intro1"] = 0x621ECF, - ["gText_MatchCallEliteFour_Phoebe_Intro2"] = 0x621EEA, - ["gText_MatchCallEliteFour_Phoebe_Pokemon"] = 0x621EB5, - ["gText_MatchCallEliteFour_Phoebe_Strategy"] = 0x621E9F, - ["gText_MatchCallEliteFour_Sidney_Intro1"] = 0x621E68, - ["gText_MatchCallEliteFour_Sidney_Intro2"] = 0x621E84, - ["gText_MatchCallEliteFour_Sidney_Pokemon"] = 0x621E4E, - ["gText_MatchCallEliteFour_Sidney_Strategy"] = 0x621E38, - ["gText_MatchCallExpert_Shelby_Intro1"] = 0x620FCD, - ["gText_MatchCallExpert_Shelby_Intro2"] = 0x620FE6, - ["gText_MatchCallExpert_Shelby_Pokemon"] = 0x620FBA, - ["gText_MatchCallExpert_Shelby_Strategy"] = 0x620FA2, - ["gText_MatchCallExpert_Timothy_Intro1"] = 0x620F76, - ["gText_MatchCallExpert_Timothy_Intro2"] = 0x620F90, - ["gText_MatchCallExpert_Timothy_Pokemon"] = 0x620F5D, - ["gText_MatchCallExpert_Timothy_Strategy"] = 0x620F43, - ["gText_MatchCallFisherman_Elliot_Intro1"] = 0x621083, - ["gText_MatchCallFisherman_Elliot_Intro2"] = 0x62109F, - ["gText_MatchCallFisherman_Elliot_Pokemon"] = 0x62106A, - ["gText_MatchCallFisherman_Elliot_Strategy"] = 0x621056, - ["gText_MatchCallGentleman_Walter_Intro1"] = 0x620D32, - ["gText_MatchCallGentleman_Walter_Intro2"] = 0x620D49, - ["gText_MatchCallGentleman_Walter_Pokemon"] = 0x620D1A, - ["gText_MatchCallGentleman_Walter_Strategy"] = 0x620D06, - ["gText_MatchCallGuitarist_Dalton_Intro1"] = 0x620A9E, - ["gText_MatchCallGuitarist_Dalton_Intro2"] = 0x620AB8, - ["gText_MatchCallGuitarist_Dalton_Pokemon"] = 0x620A8C, - ["gText_MatchCallGuitarist_Dalton_Strategy"] = 0x620A78, - ["gText_MatchCallGuitarist_Fernando_Intro1"] = 0x620A4B, - ["gText_MatchCallGuitarist_Fernando_Intro2"] = 0x620A66, - ["gText_MatchCallGuitarist_Fernando_Pokemon"] = 0x620A31, - ["gText_MatchCallGuitarist_Fernando_Strategy"] = 0x620A18, - ["gText_MatchCallHexManiac_Valerie_Intro1"] = 0x620717, - ["gText_MatchCallHexManiac_Valerie_Intro2"] = 0x620730, - ["gText_MatchCallHexManiac_Valerie_Pokemon"] = 0x6206FF, - ["gText_MatchCallHexManiac_Valerie_Strategy"] = 0x6206E7, - ["gText_MatchCallHiker_Sawyer_Intro1"] = 0x621A41, - ["gText_MatchCallHiker_Sawyer_Intro2"] = 0x621A5C, - ["gText_MatchCallHiker_Sawyer_Pokemon"] = 0x621A34, - ["gText_MatchCallHiker_Sawyer_Strategy"] = 0x621A25, - ["gText_MatchCallHiker_Trent_Intro1"] = 0x6219F8, - ["gText_MatchCallHiker_Trent_Intro2"] = 0x621A13, - ["gText_MatchCallHiker_Trent_Pokemon"] = 0x6219E3, - ["gText_MatchCallHiker_Trent_Strategy"] = 0x6219CE, - ["gText_MatchCallKindler_Bernie_Intro1"] = 0x620AFB, - ["gText_MatchCallKindler_Bernie_Intro2"] = 0x620B16, - ["gText_MatchCallKindler_Bernie_Pokemon"] = 0x620AE4, - ["gText_MatchCallKindler_Bernie_Strategy"] = 0x620AD2, - ["gText_MatchCallLady_Cindy_Intro1"] = 0x62075D, - ["gText_MatchCallLady_Cindy_Intro2"] = 0x620779, - ["gText_MatchCallLady_Cindy_Pokemon"] = 0x62074E, - ["gText_MatchCallLady_Cindy_Strategy"] = 0x62073D, - ["gText_MatchCallLass_Haley_Intro1"] = 0x62193C, - ["gText_MatchCallLass_Haley_Intro2"] = 0x621955, - ["gText_MatchCallLass_Haley_Pokemon"] = 0x621921, - ["gText_MatchCallLass_Haley_Strategy"] = 0x621908, - ["gText_MatchCallMay_Intro1"] = 0x5ED579, - ["gText_MatchCallMay_Intro2"] = 0x5ED58F, - ["gText_MatchCallMay_Pokemon"] = 0x5ED563, - ["gText_MatchCallMay_Strategy"] = 0x5ED547, - ["gText_MatchCallMysticDuo_TateAndLiza_Intro1"] = 0x621DA2, - ["gText_MatchCallMysticDuo_TateAndLiza_Intro2"] = 0x621DBB, - ["gText_MatchCallMysticDuo_TateAndLiza_Pokemon"] = 0x621D89, - ["gText_MatchCallMysticDuo_TateAndLiza_Strategy"] = 0x621D6F, - ["gText_MatchCallNinjaBoy_Lao_Intro1"] = 0x621439, - ["gText_MatchCallNinjaBoy_Lao_Intro2"] = 0x621451, - ["gText_MatchCallNinjaBoy_Lao_Pokemon"] = 0x621426, - ["gText_MatchCallNinjaBoy_Lao_Strategy"] = 0x62140B, - ["gText_MatchCallOldCouple_JohnAndJay_Intro1"] = 0x620BC0, - ["gText_MatchCallOldCouple_JohnAndJay_Intro2"] = 0x620BD8, - ["gText_MatchCallOldCouple_JohnAndJay_Pokemon"] = 0x620BA6, - ["gText_MatchCallOldCouple_JohnAndJay_Strategy"] = 0x620B8C, - ["gText_MatchCallParasolLady_Madeline_Intro1"] = 0x6214EF, - ["gText_MatchCallParasolLady_Madeline_Intro2"] = 0x621507, - ["gText_MatchCallParasolLady_Madeline_Pokemon"] = 0x6214DA, - ["gText_MatchCallParasolLady_Madeline_Strategy"] = 0x6214C6, - ["gText_MatchCallPassionBurn_Flannery_Intro1"] = 0x621C6F, - ["gText_MatchCallPassionBurn_Flannery_Intro2"] = 0x621C8A, - ["gText_MatchCallPassionBurn_Flannery_Pokemon"] = 0x621C5C, - ["gText_MatchCallPassionBurn_Flannery_Strategy"] = 0x621C47, - ["gText_MatchCallPicnicker_Diana_Intro1"] = 0x621599, - ["gText_MatchCallPicnicker_Diana_Intro2"] = 0x6215AF, - ["gText_MatchCallPicnicker_Diana_Pokemon"] = 0x621585, - ["gText_MatchCallPicnicker_Diana_Strategy"] = 0x62156A, - ["gText_MatchCallPkmnBreeder_Gabrielle_Intro1"] = 0x621826, - ["gText_MatchCallPkmnBreeder_Gabrielle_Intro2"] = 0x62183E, - ["gText_MatchCallPkmnBreeder_Gabrielle_Pokemon"] = 0x621810, - ["gText_MatchCallPkmnBreeder_Gabrielle_Strategy"] = 0x6217F5, - ["gText_MatchCallPkmnBreeder_Isaac_Intro1"] = 0x6217C2, - ["gText_MatchCallPkmnBreeder_Isaac_Intro2"] = 0x6217D9, - ["gText_MatchCallPkmnBreeder_Isaac_Pokemon"] = 0x6217A9, - ["gText_MatchCallPkmnBreeder_Isaac_Strategy"] = 0x621799, - ["gText_MatchCallPkmnBreeder_Lydia_Intro1"] = 0x62176B, - ["gText_MatchCallPkmnBreeder_Lydia_Intro2"] = 0x621782, - ["gText_MatchCallPkmnBreeder_Lydia_Pokemon"] = 0x621752, - ["gText_MatchCallPkmnBreeder_Lydia_Strategy"] = 0x621740, - ["gText_MatchCallPkmnRanger_Catherine_Intro1"] = 0x621881, - ["gText_MatchCallPkmnRanger_Catherine_Intro2"] = 0x621899, - ["gText_MatchCallPkmnRanger_Catherine_Pokemon"] = 0x62186A, - ["gText_MatchCallPkmnRanger_Catherine_Strategy"] = 0x621851, - ["gText_MatchCallPkmnRanger_Jackson_Intro1"] = 0x6218D7, - ["gText_MatchCallPkmnRanger_Jackson_Intro2"] = 0x6218F0, - ["gText_MatchCallPkmnRanger_Jackson_Pokemon"] = 0x6218C0, - ["gText_MatchCallPkmnRanger_Jackson_Strategy"] = 0x6218AF, - ["gText_MatchCallPkmnTrainer_Wally_Intro1"] = 0x621AF7, - ["gText_MatchCallPkmnTrainer_Wally_Intro2"] = 0x621B10, - ["gText_MatchCallPkmnTrainer_Wally_Pokemon"] = 0x621ADD, - ["gText_MatchCallPkmnTrainer_Wally_Strategy"] = 0x621AC5, - ["gText_MatchCallPokeManiac_Steve_Intro1"] = 0x6208DC, - ["gText_MatchCallPokeManiac_Steve_Intro2"] = 0x6208F1, - ["gText_MatchCallPokeManiac_Steve_Pokemon"] = 0x6208C3, - ["gText_MatchCallPokeManiac_Steve_Strategy"] = 0x6208AA, - ["gText_MatchCallPokefan_Isabel_Intro1"] = 0x620EB5, - ["gText_MatchCallPokefan_Isabel_Intro2"] = 0x620ECC, - ["gText_MatchCallPokefan_Isabel_Pokemon"] = 0x620E98, - ["gText_MatchCallPokefan_Isabel_Strategy"] = 0x620E83, - ["gText_MatchCallPokefan_Miguel_Intro1"] = 0x620F12, - ["gText_MatchCallPokefan_Miguel_Intro2"] = 0x620F2A, - ["gText_MatchCallPokefan_Miguel_Pokemon"] = 0x620EF6, - ["gText_MatchCallPokefan_Miguel_Strategy"] = 0x620EE2, - ["gText_MatchCallPsychic_Cameron_Intro1"] = 0x620C78, - ["gText_MatchCallPsychic_Cameron_Intro2"] = 0x620C92, - ["gText_MatchCallPsychic_Cameron_Pokemon"] = 0x620C60, - ["gText_MatchCallPsychic_Cameron_Strategy"] = 0x620C4E, - ["gText_MatchCallPsychic_Jacki_Intro1"] = 0x620CD9, - ["gText_MatchCallPsychic_Jacki_Intro2"] = 0x620CF5, - ["gText_MatchCallPsychic_Jacki_Pokemon"] = 0x620CBE, - ["gText_MatchCallPsychic_Jacki_Strategy"] = 0x620CA8, - ["gText_MatchCallReliableOne_Dad_Intro1"] = 0x621CD9, - ["gText_MatchCallReliableOne_Dad_Intro2"] = 0x621CF4, - ["gText_MatchCallReliableOne_Dad_Pokemon"] = 0x621CBD, - ["gText_MatchCallReliableOne_Dad_Strategy"] = 0x621CA2, - ["gText_MatchCallRichBoy_Winston_Intro1"] = 0x62087C, - ["gText_MatchCallRichBoy_Winston_Intro2"] = 0x620896, - ["gText_MatchCallRichBoy_Winston_Pokemon"] = 0x620863, - ["gText_MatchCallRichBoy_Winston_Strategy"] = 0x62084B, - ["gText_MatchCallRockinWhiz_Roxanne_Intro1"] = 0x621B57, - ["gText_MatchCallRockinWhiz_Roxanne_Intro2"] = 0x621B73, - ["gText_MatchCallRockinWhiz_Roxanne_Pokemon"] = 0x621B3B, - ["gText_MatchCallRockinWhiz_Roxanne_Strategy"] = 0x621B23, - ["gText_MatchCallRuinManiac_Andres_Intro1"] = 0x620428, - ["gText_MatchCallRuinManiac_Andres_Intro2"] = 0x620444, - ["gText_MatchCallRuinManiac_Andres_Pokemon"] = 0x62040D, - ["gText_MatchCallRuinManiac_Andres_Strategy"] = 0x6203F2, - ["gText_MatchCallRuinManiac_Dusty_Intro1"] = 0x620481, - ["gText_MatchCallRuinManiac_Dusty_Intro2"] = 0x62049C, - ["gText_MatchCallRuinManiac_Dusty_Pokemon"] = 0x62046C, - ["gText_MatchCallRuinManiac_Dusty_Strategy"] = 0x620456, - ["gText_MatchCallSailor_Cory_Intro1"] = 0x6216A8, - ["gText_MatchCallSailor_Cory_Intro2"] = 0x6216C3, - ["gText_MatchCallSailor_Cory_Pokemon"] = 0x62168C, - ["gText_MatchCallSailor_Cory_Strategy"] = 0x621674, - ["gText_MatchCallSailor_Ernest_Intro1"] = 0x62164A, - ["gText_MatchCallSailor_Ernest_Intro2"] = 0x621664, - ["gText_MatchCallSailor_Ernest_Pokemon"] = 0x621630, - ["gText_MatchCallSailor_Ernest_Strategy"] = 0x621615, - ["gText_MatchCallSchoolKid_Jerry_Intro1"] = 0x620DF1, - ["gText_MatchCallSchoolKid_Jerry_Intro2"] = 0x620E08, - ["gText_MatchCallSchoolKid_Jerry_Pokemon"] = 0x620DDE, - ["gText_MatchCallSchoolKid_Jerry_Strategy"] = 0x620DCA, - ["gText_MatchCallSchoolKid_Karen_Intro1"] = 0x620D98, - ["gText_MatchCallSchoolKid_Karen_Intro2"] = 0x620DB3, - ["gText_MatchCallSchoolKid_Karen_Pokemon"] = 0x620D7C, - ["gText_MatchCallSchoolKid_Karen_Strategy"] = 0x620D63, - ["gText_MatchCallSisAndBro_LilaAndRoy_Intro1"] = 0x6205AC, - ["gText_MatchCallSisAndBro_LilaAndRoy_Intro2"] = 0x6205C6, - ["gText_MatchCallSisAndBro_LilaAndRoy_Pokemon"] = 0x620592, - ["gText_MatchCallSisAndBro_LilaAndRoy_Strategy"] = 0x62057D, - ["gText_MatchCallSkyTamer_Winona_Intro1"] = 0x621D3D, - ["gText_MatchCallSkyTamer_Winona_Intro2"] = 0x621D56, - ["gText_MatchCallSkyTamer_Winona_Pokemon"] = 0x621D27, - ["gText_MatchCallSkyTamer_Winona_Strategy"] = 0x621D0C, - ["gText_MatchCallSrAndJr_AnnaAndMeg_Intro1"] = 0x620E54, - ["gText_MatchCallSrAndJr_AnnaAndMeg_Intro2"] = 0x620E6C, - ["gText_MatchCallSrAndJr_AnnaAndMeg_Pokemon"] = 0x620E39, - ["gText_MatchCallSrAndJr_AnnaAndMeg_Strategy"] = 0x620E22, - ["gText_MatchCallSteven_Intro1_AfterMeteorFallsBattle"] = 0x5ED4B3, - ["gText_MatchCallSteven_Intro1_BeforeMeteorFallsBattle"] = 0x5ED483, - ["gText_MatchCallSteven_Intro2_AfterMeteorFallsBattle"] = 0x5ED4CE, - ["gText_MatchCallSteven_Intro2_BeforeMeteorFallsBattle"] = 0x5ED49D, - ["gText_MatchCallSteven_Pokemon"] = 0x5ED46B, - ["gText_MatchCallSteven_Strategy"] = 0x5ED453, - ["gText_MatchCallSwellShock_Wattson_Intro1"] = 0x621C18, - ["gText_MatchCallSwellShock_Wattson_Intro2"] = 0x621C2F, - ["gText_MatchCallSwellShock_Wattson_Pokemon"] = 0x621BFC, - ["gText_MatchCallSwellShock_Wattson_Strategy"] = 0x621BE5, - ["gText_MatchCallSwimmer_Jenny_Intro1"] = 0x62153B, - ["gText_MatchCallSwimmer_Jenny_Intro2"] = 0x621551, - ["gText_MatchCallSwimmer_Jenny_Pokemon"] = 0x621527, - ["gText_MatchCallSwimmer_Jenny_Strategy"] = 0x62151D, - ["gText_MatchCallSwimmer_Tony_Intro1"] = 0x62092E, - ["gText_MatchCallSwimmer_Tony_Intro2"] = 0x62094A, - ["gText_MatchCallSwimmer_Tony_Pokemon"] = 0x62091C, - ["gText_MatchCallSwimmer_Tony_Strategy"] = 0x620909, - ["gText_MatchCallTheBigHit_Brawly_Intro1"] = 0x621BBB, - ["gText_MatchCallTheBigHit_Brawly_Intro2"] = 0x621BD6, - ["gText_MatchCallTheBigHit_Brawly_Pokemon"] = 0x621BA4, - ["gText_MatchCallTheBigHit_Brawly_Strategy"] = 0x621B8C, - ["gText_MatchCallTriathlete_Abigail_Intro1"] = 0x621197, - ["gText_MatchCallTriathlete_Abigail_Intro2"] = 0x6211B3, - ["gText_MatchCallTriathlete_Abigail_Pokemon"] = 0x621182, - ["gText_MatchCallTriathlete_Abigail_Strategy"] = 0x62116E, - ["gText_MatchCallTriathlete_Benjamin_Intro1"] = 0x6212A7, - ["gText_MatchCallTriathlete_Benjamin_Intro2"] = 0x6212BF, - ["gText_MatchCallTriathlete_Benjamin_Pokemon"] = 0x621290, - ["gText_MatchCallTriathlete_Benjamin_Strategy"] = 0x62127B, - ["gText_MatchCallTriathlete_Dylan_Intro1"] = 0x6211FC, - ["gText_MatchCallTriathlete_Dylan_Intro2"] = 0x621216, - ["gText_MatchCallTriathlete_Dylan_Pokemon"] = 0x6211E4, - ["gText_MatchCallTriathlete_Dylan_Strategy"] = 0x6211CC, - ["gText_MatchCallTriathlete_Isaiah_Intro1"] = 0x6210E7, - ["gText_MatchCallTriathlete_Isaiah_Intro2"] = 0x621101, - ["gText_MatchCallTriathlete_Isaiah_Pokemon"] = 0x6210CE, - ["gText_MatchCallTriathlete_Isaiah_Strategy"] = 0x6210B5, - ["gText_MatchCallTriathlete_Katelyn_Intro1"] = 0x621257, - ["gText_MatchCallTriathlete_Katelyn_Intro2"] = 0x621270, - ["gText_MatchCallTriathlete_Katelyn_Pokemon"] = 0x621243, - ["gText_MatchCallTriathlete_Katelyn_Strategy"] = 0x621230, - ["gText_MatchCallTriathlete_Maria_Intro1"] = 0x62113D, - ["gText_MatchCallTriathlete_Maria_Intro2"] = 0x621157, - ["gText_MatchCallTriathlete_Maria_Pokemon"] = 0x621125, - ["gText_MatchCallTriathlete_Maria_Strategy"] = 0x621114, - ["gText_MatchCallTriathlete_Pablo_Intro1"] = 0x62130C, - ["gText_MatchCallTriathlete_Pablo_Intro2"] = 0x621326, - ["gText_MatchCallTriathlete_Pablo_Pokemon"] = 0x6212F3, - ["gText_MatchCallTriathlete_Pablo_Strategy"] = 0x6212DA, - ["gText_MatchCallTuber_Lola_Intro1"] = 0x6204DD, - ["gText_MatchCallTuber_Lola_Intro2"] = 0x6204F9, - ["gText_MatchCallTuber_Lola_Pokemon"] = 0x6204C7, - ["gText_MatchCallTuber_Lola_Strategy"] = 0x6204B0, - ["gText_MatchCallTuber_Ricky_Intro1"] = 0x620546, - ["gText_MatchCallTuber_Ricky_Intro2"] = 0x620562, - ["gText_MatchCallTuber_Ricky_Pokemon"] = 0x62052B, - ["gText_MatchCallTuber_Ricky_Strategy"] = 0x62050E, - ["gText_MatchCallTwins_AmyAndLiv_Intro1"] = 0x6215E3, - ["gText_MatchCallTwins_AmyAndLiv_Intro2"] = 0x6215FD, - ["gText_MatchCallTwins_AmyAndLiv_Pokemon"] = 0x6215D0, - ["gText_MatchCallTwins_AmyAndLiv_Strategy"] = 0x6215BC, - ["gText_MatchCallYoungCouple_LoisAndHal_Intro1"] = 0x621A9D, - ["gText_MatchCallYoungCouple_LoisAndHal_Intro2"] = 0x621AB0, - ["gText_MatchCallYoungCouple_LoisAndHal_Pokemon"] = 0x621A88, - ["gText_MatchCallYoungCouple_LoisAndHal_Strategy"] = 0x621A72, - ["gText_MatchCallYoungster_Calvin_Intro1"] = 0x621021, - ["gText_MatchCallYoungster_Calvin_Intro2"] = 0x62103B, - ["gText_MatchCallYoungster_Calvin_Pokemon"] = 0x62100A, - ["gText_MatchCallYoungster_Calvin_Strategy"] = 0x620FF9, - ["gText_Matchup"] = 0x5EB48E, - ["gText_MaxHP"] = 0x5EEA46, - ["gText_MayBrendanMatchCallDesc"] = 0x5EFB18, - ["gText_MegaKick48BP"] = 0x5ECE4F, - ["gText_MegaPunch24BP"] = 0x5ECE3D, - ["gText_MemberDroppedOut"] = 0x5ED754, - ["gText_MentalHerb48BP"] = 0x5ECD93, - ["gText_MenuBag"] = 0x5EE6D3, - ["gText_MenuExit"] = 0x5EE6EE, - ["gText_MenuOption"] = 0x5EE6E7, - ["gText_MenuOptionBag"] = 0x5EB26C, - ["gText_MenuOptionExit"] = 0x5EB285, - ["gText_MenuOptionOption"] = 0x5EB27E, - ["gText_MenuOptionPokedex"] = 0x5EB25C, - ["gText_MenuOptionPokemon"] = 0x5EB264, - ["gText_MenuOptionPokenav"] = 0x5EB270, - ["gText_MenuOptionSave"] = 0x5EB279, - ["gText_MenuPlayer"] = 0x5EE6DF, - ["gText_MenuPokedex"] = 0x5EE6C3, - ["gText_MenuPokemon"] = 0x5EE6CB, - ["gText_MenuPokenav"] = 0x5EE6D7, - ["gText_MenuRest"] = 0x5EE6FA, - ["gText_MenuRetire"] = 0x5EE6F3, - ["gText_MenuSave"] = 0x5EE6E2, - ["gText_MeowthDoll48BP"] = 0x5ECC2B, - ["gText_MessageWillBeLost"] = 0x5EAC8D, - ["gText_Mind"] = 0x5CCDBB, - ["gText_MixingRecords"] = 0x5EEA7E, - ["gText_Mom"] = 0x5EAD08, - ["gText_MomMatchCallDesc"] = 0x5EFB32, - ["gText_MomMatchCallName"] = 0x5EFB4B, - ["gText_MomOrDadMightLikeThisProgram"] = 0x272BCF, - ["gText_MonAppealedWithMove"] = 0x27D55A, - ["gText_MonAttractedALotOfAttention"] = 0x27D7A5, - ["gText_MonBecameALittleDistracted"] = 0x27E448, - ["gText_MonBecameNervous"] = 0x27DD6F, - ["gText_MonCantAppealNextTurn"] = 0x27E793, - ["gText_MonCantMakeAnAppealAfterThis"] = 0x27DBB0, - ["gText_MonCaughtALittleAttention"] = 0x27D785, - ["gText_MonCommandedTotalAttention"] = 0x27D7C8, - ["gText_MonCouldntHelpLeapingUp"] = 0x27E531, - ["gText_MonCouldntHelpUtteringCry"] = 0x27E504, - ["gText_MonDidntStandOutVeryMuch"] = 0x27D764, - ["gText_MonFailedToStandOutAtAll"] = 0x27D743, - ["gText_MonHasntMadeItsAppeal"] = 0x27D7E8, - ["gText_MonIsntFazedByThatSortOfThing"] = 0x27E416, - ["gText_MonLookedDownOutOfDistraction"] = 0x27E4A6, - ["gText_MonManagedToAvertGaze"] = 0x27E3C1, - ["gText_MonManagedToAvoidSeeingIt"] = 0x27E3EB, - ["gText_MonRegainedItsForm"] = 0x27DDF2, - ["gText_MonTrippedOutOfDistraction"] = 0x27E55A, - ["gText_MonTurnedBackOutOfDistraction"] = 0x27E4D5, - ["gText_MonTypeAndNo"] = 0x5EB3FC, - ["gText_MonWasTooNervousToMove"] = 0x27E58A, - ["gText_MonWasUnaffected"] = 0x27E6C4, - ["gText_MonWasWatchingOthers"] = 0x27D56F, - ["gText_MonsAppealDidNotGoWell"] = 0x27DED9, - ["gText_MonsAppealDidNotGoWell2"] = 0x27DF3A, - ["gText_MonsAppealDidNotGoWell3"] = 0x27DF63, - ["gText_MonsAppealDidNotWorkVeryWell"] = 0x27E02F, - ["gText_MonsAppealEffectWoreOff"] = 0x27D872, - ["gText_MonsAppealWasDud"] = 0x27E00C, - ["gText_MonsAppealWentExcellently"] = 0x27DFE2, - ["gText_MonsAppealWentExcellently2"] = 0x27E0DD, - ["gText_MonsAppealWentPrettyWell"] = 0x27DFB8, - ["gText_MonsAppealWentPrettyWell2"] = 0x27E08B, - ["gText_MonsAppealWentSlightlyWell"] = 0x27DF8C, - ["gText_MonsAppealWentSlightlyWell2"] = 0x27E05F, - ["gText_MonsAppealWentVeryWell"] = 0x27E0B5, - ["gText_MonsConditionRoseAboveUsual"] = 0x27E1F3, - ["gText_MonsHotStatusMadeGreatAppeal"] = 0x27E220, - ["gText_MonsMoveIsIgnored"] = 0x27E817, - ["gText_MonsXChangedPerceptions"] = 0x27D855, - ["gText_MonsXDidntGoOverWell"] = 0x27E73C, - ["gText_MonsXGotTheCrowdGoing"] = 0x27E76A, - ["gText_MonsXWentOverGreat"] = 0x27E717, - ["gText_MostImpressiveMon"] = 0x27E992, - ["gText_MoveCloserToLinkPartner"] = 0x5EE50C, - ["gText_MoveInterfaceDynamicColors"] = 0x5CCA8A, - ["gText_MoveInterfacePP"] = 0x5CCA6F, - ["gText_MoveInterfacePPType"] = 0x5CCA79, - ["gText_MoveInterfaceType"] = 0x5CCA73, - ["gText_MoveItems"] = 0x5EBB9F, - ["gText_MoveItemsDescription"] = 0x5EBC35, - ["gText_MoveMonDescription"] = 0x5EBC04, - ["gText_MoveNotLearned"] = 0x5E9B76, - ["gText_MoveOkBack"] = 0x5EEAF6, - ["gText_MovePokemon"] = 0x5EBB92, - ["gText_MoveRelearnedPkmnDidNotLearnMove"] = 0x5EF9C8, - ["gText_MoveRelearnerAccuracy"] = 0x5EFA5D, - ["gText_MoveRelearnerAndPoof"] = 0x5EF979, - ["gText_MoveRelearnerAppeal"] = 0x5EFA67, - ["gText_MoveRelearnerBattleMoves"] = 0x5EFA31, - ["gText_MoveRelearnerContestMovesTitle"] = 0x5EFA3E, - ["gText_MoveRelearnerGiveUp"] = 0x5EF9E6, - ["gText_MoveRelearnerJam"] = 0x5EFA6E, - ["gText_MoveRelearnerPP"] = 0x5EFA52, - ["gText_MoveRelearnerPkmnForgotMoveAndLearnedNew"] = 0x5EF9A6, - ["gText_MoveRelearnerPkmnLearnedMove"] = 0x5EF8E4, - ["gText_MoveRelearnerPkmnTryingToLearnMove"] = 0x5EF8F3, - ["gText_MoveRelearnerPower"] = 0x5EFA56, - ["gText_MoveRelearnerStopTryingToTeachMove"] = 0x5EF960, - ["gText_MoveRelearnerTeachMoveConfirm"] = 0x5EF8DA, - ["gText_MoveRelearnerType"] = 0x5EFA4C, - ["gText_MoveRelearnerWhichMoveToForget"] = 0x5EFA10, - ["gText_MoveToBag"] = 0x5EAC3A, - ["gText_MoveToWhere"] = 0x5E9E55, - ["gText_MoveUsedMostOften"] = 0x27E973, - ["gText_MoveVar1Where"] = 0x5E8DFE, - ["gText_MovedBackInLineForNextAppeal"] = 0x27E289, - ["gText_MovedUpInLineForNextAppeal"] = 0x27E254, - ["gText_Moves"] = 0x5E96BE, - ["gText_MovesPPIncreased"] = 0x5E9CF5, - ["gText_MrBriney"] = 0x5EAE97, - ["gText_MrStoneMatchCallDesc"] = 0x5EFAEF, - ["gText_MrStoneMatchCallName"] = 0x5EFAFA, - ["gText_MtPyre"] = 0x5EB7C7, - ["gText_MudSlap24BP"] = 0x5ECEDB, - ["gText_MudkipDollAndPrice"] = 0x5EAFE8, - ["gText_Multi"] = 0x5EB201, - ["gText_MultiBattle"] = 0x5EAE8A, - ["gText_MultiBattleRoomResults"] = 0x5EE198, - ["gText_MultiLink"] = 0x5EB207, - ["gText_MultiLink2"] = 0x5EB451, - ["gText_MustBePlacedOnDesk"] = 0x5EA978, - ["gText_MysteryEventBerry"] = 0x674AE4, - ["gText_MysteryEventBerryObtained"] = 0x674B42, - ["gText_MysteryEventBerryTransform"] = 0x674B16, - ["gText_MysteryEventCantBeUsed"] = 0x674C86, - ["gText_MysteryEventFullParty"] = 0x674C02, - ["gText_MysteryEventNationalDex"] = 0x674B9E, - ["gText_MysteryEventNewAdversaryInBattleTower"] = 0x674C55, - ["gText_MysteryEventNewTrainer"] = 0x674C31, - ["gText_MysteryEventRareWord"] = 0x674BD4, - ["gText_MysteryEventSentOver"] = 0x674BF0, - ["gText_MysteryEventSpecialRibbon"] = 0x674B6A, - ["gText_MysteryEventsCantUse"] = 0x5E8328, - ["gText_MysteryGift"] = 0x5EF654, - ["gText_MysteryGiftCantUse"] = 0x5E82E5, - ["gText_Name"] = 0x5E8C48, - ["gText_NamingScreenKeyboard_01234"] = 0x62B995, - ["gText_NamingScreenKeyboard_56789"] = 0x62B9AE, - ["gText_NamingScreenKeyboard_ABCDEF"] = 0x62B911, - ["gText_NamingScreenKeyboard_GHIJKL"] = 0x62B932, - ["gText_NamingScreenKeyboard_MNOPQRS"] = 0x62B953, - ["gText_NamingScreenKeyboard_Symbols1"] = 0x62B9C7, - ["gText_NamingScreenKeyboard_Symbols2"] = 0x62B9E0, - ["gText_NamingScreenKeyboard_TUVWXYZ"] = 0x62B974, - ["gText_NamingScreenKeyboard_abcdef"] = 0x62B88D, - ["gText_NamingScreenKeyboard_ghijkl"] = 0x62B8AE, - ["gText_NamingScreenKeyboard_mnopqrs"] = 0x62B8CF, - ["gText_NamingScreenKeyboard_tuvwxyz"] = 0x62B8F0, - ["gText_NatureSlash"] = 0x5EBF8D, - ["gText_NavelRock"] = 0x5EB2D9, - ["gText_NeatnessRankings"] = 0x5ED88C, - ["gText_NewLine"] = 0x5CCB86, - ["gText_NewLine2"] = 0x5EF881, - ["gText_NewStampReceived"] = 0x5EF178, - ["gText_NewTrainerReceived"] = 0x5EF198, - ["gText_Next"] = 0x5E8544, - ["gText_NextAppealMadeEarlier"] = 0x27D2DA, - ["gText_NextAppealMadeLater"] = 0x27D309, - ["gText_NextBack"] = 0x5E854C, - ["gText_NextLv"] = 0x5EA3AB, - ["gText_NicknameHatchPrompt"] = 0x5ED5BE, - ["gText_NicknameThisPokemon"] = 0x273374, - ["gText_No"] = 0x5EAE66, - ["gText_No2"] = 0x5EEC4D, - ["gText_No3"] = 0x5EECCE, - ["gText_No4"] = 0x5EB2FC, - ["gText_NoDecorationHere"] = 0x5EAA81, - ["gText_NoDecorations"] = 0x5EA83D, - ["gText_NoDecorationsInUse"] = 0x5EAAEC, - ["gText_NoEntry"] = 0x5E96CA, - ["gText_NoIdenticalHoldItems"] = 0x5E9FC2, - ["gText_NoItems"] = 0x5EABD1, - ["gText_NoMailHere"] = 0x5EAC4B, - ["gText_NoMatchingPkmnWereFound"] = 0x5E8785, - ["gText_NoMoreDecorations"] = 0x5EA8EA, - ["gText_NoMoreDecorations2"] = 0x5EA931, - ["gText_NoMoreRoomForPokemon"] = 0x27331C, - ["gText_NoMoreRoomForStamps"] = 0x5EF21C, - ["gText_NoMoreRoomForThis"] = 0x5E9558, - ["gText_NoMoreThanVar1Pkmn"] = 0x5E9830, - ["gText_NoNeed"] = 0x5ED198, - ["gText_NoPokemon"] = 0x5E8DE9, - ["gText_NoPokemonForBattle"] = 0x5E9F58, - ["gText_NoRegistry"] = 0x5EA785, - ["gText_NoRibbonWinners"] = 0x5EBD4A, - ["gText_NoRoomForItems"] = 0x5E8E6E, - ["gText_NoRoomInBag"] = 0x5EABE7, - ["gText_NoRoomLeftForAnother"] = 0x272B1A, - ["gText_NoSaveFileCantSetTime"] = 0x5ECAA7, - ["gText_NoTrainersRegistered"] = 0x5EBD67, - ["gText_NoUse"] = 0x5EA091, - ["gText_NoWeather"] = 0x5EFC3E, - ["gText_NolandDefeatGold"] = 0x2C8589, - ["gText_NolandDefeatSilver"] = 0x2C853B, - ["gText_NolandWonGold"] = 0x2C8561, - ["gText_NolandWonSilver"] = 0x2C8512, - ["gText_None"] = 0x5E8D26, - ["gText_Normal"] = 0x5EB105, - ["gText_NormalRank"] = 0x5EB1C5, - ["gText_NormalTagMatch"] = 0x5EB32D, - ["gText_NormanMatchCallDesc"] = 0x5EFB25, - ["gText_NormanMatchCallName"] = 0x5EFB47, - ["gText_NotAble"] = 0x5EA0B6, - ["gText_NotAble2"] = 0x5EA0C5, - ["gText_NotEnoughHp"] = 0x5E9F81, - ["gText_NotEvenANibble"] = 0x5EE91E, - ["gText_NotPkmnOtherTrainerWants"] = 0x5EA2E0, - ["gText_NotSameTypeAsOneBeforeGood"] = 0x27E143, - ["gText_NotSoGood"] = 0x5EB034, - ["gText_NotYet"] = 0x5EAE5A, - ["gText_NothingChanged"] = 0x5EC7EF, - ["gText_NothingSentOver"] = 0x5EF2E4, - ["gText_NothingToCut"] = 0x5E9EBC, - ["gText_NoticesGoldCard"] = 0x272860, - ["gText_NumBP"] = 0x5ED0C8, - ["gText_NumPlayerLink"] = 0x5EE667, - ["gText_NumPokeblocks"] = 0x5ED096, - ["gText_Number"] = 0x5E8C36, - ["gText_Number2"] = 0x5EBFD7, - ["gText_NumberClear01"] = 0x5E8D4F, - ["gText_NumberIndex"] = 0x5EC029, - ["gText_NumberItem_HM"] = 0x5E921A, - ["gText_NumberItem_TMBerry"] = 0x5E9210, - ["gText_NumberOfBattles"] = 0x5EBE6E, - ["gText_NumberRegistered"] = 0x5EBE5F, - ["gText_NumberVar1Var2"] = 0x5E9263, - ["gText_OTSlash"] = 0x5EA365, - ["gText_ObtainedTheDecor"] = 0x272B09, - ["gText_ObtainedTheItem"] = 0x272A78, - ["gText_OddEggFoundByCouple"] = 0x5EA647, - ["gText_OhABite"] = 0x5EE8F7, - ["gText_Ok"] = 0x5ED430, - ["gText_Ok2"] = 0x5EFCC5, - ["gText_Ok3"] = 0x5EAE4B, - ["gText_OkayToDeleteFromRegistry"] = 0x5EA737, - ["gText_OkayToDiscardNews"] = 0x5EF55C, - ["gText_OlivePokeblock"] = 0x5E92E3, - ["gText_OneDash"] = 0x5E8D40, - ["gText_OnlyOnePhrase"] = 0x5EC4C9, - ["gText_OnlyPkmnForBattle"] = 0x5EA1E8, - ["gText_OpenLevel"] = 0x5EAEB8, - ["gText_OpenLevel2"] = 0x5EB3F1, - ["gText_OpenLevel3"] = 0x5EB4E0, - ["gText_OpenLv"] = 0x5EE2F9, - ["gText_Opponent"] = 0x5EB1A0, - ["gText_Option"] = 0x5EE589, - ["gText_OptionMenuCancel"] = 0x5EE5C1, - ["gText_OriginalSongWillBeUsed"] = 0x5EC4E9, - ["gText_Ornament"] = 0x5EA86A, - ["gText_OtherTrainerCanceled"] = 0x5EF4C4, - ["gText_OtherTrainerCantAcceptPkmn"] = 0x5EA284, - ["gText_OtherTrainerHasCard"] = 0x5EF42C, - ["gText_OtherTrainerHasNews"] = 0x5EF460, - ["gText_OtherTrainerHasStamp"] = 0x5EF494, - ["gText_OtherTrainersPkmnCantBeTraded"] = 0x5EA250, - ["gText_Others"] = 0x5ED248, - ["gText_Others2"] = 0x5EEC1D, - ["gText_OutOfTheListedChoices"] = 0x5EC2FF, - ["gText_PCMailboxFull"] = 0x5E9897, - ["gText_PPUp3000"] = 0x5EB76C, - ["gText_PPWasRestored"] = 0x5E9CB7, - ["gText_PackUp"] = 0x5EAE04, - ["gText_Par"] = 0x5EAE35, - ["gText_Paralysis"] = 0x5CCB5C, - ["gText_PartyFull"] = 0x5EBCAD, - ["gText_PauseUntilPress"] = 0x5EA1CA, - ["gText_Peak"] = 0x5EE760, - ["gText_PeculiarEggNicePlace"] = 0x5EA678, - ["gText_PeculiarEggTrade"] = 0x5EA6AB, - ["gText_Peekaboo"] = 0x5EE49A, - ["gText_PeopleBattling"] = 0x5EED6F, - ["gText_PeopleCommunicating"] = 0x5EED9A, - ["gText_PeopleInUnionRoom"] = 0x5EED80, - ["gText_PeopleTrading"] = 0x5EED5F, - ["gText_Petalburg"] = 0x5EAD37, - ["gText_Philip"] = 0x5EAB17, - ["gText_Phoebe"] = 0x5EAD29, - ["gText_PickAWordOrPhraseAnd"] = 0x5EC34C, - ["gText_PickCancel"] = 0x5E8574, - ["gText_PickNextCancel"] = 0x5E855C, - ["gText_PickOKCancel"] = 0x5EF678, - ["gText_PickOKExit"] = 0x5EF664, - ["gText_PickOk"] = 0x5E8538, - ["gText_PickTheWallpaper"] = 0x5EB82F, - ["gText_PinkPokeblock"] = 0x5E928A, - ["gText_PkmnAdoresBaseVar2Fell"] = 0x5E9D6D, - ["gText_PkmnAlreadyHoldingItemSwitch"] = 0x5E993A, - ["gText_PkmnAlreadyInBattle"] = 0x5E9754, - ["gText_PkmnAlreadyKnows"] = 0x5E9C00, - ["gText_PkmnAlreadySelected"] = 0x5E976F, - ["gText_PkmnBaseVar2StatIncreased"] = 0x5E9D25, - ["gText_PkmnBecameHealthy"] = 0x5E9CE0, - ["gText_PkmnBurnHealed"] = 0x5E9C8A, - ["gText_PkmnCameBack"] = 0x5EB979, - ["gText_PkmnCantBeTradedNow"] = 0x5EA210, - ["gText_PkmnCantLearnMove"] = 0x5E9AB4, - ["gText_PkmnCantParticipate"] = 0x5EA0EE, - ["gText_PkmnCantSwitchOut"] = 0x5E9738, - ["gText_PkmnCuredOfParalysis"] = 0x5E9C5F, - ["gText_PkmnCuredOfPoison"] = 0x5E9C3E, - ["gText_PkmnElevatedToLvVar2"] = 0x5E9D0A, - ["gText_PkmnFainted_FldPsn"] = 0x5ECF5D, - ["gText_PkmnForSwap"] = 0x5EECD1, - ["gText_PkmnFriendlyBaseVar2CantFall"] = 0x5E9D90, - ["gText_PkmnFriendlyBaseVar2Fell"] = 0x5E9D45, - ["gText_PkmnGotOverInfatuation"] = 0x5E9DE1, - ["gText_PkmnHPRestoredByVar2"] = 0x5E9C17, - ["gText_PkmnHasNoEnergy"] = 0x5E978F, - ["gText_PkmnHoldingItemCantHoldMail"] = 0x5E9A08, - ["gText_PkmnInfo"] = 0x5EA3CF, - ["gText_PkmnIsEvolving"] = 0x5CC990, - ["gText_PkmnIsSelected"] = 0x5EB843, - ["gText_PkmnJumpRecords"] = 0x5EDE8D, - ["gText_PkmnLearnedMove3"] = 0x5E9AA5, - ["gText_PkmnNeedsToReplaceMove"] = 0x5E9AE9, - ["gText_PkmnNotHolding"] = 0x5E9980, - ["gText_PkmnRegainhedHealth"] = 0x5E9CCA, - ["gText_PkmnSkills"] = 0x5EA3DC, - ["gText_PkmnSnappedOutOfConfusion"] = 0x5E9DBE, - ["gText_PkmnStoppedEvolving"] = 0x5CC9D3, - ["gText_PkmnSwap"] = 0x5EEC66, - ["gText_PkmnThawedOut"] = 0x5E9CA2, - ["gText_PkmnTransferredLanettesPC"] = 0x273256, - ["gText_PkmnTransferredLanettesPCBoxFull"] = 0x2732D9, - ["gText_PkmnTransferredSomeonesPC"] = 0x273216, - ["gText_PkmnTransferredSomeonesPCBoxFull"] = 0x273296, - ["gText_PkmnWasDeposited"] = 0x5EB87C, - ["gText_PkmnWasGivenItem"] = 0x5E991B, - ["gText_PkmnWasReleased"] = 0x5EB8B5, - ["gText_PkmnWokeUp2"] = 0x5E9C7C, - ["gText_PkmnsNickname"] = 0x5EEAD3, - ["gText_PlaceItHere"] = 0x5EA897, - ["gText_PlacedItemInBag"] = 0x5EB9D6, - ["gText_Plant"] = 0x5EA864, - ["gText_PlayBerryCrushAgain"] = 0x5ED704, - ["gText_Player"] = 0x5E84BF, - ["gText_PlayerFoundOneItem"] = 0x272ABF, - ["gText_PlayerFoundOneTMHM"] = 0x2731A9, - ["gText_PlayerHandedOverTheItem"] = 0x273161, - ["gText_PlayerHouseBootPC"] = 0x272D87, - ["gText_PlayerPutItemInBag"] = 0x272AEA, - ["gText_PlayerSavedGame"] = 0x2C8832, - ["gText_PlayerSavedGame_Chat"] = 0x5ED3C5, - ["gText_PlayerUsedSurf"] = 0x27300D, - ["gText_PlayerUsedVar2"] = 0x5E9080, - ["gText_PlayerWhatCanIDoForYou"] = 0x272A52, - ["gText_PlayerWhitedOut"] = 0x272DE3, - ["gText_PlayersBattleResults"] = 0x5EF68D, - ["gText_PlayersPC"] = 0x5EB183, - ["gText_PleaseComeAgain"] = 0x272A3F, - ["gText_PleaseEnterPhraseOrWord"] = 0x5EC488, - ["gText_PleasePickATheme"] = 0x5EB81A, - ["gText_PleaseRemoveTheMail"] = 0x5EB9AB, - ["gText_PleaseResetTime"] = 0x5ECA38, - ["gText_PleaseWaitForLink"] = 0x278091, - ["gText_Plus"] = 0x5EEA73, - ["gText_PlusSymbol"] = 0x5E8D55, - ["gText_Points"] = 0x5ECF91, - ["gText_Poison"] = 0x5CCB50, - ["gText_PokeBalls"] = 0x5EFCD4, - ["gText_PokeBallsPocket"] = 0x5E91D4, - ["gText_PokeDollar"] = 0x5ECF84, - ["gText_Poke_Balls"] = 0x5EB14C, - ["gText_PokeblockLinkCanceled"] = 0x272D9C, - ["gText_PokeblocksWithFriends"] = 0x5ED085, - ["gText_Pokedex"] = 0x5E84C6, - ["gText_PokedexDiploma"] = 0x5EE85E, - ["gText_PokedexRegistration"] = 0x5E8735, - ["gText_PokedollarVar1"] = 0x5E969C, - ["gText_PokemartSign"] = 0x272B6A, - ["gText_Pokemon"] = 0x5E8268, - ["gText_Pokemon2"] = 0x5ED415, - ["gText_Pokemon3"] = 0x5EDFB7, - ["gText_Pokemon4"] = 0x5E8CAC, - ["gText_PokemonAreNeeded"] = 0x5E9F90, - ["gText_PokemonCantBeSame"] = 0x5E9FA7, - ["gText_PokemonCenter"] = 0x5EC791, - ["gText_PokemonCenterSign"] = 0x272B9E, - ["gText_PokemonFemaleLv"] = 0x5EBFF6, - ["gText_PokemonFemaleLv2"] = 0x5EC04F, - ["gText_PokemonJump"] = 0x5EB3A4, - ["gText_PokemonLeague"] = 0x5EC782, - ["gText_PokemonMaleLv"] = 0x5EBFE4, - ["gText_PokemonMaleLv2"] = 0x5EC03B, - ["gText_PokemonMoves"] = 0x5EB508, - ["gText_PokemonNature"] = 0x5EB4F9, - ["gText_PokemonNoGenderLv"] = 0x5EC008, - ["gText_PokemonNoGenderLv2"] = 0x5EC063, - ["gText_PokemonOnHook"] = 0x5EE903, - ["gText_PokemonOrder"] = 0x5EB589, - ["gText_PokemonTrades"] = 0x5ED010, - ["gText_PokemonTrainerSchoolEmail"] = 0x272CD5, - ["gText_PokenavAndBag"] = 0x5EB56E, - ["gText_PokenavCondition_MarkingButtons"] = 0x5EBF22, - ["gText_PokenavCondition_MonListButtons"] = 0x5EBEF9, - ["gText_PokenavCondition_MonStatusButtons"] = 0x5EBF0E, - ["gText_PokenavMap_ZoomedInButtons"] = 0x5EBEE9, - ["gText_PokenavMap_ZoomedOutButtons"] = 0x5EBED9, - ["gText_PokenavMatchCall_CallMenuButtons"] = 0x5EBF49, - ["gText_PokenavMatchCall_CheckTrainerButtons"] = 0x5EBF57, - ["gText_PokenavMatchCall_SelfIntroduction"] = 0x5EBEC3, - ["gText_PokenavMatchCall_Strategy"] = 0x5EBEA8, - ["gText_PokenavMatchCall_TrainerListButtons"] = 0x5EBF39, - ["gText_PokenavMatchCall_TrainerPokemon"] = 0x5EBEB1, - ["gText_PokenavRibbons_MonListButtons"] = 0x5EBF60, - ["gText_PokenavRibbons_RibbonCheckButtons"] = 0x5EBF84, - ["gText_PokenavRibbons_RibbonListButtons"] = 0x5EBF73, - ["gText_Pokenav_ClearButtonList"] = 0x5EBED5, - ["gText_PokerusExplanation"] = 0x272F07, - ["gText_Poster"] = 0x5EA877, - ["gText_Powder"] = 0x5EDCDE, - ["gText_PowderQty"] = 0x5E9138, - ["gText_Power"] = 0x5EA37E, - ["gText_PreliminaryResults"] = 0x5ED1D0, - ["gText_PresentTime"] = 0x5ECA0D, - ["gText_PressAToLoadEvent"] = 0x5EE788, - ["gText_PressesRankings"] = 0x5ED860, - ["gText_PressingPowerRankings"] = 0x5ED8B8, - ["gText_PressingSpeed"] = 0x5ED810, - ["gText_PressingSpeedRankings"] = 0x5ED8DC, - ["gText_PrettyChair"] = 0x5EB651, - ["gText_PrettyDesk"] = 0x5EB65E, - ["gText_Prev"] = 0x5EE320, - ["gText_PreviousTime"] = 0x5ECA22, - ["gText_PrimaryJudgingNumX"] = 0x27E929, - ["gText_ProfBirchMatchCallDesc"] = 0x5EFBC9, - ["gText_ProfBirchMatchCallName"] = 0x5E8270, - ["gText_Profile"] = 0x5EC5AA, - ["gText_Protein1000"] = 0x5EB703, - ["gText_Protein1BP"] = 0x5ECD13, - ["gText_Psn"] = 0x5EAE31, - ["gText_PsychUp48BP"] = 0x5ECF16, - ["gText_PurplePokeblock"] = 0x5E92AE, - ["gText_PutAway"] = 0x5EA7BA, - ["gText_PutAwayFrontierPass"] = 0x5ED977, - ["gText_PutAwayPokenav"] = 0x5EBD34, - ["gText_PutItemInBag"] = 0x5EB9FF, - ["gText_PutItemInPocket"] = 0x272A9A, - ["gText_PutOutSelectedDecorItem"] = 0x5EA7CF, - ["gText_PyramidMaze"] = 0x5EB555, - ["gText_PyramidPokemon"] = 0x5EB532, - ["gText_PyramidTrainers"] = 0x5EB543, - ["gText_QuestionMark"] = 0x5ECBDA, - ["gText_QuestionPressTheAButton"] = 0x5EC6D4, - ["gText_Questionnaire"] = 0x5EC664, - ["gText_QuickClaw48BP"] = 0x5ECD81, - ["gText_Quit"] = 0x5EAE4E, - ["gText_QuitChatting"] = 0x5ED265, - ["gText_QuitEditing"] = 0x5EC3A6, - ["gText_QuitEditing2"] = 0x5EC449, - ["gText_QuitGivingInfo"] = 0x5EC53E, - ["gText_QuitShopping"] = 0x5E93E0, - ["gText_QuitSwapping"] = 0x5EECBB, - ["gText_QuitTheGame"] = 0x5EF6E9, - ["gText_Quiz"] = 0x5EFCC8, - ["gText_QuizLady_Lady"] = 0x5EEB7E, - ["gText_RButton"] = 0x5E84EC, - ["gText_RTCTime"] = 0x5EE6AD, - ["gText_Rage"] = 0x27D473, - ["gText_Rain"] = 0x5EFC56, - ["gText_RainDance"] = 0x27D468, - ["gText_RankingHall"] = 0x5EB77D, - ["gText_Ranks"] = 0x5EAD96, - ["gText_Read"] = 0x5EAC35, - ["gText_Read2"] = 0x5E96D8, - ["gText_ReadNewsThatArrived"] = 0x5EEEB8, - ["gText_ReadyPickBerry"] = 0x5ED5F0, - ["gText_ReadyToStart"] = 0x5EB1B6, - ["gText_Receive"] = 0x5EEE30, - ["gText_ReceivedItemFromPkmn"] = 0x5E999D, - ["gText_Rechoose"] = 0x5EECB2, - ["gText_Record"] = 0x5EE319, - ["gText_Record2"] = 0x5EB5BC, - ["gText_Record3"] = 0x5EB41D, - ["gText_RecordBattleToPass"] = 0x5CD28F, - ["gText_RecordCorner"] = 0x5EB389, - ["gText_RecordMixingComplete"] = 0x5EEA8E, - ["gText_RecordUploadedViaWireless"] = 0x5EF244, - ["gText_RecordingGameResults"] = 0x5ED6C8, - ["gText_RecordsLv50"] = 0x5EE3B0, - ["gText_RecordsOpenLevel"] = 0x5EE3B8, - ["gText_Red"] = 0x5EE14B, - ["gText_RedFlute"] = 0x5EB62F, - ["gText_RedFlute2"] = 0x5EAF7D, - ["gText_RedPokeblock"] = 0x5E9275, - ["gText_RedShard"] = 0x5EB040, - ["gText_RedTent"] = 0x5EB29A, - ["gText_ReelTimeHelp"] = 0x5EF750, - ["gText_RefusedBattle"] = 0x5EFC27, - ["gText_Register"] = 0x5EA333, - ["gText_Register2"] = 0x5ED257, - ["gText_RegisterTextHere"] = 0x5ED289, - ["gText_RegisterTextWhere"] = 0x5ED274, - ["gText_RegisteredDataDeleted"] = 0x5EA762, - ["gText_RegisteredTextChangedOKToSave"] = 0x5ED325, - ["gText_RegisteredTrainerinPokeNav"] = 0x272E0F, - ["gText_Registry"] = 0x5EAE12, - ["gText_ReleaseThisPokemon"] = 0x5EB89F, - ["gText_RemoveMailBeforeItem"] = 0x5E98EC, - ["gText_Rent"] = 0x5EEC10, - ["gText_RentalPkmn"] = 0x5EA369, - ["gText_RentalPkmn2"] = 0x5EEBB2, - ["gText_RentalSwap"] = 0x5EE326, - ["gText_RepeatedAppeal"] = 0x27E6E3, - ["gText_RepelEffectsLingered"] = 0x5E9092, - ["gText_ResetRTCConfirmCancel"] = 0x5EC9EC, - ["gText_Rest"] = 0x5EB5C3, - ["gText_Rest2"] = 0x27D4C7, - ["gText_RestoreWhichMove"] = 0x5E9F16, - ["gText_RestoredPkmnToFullHealth"] = 0x272798, - ["gText_Retire"] = 0x5EB5C8, - ["gText_ReturnDecorationToPC"] = 0x5EAAA3, - ["gText_ReturnToConditionMenu"] = 0x5EBE41, - ["gText_ReturnToHealingSpot"] = 0x5EA196, - ["gText_ReturnToPokedex"] = 0x5E87EF, - ["gText_ReturnToPokenavMenu"] = 0x5EBDBF, - ["gText_ReturnToTitle"] = 0x5EEED4, - ["gText_ReturnToVar1"] = 0x5E91C0, - ["gText_ReturnToWaitingRoom"] = 0x5EA139, - ["gText_RevivalHerb300"] = 0x5EB6ED, - ["gText_Ribbons"] = 0x5EBFDC, - ["gText_RibbonsF700"] = 0x5EC030, - ["gText_RibbonsVar1"] = 0x5EA3B4, - ["gText_Right"] = 0x5EB07E, - ["gText_RightArrow"] = 0x5E8D57, - ["gText_Roberto"] = 0x5EAB25, - ["gText_RockSlide48BP"] = 0x5ECE71, - ["gText_Rooftop"] = 0x5EB69D, - ["gText_RoomsCleared"] = 0x5EE35B, - ["gText_RootFossil"] = 0x5EB2F0, - ["gText_Roulette"] = 0x5ECB12, - ["gText_Round2Results"] = 0x5ED1E9, - ["gText_RoxanneMatchCallDesc"] = 0x5EFB62, - ["gText_Roy"] = 0x5EFA80, - ["gText_SafariBallLeft"] = 0x5CCB3C, - ["gText_SafariBallStock"] = 0x5EE6FF, - ["gText_SafariBalls"] = 0x5CCB2C, - ["gText_SafariZoneMenu"] = 0x5CCA54, - ["gText_SamePkmnInPartyAlready"] = 0x5EED0A, - ["gText_SameTypeAsOneBeforeGood"] = 0x27E107, - ["gText_Sandstorm"] = 0x5EFC7A, - ["gText_SandstormIsVicious"] = 0x272C5F, - ["gText_SandySlopes"] = 0x5EAF0E, - ["gText_Satisfied"] = 0x5EAF34, - ["gText_SaveCompleteGameCannotContinue"] = 0x5EC94F, - ["gText_SaveCompletePressA"] = 0x5EC9A2, - ["gText_SaveCompleted"] = 0x5ECA8A, - ["gText_SaveCompletedPressA"] = 0x5EF5E0, - ["gText_SaveError"] = 0x2C892A, - ["gText_SaveFailed"] = 0x5ECA9A, - ["gText_SaveFailedCheckingBackup"] = 0x5EC816, - ["gText_SaveFileCorrupted"] = 0x5E83C2, - ["gText_SaveFileErased"] = 0x5E8405, - ["gText_SaveFileUpdated"] = 0x5E83A2, - ["gText_SavingBadges"] = 0x5EED2D, - ["gText_SavingDontTurnOff"] = 0x2C8810, - ["gText_SavingDontTurnOff2"] = 0x5EE42D, - ["gText_SavingDontTurnOffPower"] = 0x2C8959, - ["gText_SavingDontTurnOff_Chat"] = 0x5ED3A3, - ["gText_SavingPlayer"] = 0x5EED26, - ["gText_SavingPokedex"] = 0x5EED34, - ["gText_SavingTime"] = 0x5EED3C, - ["gText_SawIt"] = 0x5EAE53, - ["gText_ScopeLens64BP"] = 0x5ECDF2, - ["gText_ScottMatchCallDesc"] = 0x5EFB4F, - ["gText_ScottMatchCallName"] = 0x5EFB5C, - ["gText_ScrambleOrderOfNextAppeals"] = 0x27D369, - ["gText_ScrambledUpOrderForNextTurn"] = 0x27E2C5, - ["gText_Seafloor"] = 0x5EFC89, - ["gText_Seafloor2"] = 0x5EFCAB, - ["gText_SearchCompleted"] = 0x5E8773, - ["gText_SearchForPkmnBasedOnParameters"] = 0x5E87A5, - ["gText_SearchingPleaseWait"] = 0x5E875B, - ["gText_Second"] = 0x5ECB80, - ["gText_Second_PM"] = 0x5EA0A4, - ["gText_SecondaryJudgingNumX"] = 0x27E943, - ["gText_SecretBase"] = 0x5EC9D8, - ["gText_SectionMustBeCompleted"] = 0x5EC688, - ["gText_SeeYa"] = 0x5EBBAA, - ["gText_SeeYaDescription"] = 0x5EBC6C, - ["gText_SeismicToss24BP"] = 0x5ECE16, - ["gText_Select"] = 0x5E8504, - ["gText_SelectFirstPkmn"] = 0x5EEBC1, - ["gText_SelectPkmnToAccept"] = 0x5EEC8B, - ["gText_SelectPkmnToSwap"] = 0x5EEC73, - ["gText_SelectPokedexListingMode"] = 0x5E881F, - ["gText_SelectPokedexMode"] = 0x5E8806, - ["gText_SelectSecondPkmn"] = 0x5EEBDB, - ["gText_SelectTheAnswer"] = 0x5EC752, - ["gText_SelectTheAnswerToTheQuiz"] = 0x5EC31A, - ["gText_SelectThirdPkmn"] = 0x5EEBF6, - ["gText_SelectWithoutRegisteredItem"] = 0x272C98, - ["gText_SelectorArrow"] = 0x5E8B3A, - ["gText_SelectorArrow2"] = 0x5E8D60, - ["gText_SelectorArrow3"] = 0x5EE498, - ["gText_Send"] = 0x5EEE38, - ["gText_SendMailToPC"] = 0x5E9855, - ["gText_SendOut"] = 0x5E96A6, - ["gText_SendingWonderCard"] = 0x5EF360, - ["gText_SendingWonderNews"] = 0x5EF37C, - ["gText_SetEventNumX"] = 0x27E95F, - ["gText_SetEventNumX2"] = 0x27E9B9, - ["gText_SetKOTourneyResults"] = 0x5EE27C, - ["gText_SetTheAnswer"] = 0x5EC584, - ["gText_SetTheQuizAnswer"] = 0x5EC366, - ["gText_SettledDownJustLittleBit"] = 0x27DBE0, - ["gText_SeverelyCheapenedOtherAppeals"] = 0x27D961, - ["gText_Shift"] = 0x5E96A0, - ["gText_ShiftJudgesAttentionFromOthers"] = 0x27CE5F, - ["gText_ShopBuy"] = 0x5E93C7, - ["gText_ShopQuit"] = 0x5E93D0, - ["gText_ShopSell"] = 0x5E93CB, - ["gText_Silkiness"] = 0x5ED820, - ["gText_Silver"] = 0x5EA890, - ["gText_SilverCard"] = 0x5EE67E, - ["gText_Single"] = 0x5ECB64, - ["gText_Single2"] = 0x5EB1F3, - ["gText_SingleBattle"] = 0x5EAE6E, - ["gText_SingleBattleHallResults"] = 0x5EE221, - ["gText_SingleBattleRoomResults"] = 0x5EE158, - ["gText_SingleBattleTourneyResults"] = 0x5EE1DB, - ["gText_SizeComparedTo"] = 0x5E8723, - ["gText_SizeSlash"] = 0x5E9225, - ["gText_Skill"] = 0x5CCDC0, - ["gText_SkyPillar"] = 0x5EB7D0, - ["gText_Slash"] = 0x5E8D3E, - ["gText_Slateport"] = 0x5EAD41, - ["gText_SlateportCity"] = 0x5EB7A9, - ["gText_Sleep"] = 0x5CCB4A, - ["gText_SlightlyStartleAppealed"] = 0x27CD0F, - ["gText_SlightlyStartleFrontMon"] = 0x27CCE7, - ["gText_Slots"] = 0x5ECB0C, - ["gText_Slp"] = 0x5EAE39, - ["gText_Smart"] = 0x5EB0F9, - ["gText_Smart2"] = 0x5EB130, - ["gText_SmartMove"] = 0x27E8B4, - ["gText_Smartness"] = 0x5EC7CB, - ["gText_SmartnessContest"] = 0x5EADD5, - ["gText_SmoochumDoll32BP"] = 0x5ECC03, - ["gText_Snore24BP"] = 0x5ECECE, - ["gText_SnorlaxDoll128BP"] = 0x5ECCBA, - ["gText_Snow"] = 0x5EFC5B, - ["gText_SoSo"] = 0x5ECB34, - ["gText_SodaPopAndPrice"] = 0x5EAED6, - ["gText_Softboiled"] = 0x27D48E, - ["gText_Softboiled16BP"] = 0x5ECE04, - ["gText_SomeoneDroppedOut"] = 0x5EDE2C, - ["gText_SomeoneDroppedOut2"] = 0x5EDF3E, - ["gText_SomeonesPC"] = 0x5EB169, - ["gText_Son"] = 0x5EB60A, - ["gText_Sorry"] = 0x5ED434, - ["gText_SorryRecordCornerPreparation"] = 0x273125, - ["gText_SorryTradeCenterInspections"] = 0x2730E5, - ["gText_SorryWirelessClubAdjustments"] = 0x27306F, - ["gText_Sound"] = 0x5EE5B5, - ["gText_SoundMono"] = 0x5EE61D, - ["gText_SoundStereo"] = 0x5EE628, - ["gText_Sour"] = 0x5E9334, - ["gText_Sour2"] = 0x5ECB5F, - ["gText_SouthernIsland"] = 0x5EB2AD, - ["gText_SpAtk"] = 0x5EEA63, - ["gText_SpAtk3"] = 0x5E96E6, - ["gText_SpAtk4"] = 0x5EA34B, - ["gText_SpDef"] = 0x5EEA6B, - ["gText_SpDef3"] = 0x5E96EE, - ["gText_SpDef4"] = 0x5EA353, - ["gText_Space"] = 0x5E8D5E, - ["gText_Space2"] = 0x5CCB82, - ["gText_SpaceAndSpace"] = 0x5CCB79, - ["gText_SpaceForVar1Full"] = 0x5E957F, - ["gText_SpaceMin"] = 0x5ED830, - ["gText_SpacePoints"] = 0x5EDE5D, - ["gText_SpacePoints2"] = 0x5EDE7C, - ["gText_SpaceSec"] = 0x5ED840, - ["gText_SpaceSeconds"] = 0x5EB5E5, - ["gText_SpaceTimes"] = 0x5EB5EE, - ["gText_SpaceTimes2"] = 0x5ED7E8, - ["gText_SpaceTimes3"] = 0x5EDE84, - ["gText_SpecialAppealsEffectWoreOff"] = 0x27D88F, - ["gText_Speed"] = 0x5EEA5D, - ["gText_Speed2"] = 0x5EA35B, - ["gText_SpenserDefeatGold"] = 0x2C86B3, - ["gText_SpenserDefeatSilver"] = 0x2C8662, - ["gText_SpenserWonGold"] = 0x2C8682, - ["gText_SpenserWonSilver"] = 0x2C8628, - ["gText_Spicy"] = 0x5E931D, - ["gText_Spicy2"] = 0x5ECB48, - ["gText_StampSentTo"] = 0x5EF3F0, - ["gText_StandoutMonHustledEvenMore"] = 0x27DE44, - ["gText_Start"] = 0x5E84FE, - ["gText_StartleAllAppealed"] = 0x27CD73, - ["gText_StartleAllAppealed2"] = 0x27CE2C, - ["gText_StartleAppealedBeforeUser"] = 0x27CD3F, - ["gText_StartleAppealedBeforeUser2"] = 0x27CDF8, - ["gText_StartleMonHasJudgesAttention"] = 0x27CE89, - ["gText_StartleMonsMadeSameTypeAppeal"] = 0x27CEEF, - ["gText_StatRose"] = 0x5CB38A, - ["gText_Status"] = 0x5EA398, - ["gText_Steven"] = 0x5EAD14, - ["gText_StevenMatchCallDesc"] = 0x5EFB04, - ["gText_StevenMatchCallName"] = 0x5EFB11, - ["gText_StoleAttentionAwayFromMon"] = 0x27D926, - ["gText_StoodOutAsMuchAsMon"] = 0x27DB03, - ["gText_StoodOutAsMuchAsSpecialAppeals"] = 0x27DADA, - ["gText_StoodOutMuchMoreThanMonBefore"] = 0x27E17F, - ["gText_StoodOutToMakeUpForBeingJammed"] = 0x27DA31, - ["gText_StopGivingPkmnMail"] = 0x5EC3B4, - ["gText_StopGivingPkmnMail2"] = 0x5EC557, - ["gText_StopLearningMove2"] = 0x5E9B5D, - ["gText_StopPuttingAwayDecorations"] = 0x5EAA62, - ["gText_StoppedCaringAboutOtherMons"] = 0x27DC7C, - ["gText_StorageSystemOpened"] = 0x2726A3, - ["gText_Store"] = 0x5E8D1A, - ["gText_StoreChosenDecorInPC"] = 0x5EA7F5, - ["gText_StoreItemsInPC"] = 0x5EAB7B, - ["gText_StowCase"] = 0x5E9344, - ["gText_StrVar1"] = 0x5ED82C, - ["gText_Sudowoodo_Attacked"] = 0x2731BD, - ["gText_Summary"] = 0x5EEC15, - ["gText_Summary2"] = 0x5EECAA, - ["gText_Summary5"] = 0x5E96B6, - ["gText_Sunny"] = 0x5EFC49, - ["gText_Sunny2"] = 0x5EFC4F, - ["gText_Sunny3"] = 0x5EFC99, - ["gText_SunnyDay"] = 0x27D4BD, - ["gText_Super"] = 0x5EB10C, - ["gText_SuperRank"] = 0x5EB1D1, - ["gText_Swap"] = 0x5EECA5, - ["gText_Swap2"] = 0x5EECE4, - ["gText_SwapNotes"] = 0x5EB4D4, - ["gText_SwapNumber"] = 0x5EB4C7, - ["gText_SwapPartners"] = 0x5EB4B9, - ["gText_Sweet"] = 0x5E9327, - ["gText_Sweet2"] = 0x5ECB52, - ["gText_Swift24BP"] = 0x5ECEEB, - ["gText_Switch"] = 0x5EA3C8, - ["gText_Switch2"] = 0x5E96AF, - ["gText_SwitchPokedexListings"] = 0x5E87D6, - ["gText_SwitchedPkmnItem"] = 0x5E99DB, - ["gText_SwordsDance"] = 0x27D4A5, - ["gText_SwordsDance48BP"] = 0x5ECEA6, - ["gText_Symbols"] = 0x5ED24F, - ["gText_Symbols2"] = 0x5EB415, - ["gText_SymbolsEarned"] = 0x5ED8FF, - ["gText_TM13AndPrice"] = 0x5EB0D5, - ["gText_TM24AndPrice"] = 0x5EB0C2, - ["gText_TM29AndPrice"] = 0x5EB09C, - ["gText_TM32AndPrice"] = 0x5EB089, - ["gText_TM35AndPrice"] = 0x5EB0AF, - ["gText_TMHMContainedVar1"] = 0x5E9058, - ["gText_TMHMPocket"] = 0x5E91DF, - ["gText_TMs_Hms"] = 0x5EB157, - ["gText_Take"] = 0x5E8D15, - ["gText_Take2"] = 0x5E96D3, - ["gText_TakeGoodCareOfX"] = 0x5EE977, - ["gText_TakeOutItemsFromPC"] = 0x5EAB92, - ["gText_Tasty"] = 0x5E9339, - ["gText_TateLizaMatchCallDesc"] = 0x5EFB9E, - ["gText_TauntedMonsDoingWell"] = 0x27DDC7, - ["gText_TeachWhichMoveToPkmn"] = 0x5EF8C2, - ["gText_TeachWhichPokemon"] = 0x5E9E64, - ["gText_TeachWhichPokemon2"] = 0x5E9E2E, - ["gText_TellHimTheWords"] = 0x5EEAE2, - ["gText_TemporarilyStopCrowdExcited"] = 0x27D436, - ["gText_Tension"] = 0x27E88C, - ["gText_TextSpeed"] = 0x5EE590, - ["gText_TextSpeedFast"] = 0x5EE5E9, - ["gText_TextSpeedMid"] = 0x5EE5DF, - ["gText_TextSpeedSlow"] = 0x5EE5D4, - ["gText_ThankYou"] = 0x5ED440, - ["gText_ThankYouForAccessingMysteryGift"] = 0x273178, - ["gText_ThankYouForWaiting"] = 0x2729F0, - ["gText_ThankYouIllSendItHome"] = 0x5E94E7, - ["gText_ThanksIllSendItHome"] = 0x5E9510, - ["gText_ThatIsntAnEgg"] = 0x5EA320, - ["gText_ThatsTrendyAlready"] = 0x5EC509, - ["gText_ThatsYourLastPkmn"] = 0x5EB8E6, - ["gText_TheAnswer"] = 0x5EC1F8, - ["gText_TheAnswerColon"] = 0x5EC37B, - ["gText_TheBagIsFull"] = 0x272A89, - ["gText_TheBardsSong"] = 0x5EC5F6, - ["gText_TheBardsSong2"] = 0x5EC228, - ["gText_TheBattle"] = 0x5E9153, - ["gText_TheDecorWasTransferredToThePC"] = 0x272B48, - ["gText_TheField"] = 0x5E9149, - ["gText_TheGreatNewHope"] = 0x5CCD30, - ["gText_TheImage"] = 0x5EC2F4, - ["gText_TheMailMessage"] = 0x5EC203, - ["gText_TheMailSalutation"] = 0x5EC214, - ["gText_ThePC"] = 0x5E9178, - ["gText_ThePokemonList"] = 0x5E915E, - ["gText_ThePreviousChampion"] = 0x5CCD82, - ["gText_TheQuizAnswerIs"] = 0x5EC6F2, - ["gText_TheQuizColon"] = 0x5EC387, - ["gText_TheShop"] = 0x5E916F, - ["gText_TheTrainersImage"] = 0x5EC2DF, - ["gText_TheTrendySaying"] = 0x5EC26C, - ["gText_TheUnbeatenChampion"] = 0x5CCD9A, - ["gText_TheWorst"] = 0x5ECB3E, - ["gText_ThereIsNoBattleRecord"] = 0x5EDA96, - ["gText_TheseThreePkmnOkay"] = 0x5EEC2D, - ["gText_Third"] = 0x5ECB87, - ["gText_Third_PM"] = 0x5EA0AB, - ["gText_ThisIsAPokemon"] = 0x5E8692, - ["gText_ThreeDashes"] = 0x5E8D45, - ["gText_ThreeMarks"] = 0x5E8D34, - ["gText_ThreeQuestionMarks"] = 0x5EEA42, - ["gText_ThrewAwayVar2Var1s"] = 0x5E8EF9, - ["gText_ThrowAwayItem"] = 0x5E9E00, - ["gText_ThrowAwayItemsInPC"] = 0x5EABAE, - ["gText_ThrowAwayUnwantedDecors"] = 0x5EA81C, - ["gText_ThrowAwayVar1"] = 0x5E9353, - ["gText_ThrowAwayWonderCard"] = 0x5EF050, - ["gText_ThrowInPremierBall"] = 0x5E95EA, - ["gText_ThunderPunch48BP"] = 0x5ECF37, - ["gText_ThunderWave48BP"] = 0x5ECE92, - ["gText_Time"] = 0x5E84CE, - ["gText_TimeBoard"] = 0x5EF88A, - ["gText_TimeCleared"] = 0x5EF895, - ["gText_TimeColon"] = 0x5ED808, - ["gText_TimeColon2"] = 0x5EE69D, - ["gText_TimesCleared"] = 0x5EE36D, - ["gText_TimesPerSec"] = 0x5ED850, - ["gText_TimesUpNoGoodPowder"] = 0x5ED788, - ["gText_TimesVar1"] = 0x5EE392, - ["gText_ToTeachHerAGoodSaying"] = 0x5EC2AE, - ["gText_TogepiDoll48BP"] = 0x5ECC18, - ["gText_TooBadBagIsFull"] = 0x272AD0, - ["gText_TooImportantToToss"] = 0x5E8EB9, - ["gText_TorchicDollAndPrice"] = 0x5EAFCF, - ["gText_Toss"] = 0x5EEE40, - ["gText_Toss2"] = 0x5EA7C3, - ["gText_TossHowManyVar1s"] = 0x5E8EE0, - ["gText_TossItem"] = 0x5EAB71, - ["gText_Total"] = 0x5EE332, - ["gText_TotalRecordWLD"] = 0x5EF6A1, - ["gText_TotodileDoll80BP"] = 0x5ECC91, - ["gText_TouchedJudgeForFantasticAppeal"] = 0x27DA85, - ["gText_Tough"] = 0x5EB0FF, - ["gText_Tough2"] = 0x5EB136, - ["gText_ToughMove"] = 0x27E8BF, - ["gText_Toughness"] = 0x5EC7D6, - ["gText_ToughnessContest"] = 0x5EADE7, - ["gText_TourneyTree"] = 0x5EB496, - ["gText_Tourney_Tree"] = 0x5EB1A9, - ["gText_TowerInfo"] = 0x5EB42F, - ["gText_Toxic"] = 0x5EAE45, - ["gText_Trade"] = 0x5ED41D, - ["gText_Trade4"] = 0x5E96DD, - ["gText_TradeCenter"] = 0x5EB372, - ["gText_TrainerCardIDNo"] = 0x5ECF78, - ["gText_TrainerCardMoney"] = 0x5ECF7E, - ["gText_TrainerCardName"] = 0x5ECF71, - ["gText_TrainerCardPokedex"] = 0x5ECF86, - ["gText_TrainerCardTime"] = 0x5ECF99, - ["gText_TrainerCloseBy"] = 0x5EBF95, - ["gText_TrainerHill1F"] = 0x5EF8B6, - ["gText_TrainerHill2F"] = 0x5EF8B9, - ["gText_TrainerHill3F"] = 0x5EF8BC, - ["gText_TrainerHill4F"] = 0x5EF8BF, - ["gText_TreeckoDollAndPrice"] = 0x5EAFB6, - ["gText_TriedToDazzleOthers"] = 0x27DCE7, - ["gText_TriedToJamMonDoingWell"] = 0x27DE14, - ["gText_TriedToStartleOtherMons"] = 0x27DCB4, - ["gText_TriedToStartleOtherPokemon"] = 0x27E473, - ["gText_TriedToUnnerveNextMon"] = 0x27DD3D, - ["gText_Tristan"] = 0x5EAB0F, - ["gText_TuckerDefeatGold"] = 0x2C84F7, - ["gText_TuckerDefeatSilver"] = 0x2C84C1, - ["gText_TuckerWonGold"] = 0x2C84D0, - ["gText_TuckerWonSilver"] = 0x2C848B, - ["gText_TurnOff"] = 0x5EAB2D, - ["gText_TurnOrderMoreEasilyScrambled"] = 0x27D336, - ["gText_TurnedOverVar1ForVar2"] = 0x5E9677, - ["gText_TwoDashes"] = 0x5E8D42, - ["gText_TwoStyles"] = 0x5EB3DF, - ["gText_TypeSlash"] = 0x5EA378, - ["gText_TypesOfContests"] = 0x5EAD84, - ["gText_UndergoingAdjustments"] = 0x2730BC, - ["gText_Underpowered"] = 0x5EB516, - ["gText_UnionRoomChatKeyboard_01234Lower"] = 0x62BA59, - ["gText_UnionRoomChatKeyboard_01234Upper"] = 0x62BA1D, - ["gText_UnionRoomChatKeyboard_56789Lower"] = 0x62BA5F, - ["gText_UnionRoomChatKeyboard_56789Upper"] = 0x62BA23, - ["gText_UnionRoomChatKeyboard_ABCDE"] = 0x62B9F9, - ["gText_UnionRoomChatKeyboard_Emoji1"] = 0x62BA79, - ["gText_UnionRoomChatKeyboard_Emoji10"] = 0x62BADA, - ["gText_UnionRoomChatKeyboard_Emoji2"] = 0x62BA84, - ["gText_UnionRoomChatKeyboard_Emoji3"] = 0x62BA8F, - ["gText_UnionRoomChatKeyboard_Emoji4"] = 0x62BA9A, - ["gText_UnionRoomChatKeyboard_Emoji5"] = 0x62BAA3, - ["gText_UnionRoomChatKeyboard_Emoji6"] = 0x62BAAE, - ["gText_UnionRoomChatKeyboard_Emoji7"] = 0x62BAB9, - ["gText_UnionRoomChatKeyboard_Emoji8"] = 0x62BAC4, - ["gText_UnionRoomChatKeyboard_Emoji9"] = 0x62BACF, - ["gText_UnionRoomChatKeyboard_FGHIJ"] = 0x62B9FF, - ["gText_UnionRoomChatKeyboard_KLMNO"] = 0x62BA05, - ["gText_UnionRoomChatKeyboard_PQRST"] = 0x62BA0B, - ["gText_UnionRoomChatKeyboard_PunctuationLower"] = 0x62BA65, - ["gText_UnionRoomChatKeyboard_PunctuationUpper"] = 0x62BA29, - ["gText_UnionRoomChatKeyboard_SymbolsLower"] = 0x62BA6B, - ["gText_UnionRoomChatKeyboard_SymbolsUpper"] = 0x62BA2F, - ["gText_UnionRoomChatKeyboard_UVWXY"] = 0x62BA11, - ["gText_UnionRoomChatKeyboard_Z"] = 0x62BA17, - ["gText_UnionRoomChatKeyboard_abcde"] = 0x62BA35, - ["gText_UnionRoomChatKeyboard_fghij"] = 0x62BA3B, - ["gText_UnionRoomChatKeyboard_klmno"] = 0x62BA41, - ["gText_UnionRoomChatKeyboard_pqrst"] = 0x62BA47, - ["gText_UnionRoomChatKeyboard_uvwxy"] = 0x62BA4D, - ["gText_UnionRoomChatKeyboard_z"] = 0x62BA53, - ["gText_UnionTradesAndBattles"] = 0x5ED01F, - ["gText_UniqueTagMatch"] = 0x5EB350, - ["gText_UnkHeight"] = 0x5E86C6, - ["gText_UnkWeight"] = 0x5E86D0, - ["gText_Unknown"] = 0x5EC00F, - ["gText_UnusedCancel"] = 0x5ED92A, - ["gText_UnusedExit"] = 0x5EBE89, - ["gText_UnusedNicknameReceivedPokemon"] = 0x272DB3, - ["gText_UpdatedTime"] = 0x5EE6B6, - ["gText_UpdatingSaveExternalData"] = 0x5E836D, - ["gText_UponLosingABattle"] = 0x5EC5E0, - ["gText_UponWinningABattle"] = 0x5EC5C9, - ["gText_Upper"] = 0x5ED23C, - ["gText_UpsConditionHelpsPreventNervousness"] = 0x27D26E, - ["gText_UseOnWhichPokemon"] = 0x5E9E79, - ["gText_UsedRepeatedlyWithoutBoringJudge"] = 0x27CC03, - ["gText_UsedVar2WildLured"] = 0x5E90C6, - ["gText_UsedVar2WildRepelled"] = 0x5E90F4, - ["gText_UserLessLikelyStartled"] = 0x27CCAE, - ["gText_UserMoreEasilyStartled"] = 0x27CB9B, - ["gText_Var1AndYouWantedVar2"] = 0x5E943F, - ["gText_Var1AteTheVar2"] = 0x5E937F, - ["gText_Var1Berry"] = 0x5ED7FC, - ["gText_Var1CantBeHeld"] = 0x5E8E11, - ["gText_Var1CantBeHeldHere"] = 0x5E8E27, - ["gText_Var1CertainlyHowMany"] = 0x5E93EF, - ["gText_Var1CertainlyHowMany2"] = 0x5E9417, - ["gText_Var1DisdainfullyAteVar2"] = 0x5E93A9, - ["gText_Var1DotVar2"] = 0x5E9233, - ["gText_Var1HappilyAteVar2"] = 0x5E9390, - ["gText_Var1IsItThatllBeVar2"] = 0x5E9468, - ["gText_Var1IsSelected"] = 0x5E8DBE, - ["gText_Var1Percent"] = 0x5ED85C, - ["gText_Var1Players"] = 0x5ED8F4, - ["gText_Var1ThrownAway"] = 0x5E9367, - ["gText_Var1sTrainerCard"] = 0x5ECFA6, - ["gText_Var3Var1SlashVar2"] = 0x5EA7A9, - ["gText_VarietyOfEventsImportedWireless"] = 0x5EEE48, - ["gText_VarietyTagMatch"] = 0x5EB33E, - ["gText_VenusaurDoll256BP"] = 0x5ECCCF, - ["gText_VeryGood"] = 0x5ECB20, - ["gText_Vicegrip"] = 0x27D4CC, - ["gText_ViewRecordedBattle"] = 0x5ED961, - ["gText_VolcanoAsh"] = 0x5EFC6E, - ["gText_Vs"] = 0x5CCDB5, - ["gText_WTWeight"] = 0x5E8758, - ["gText_WaitForAllChooseBerry"] = 0x5ED62C, - ["gText_WaitingTrainerFinishReading"] = 0x5ED042, - ["gText_Wallace"] = 0x5EAD0C, - ["gText_WallyMatchCallDesc"] = 0x5EFB3E, - ["gText_WantToPlayAgain"] = 0x5EDE18, - ["gText_WantToPlayAgain2"] = 0x5EDF2A, - ["gText_WantToUseSurf"] = 0x272FD6, - ["gText_WasEnhanced"] = 0x5EC7E1, - ["gText_WasItWorriedAboutYou"] = 0x5EB987, - ["gText_WattsonMatchCallDesc"] = 0x5EFB7B, - ["gText_WeHopeToSeeYouAgain"] = 0x2727DB, - ["gText_WeHopeToSeeYouAgain2"] = 0x272A07, - ["gText_WelcomeCutShort"] = 0x2727F5, - ["gText_WelcomeToHOF"] = 0x5E8B3E, - ["gText_WentBetterThanUsual"] = 0x27D612, - ["gText_WhatDoYouWantToDo"] = 0x5EB802, - ["gText_WhatToDoWithCards"] = 0x5EF2FC, - ["gText_WhatToDoWithNews"] = 0x5EF330, - ["gText_WhatToDoWithVar1sMail"] = 0x5EAC63, - ["gText_WhatWillWallyDo"] = 0x5CCA15, - ["gText_WhatWouldYouLike"] = 0x5E8D80, - ["gText_WhatWouldYouLikeToDo"] = 0x5EBB56, - ["gText_WhatYouSayIfYouLose"] = 0x5EC1D6, - ["gText_WhatYouSayIfYouWin"] = 0x5EC1B5, - ["gText_WhatsAContest"] = 0x5EAD72, - ["gText_WhatsHipAndHappening"] = 0x5EC606, - ["gText_Wheelies"] = 0x5EAF1B, - ["gText_WhenInDanger"] = 0x5EB523, - ["gText_WhereShouldCardBeAccessed"] = 0x5EEF68, - ["gText_WhereShouldNewsBeAccessed"] = 0x5EEF94, - ["gText_WhichFloorWouldYouLike"] = 0x272C1D, - ["gText_WhichMoveToForget"] = 0x5E9B96, - ["gText_WhichMoveToForget4"] = 0x5CCA93, - ["gText_WhichOneWillYouTake"] = 0x5EB92D, - ["gText_WhichPCShouldBeAccessed"] = 0x27266F, - ["gText_WhiteFlute"] = 0x5EB639, - ["gText_WhiteFlute2"] = 0x5EAF87, - ["gText_WhiteHerb48BP"] = 0x5ECD6F, - ["gText_WhitePokeblock"] = 0x5E9306, - ["gText_WillChampionshipDreamComeTrue"] = 0x5CCD45, - ["gText_Win"] = 0x5CCB9E, - ["gText_WinLoseDraw"] = 0x5EF6BD, - ["gText_WinStreak"] = 0x5EE302, - ["gText_WinnerIsPlayersMon"] = 0x27E910, - ["gText_WinnerIsPlayersMonCongrats"] = 0x27E8E8, - ["gText_Winona"] = 0x5EAD22, - ["gText_WinonaMatchCallDesc"] = 0x5EFB94, - ["gText_WinsLosses"] = 0x5ECFED, - ["gText_WinsStraight"] = 0x5ED0E0, - ["gText_WirelessCommStatus"] = 0x5EED41, - ["gText_WirelessCommunication"] = 0x5EEE08, - ["gText_WirelessNotConnected"] = 0x5E82BE, - ["gText_WithFourPhrases"] = 0x5EC104, - ["gText_WithdrawHowManyItems"] = 0x5EAC09, - ["gText_WithdrawItem"] = 0x5EAB63, - ["gText_WithdrawMonDescription"] = 0x5EBBB2, - ["gText_WithdrawPokemon"] = 0x5EBB71, - ["gText_WithdrawXItems"] = 0x5EAC22, - ["gText_WonContestsWFriends"] = 0x5ED09F, - ["gText_WonderCardReceived"] = 0x5EF124, - ["gText_WonderCardReceivedFrom"] = 0x5EF0C8, - ["gText_WonderCardSentTo"] = 0x5EF39C, - ["gText_WonderCardThrownAway"] = 0x5EF60C, - ["gText_WonderCards"] = 0x5EEDEC, - ["gText_WonderCardsInPossession"] = 0x5EEE8C, - ["gText_WonderNews"] = 0x5EEDFC, - ["gText_WonderNewsReceived"] = 0x5EF14C, - ["gText_WonderNewsReceivedFrom"] = 0x5EF0F4, - ["gText_WonderNewsSentTo"] = 0x5EF3C4, - ["gText_WonderNewsThrownAway"] = 0x5EF630, - ["gText_WontEatAnymore"] = 0x5EC800, - ["gText_WontHaveEffect"] = 0x5E96F6, - ["gText_WorkedAsMuchAsMonBefore"] = 0x27DEA5, - ["gText_WorkedAsMuchAsPrecedingMon"] = 0x27DF02, - ["gText_WorkedFrighteninglyWell"] = 0x27D69C, - ["gText_WorkedHardAsStandoutMon"] = 0x27D6BA, - ["gText_WorkedHardToBuildOnPastMistakes"] = 0x27D654, - ["gText_WorkedLittleBetterThanUsual"] = 0x27D71D, - ["gText_WorkedRatherWell"] = 0x27D706, - ["gText_WorksWellIfDifferentTypeAsBefore"] = 0x27D20A, - ["gText_WorksWellIfSameTypeAsBefore"] = 0x27D1D6, - ["gText_WorsenConditionOfThoseMadeAppeals"] = 0x27D061, - ["gText_WouldYouLikeToRestYourPkmn"] = 0x2726EB, - ["gText_XDotY"] = 0x5ED7F4, - ["gText_XDotY2"] = 0x5ED838, - ["gText_XDotY3"] = 0x5ED848, - ["gText_XMinYDotZSec"] = 0x5EF8A3, - ["gText_XNature"] = 0x5EA5DB, - ["gText_XNatureFatefulEncounter"] = 0x5EA56A, - ["gText_XNatureHatchedAtYZ"] = 0x5EA516, - ["gText_XNatureHatchedSomewhereAt"] = 0x5EA617, - ["gText_XNatureMetAtYZ"] = 0x5EA4EC, - ["gText_XNatureMetSomewhereAt"] = 0x5EA5EB, - ["gText_XNatureObtainedInTrade"] = 0x5EA544, - ["gText_XNatureProbablyMetAt"] = 0x5EA5A8, - ["gText_XSentOverY"] = 0x5EE966, - ["gText_XWillBeSentToY"] = 0x5EE942, - ["gText_YaySmileEmoji"] = 0x5ED43A, - ["gText_YellowFlute"] = 0x5EB622, - ["gText_YellowFlute2"] = 0x5EAF70, - ["gText_YellowPokeblock"] = 0x5E92A1, - ["gText_YellowShard"] = 0x5EB04A, - ["gText_Yes"] = 0x5EAE62, - ["gText_Yes2"] = 0x5EEC49, - ["gText_Yes3"] = 0x5EECCA, - ["gText_YesNo"] = 0x5EE491, - ["gText_YouCannotQuitHere"] = 0x5EC672, - ["gText_YouCantReleaseAnEgg"] = 0x5EB946, - ["gText_YouDontHaveMoney"] = 0x5E9539, - ["gText_YouDontHaveThreeCoins"] = 0x5EF734, - ["gText_YouHaveNoBerries"] = 0x5ED724, - ["gText_YouWantTheUsual"] = 0x272982, - ["gText_YouWantedVar1ThatllBeVar2"] = 0x5E9493, - ["gText_YourFeelingAtTheBattlesStart"] = 0x5EC192, - ["gText_YourName"] = 0x5EEABE, - ["gText_YourPartnerHasRetired"] = 0x24979B, - ["gText_YourPartysFull"] = 0x5EB900, - ["gText_YourProfile"] = 0x5EC185, - ["gText_YoureHoldingAPkmn"] = 0x5EB913, - ["gText_YouveGot9999Coins"] = 0x5EF6F8, - ["gText_YouveRunOutOfCoins"] = 0x5EF710, - ["gText_Zinc1000"] = 0x5EB74B, - ["gText_Zinc1BP"] = 0x5ECD3A, - ["gText_xVar1"] = 0x5E8DA0, - ["gTrickHouse_Mechadoll_55Yen"] = 0x27ED74, - ["gTrickHouse_Mechadoll_60Yen"] = 0x27ED70, - ["gTrickHouse_Mechadoll_Azurill"] = 0x27ECD5, - ["gTrickHouse_Mechadoll_BurnHeal"] = 0x27ED4F, - ["gTrickHouse_Mechadoll_Carvanha"] = 0x27ED46, - ["gTrickHouse_Mechadoll_CostLess"] = 0x27ED95, - ["gTrickHouse_Mechadoll_CostMore"] = 0x27ED80, - ["gTrickHouse_Mechadoll_Dustox"] = 0x27ECEB, - ["gTrickHouse_Mechadoll_Eight"] = 0x27EE03, - ["gTrickHouse_Mechadoll_Eight2"] = 0x27EE09, - ["gTrickHouse_Mechadoll_ElderlyLadies"] = 0x27EDD5, - ["gTrickHouse_Mechadoll_ElderlyMen"] = 0x27EDC9, - ["gTrickHouse_Mechadoll_Female"] = 0x27EDBA, - ["gTrickHouse_Mechadoll_Four"] = 0x27EDFD, - ["gTrickHouse_Mechadoll_HarborMail"] = 0x27ED59, - ["gTrickHouse_Mechadoll_Lotad"] = 0x27ECDD, - ["gTrickHouse_Mechadoll_Male"] = 0x27EDB5, - ["gTrickHouse_Mechadoll_Neither"] = 0x27EDC1, - ["gTrickHouse_Mechadoll_Nincada"] = 0x27ECF8, - ["gTrickHouse_Mechadoll_None"] = 0x27EDF0, - ["gTrickHouse_Mechadoll_Nothing"] = 0x27ED78, - ["gTrickHouse_Mechadoll_Oddish"] = 0x27ECBC, - ["gTrickHouse_Mechadoll_One"] = 0x27EDF5, - ["gTrickHouse_Mechadoll_Poochyena"] = 0x27ECC3, - ["gTrickHouse_Mechadoll_Poochyena2"] = 0x27ED18, - ["gTrickHouse_Mechadoll_Poochyena3"] = 0x27ED36, - ["gTrickHouse_Mechadoll_Ralts"] = 0x27ED00, - ["gTrickHouse_Mechadoll_SameNumber"] = 0x27EDE4, - ["gTrickHouse_Mechadoll_SamePrice"] = 0x27ED65, - ["gTrickHouse_Mechadoll_SamePrice2"] = 0x27EDAA, - ["gTrickHouse_Mechadoll_Seven"] = 0x27EE01, - ["gTrickHouse_Mechadoll_Seven2"] = 0x27EE07, - ["gTrickHouse_Mechadoll_Shroomish"] = 0x27ED22, - ["gTrickHouse_Mechadoll_Six"] = 0x27EDFF, - ["gTrickHouse_Mechadoll_Six2"] = 0x27EE05, - ["gTrickHouse_Mechadoll_Slakoth"] = 0x27ED10, - ["gTrickHouse_Mechadoll_Taillow"] = 0x27ECCD, - ["gTrickHouse_Mechadoll_Three"] = 0x27EDFB, - ["gTrickHouse_Mechadoll_Two"] = 0x27EDF7, - ["gTrickHouse_Mechadoll_Two2"] = 0x27EDF9, - ["gTrickHouse_Mechadoll_Wingull"] = 0x27ECE3, - ["gTrickHouse_Mechadoll_Zigzagoon"] = 0x27ED06, - ["gTrickHouse_Mechadoll_Zigzagoon2"] = 0x27ED2C, - ["gTrickHouse_Mechadoll_Zubat"] = 0x27ECF2, - ["gTrickHouse_Mechadoll_Zubat2"] = 0x27ED40, - ["sCreditsText_AiMashima"] = 0x5E6264, - ["sCreditsText_AimiTomita"] = 0x5E5FB9, - ["sCreditsText_AkihitoTomisawa"] = 0x5E60BF, - ["sCreditsText_AkikoShinozaki"] = 0x5E6152, - ["sCreditsText_AkiraKinashi"] = 0x5E6229, - ["sCreditsText_AkitoMori"] = 0x5E5EF8, - ["sCreditsText_ArtDirector"] = 0x5E5C34, - ["sCreditsText_Artwork"] = 0x5E5E62, - ["sCreditsText_AstukoFujii"] = 0x5E6162, - ["sCreditsText_AsukaIwashita"] = 0x5E5FAA, - ["sCreditsText_AtsukoNishida"] = 0x5E5FE6, - ["sCreditsText_AtsushiTada"] = 0x5E61CC, - ["sCreditsText_AustralianBrailleAuthority"] = 0x5E6575, - ["sCreditsText_AzusaTajima"] = 0x5E63C6, - ["sCreditsText_BattleDirector"] = 0x5E5C41, - ["sCreditsText_BattleFrontierData"] = 0x5E5E39, - ["sCreditsText_BattleSystemPgrms"] = 0x5E5C61, - ["sCreditsText_BrailleCodeCheck"] = 0x5E5E17, - ["sCreditsText_ChiakiShinkai"] = 0x5E64A4, - ["sCreditsText_ChieMatsumiya"] = 0x5E6143, - ["sCreditsText_Coordinators"] = 0x5E5DC6, - ["sCreditsText_Credits"] = 0x5E5C10, - ["sCreditsText_DaisukeHoshino"] = 0x5E634C, - ["sCreditsText_Director"] = 0x5E5C2B, - ["sCreditsText_EmptyString"] = 0x5E5BF7, - ["sCreditsText_EnglishVersion"] = 0x5E63FF, - ["sCreditsText_EnvAndToolPgrms"] = 0x5E5D84, - ["sCreditsText_ErikJohnson"] = 0x5E65ED, - ["sCreditsText_EuropeanBlindUnion"] = 0x5E6560, - ["sCreditsText_ExecProducers"] = 0x5E5DDD, - ["sCreditsText_ExecutiveDirector"] = 0x5E5C18, - ["sCreditsText_FieldSystemPgrms"] = 0x5E5C7B, - ["sCreditsText_FujikoNomura"] = 0x5E6398, - ["sCreditsText_GakujiNomoto"] = 0x5E626F, - ["sCreditsText_GameDesigners"] = 0x5E5D0A, - ["sCreditsText_GoIchinose"] = 0x5E6010, - ["sCreditsText_GraphicDesigner"] = 0x5E6443, - ["sCreditsText_GraphicDesigners"] = 0x5E5CB5, - ["sCreditsText_HidenoriSaeki"] = 0x5E647A, - ["sCreditsText_HideyukiNakajima"] = 0x5E6468, - ["sCreditsText_HiroNakamura"] = 0x5E61FB, - ["sCreditsText_HiroakiTsuru"] = 0x5E62CA, - ["sCreditsText_HirokiEnomoto"] = 0x5E60D0, - ["sCreditsText_HiromiSagawa"] = 0x5E60FC, - ["sCreditsText_HironobuYoshida"] = 0x5E5F7A, - ["sCreditsText_HiroyukiJinnai"] = 0x5E62BA, - ["sCreditsText_HiroyukiNakamura"] = 0x5E5F22, - ["sCreditsText_HiroyukiUesugi"] = 0x5E6209, - ["sCreditsText_HisashiSogabe"] = 0x5E5EDB, - ["sCreditsText_HitomiSato"] = 0x5E6055, - ["sCreditsText_HitoshiYamagami"] = 0x5E628D, - ["sCreditsText_InfoSupervisors"] = 0x5E5DF1, - ["sCreditsText_JapanBrailleLibrary"] = 0x5E6317, - ["sCreditsText_JunOkutani"] = 0x5E5FDA, - ["sCreditsText_JunichiMasuda"] = 0x5E5E9D, - ["sCreditsText_KanakoEo"] = 0x5E5FD0, - ["sCreditsText_KazukiYoshihara"] = 0x5E63A6, - ["sCreditsText_KazuyaSuyama"] = 0x5E62F7, - ["sCreditsText_KazuyukiTerada"] = 0x5E60DF, - ["sCreditsText_KeitaKagaya"] = 0x5E5F03, - ["sCreditsText_KenSugimori"] = 0x5E5EAC, - ["sCreditsText_KenjiMatsushima"] = 0x5E6037, - ["sCreditsText_KenjiTominaga"] = 0x5E610A, - ["sCreditsText_KenjiroIto"] = 0x5E635C, - ["sCreditsText_KenkichiToyama"] = 0x5E617C, - ["sCreditsText_KimikoNakamichi"] = 0x5E64E0, - ["sCreditsText_KojiNishino"] = 0x5E602A, - ["sCreditsText_KyokoWatanabe"] = 0x5E629E, - ["sCreditsText_LeadGraphicArtist"] = 0x5E5E7A, - ["sCreditsText_LeadProgrammer"] = 0x5E5E6A, - ["sCreditsText_MainGraphicDesigner"] = 0x5E5C9F, - ["sCreditsText_MainProgrammer"] = 0x5E5C51, - ["sCreditsText_MakikoTakada"] = 0x5E6248, - ["sCreditsText_MapDataDesigners"] = 0x5E5D4F, - ["sCreditsText_MapDesigners"] = 0x5E5D41, - ["sCreditsText_MasaoTaya"] = 0x5E5F34, - ["sCreditsText_MichikoTakizawa"] = 0x5E6237, - ["sCreditsText_MikaKurosawa"] = 0x5E651E, - ["sCreditsText_MikikoOhhashi"] = 0x5E633D, - ["sCreditsText_MiyukiIwasawa"] = 0x5E5F5D, - ["sCreditsText_MorikazuAoki"] = 0x5E601C, - ["sCreditsText_MotofumiFujiwara"] = 0x5E5F8B, - ["sCreditsText_MotoyasuTojima"] = 0x5E65BD, - ["sCreditsText_MuneoSaito"] = 0x5E5FF5, - ["sCreditsText_MusicComposition"] = 0x5E5CD9, - ["sCreditsText_NCLCoordinator"] = 0x5E6433, - ["sCreditsText_NCLProductTesting"] = 0x5E5DA3, - ["sCreditsText_NCLSuperMarioClub"] = 0x5E61B7, - ["sCreditsText_NOAProductTesting"] = 0x5E6454, - ["sCreditsText_NaokoYanase"] = 0x5E61AA, - ["sCreditsText_NationalFederationBlind"] = 0x5E652C, - ["sCreditsText_NicolaPrattBarlow"] = 0x5E65CD, - ["sCreditsText_NobOgasawara"] = 0x5E64C1, - ["sCreditsText_NorihideOkamura"] = 0x5E61EA, - ["sCreditsText_NozomuSaito"] = 0x5E616F, - ["sCreditsText_PackageAndManual"] = 0x5E63E1, - ["sCreditsText_ParametricDesigners"] = 0x5E5D62, - ["sCreditsText_PatriciaAMaurer"] = 0x5E654D, - ["sCreditsText_PkmnDesigners"] = 0x5E5CC7, - ["sCreditsText_PkmnEmeraldVersion"] = 0x5E5BF8, - ["sCreditsText_PokedexText"] = 0x5E5D77, - ["sCreditsText_Producers"] = 0x5E5DD3, - ["sCreditsText_Programmers"] = 0x5E5C93, - ["sCreditsText_RenaYoshikawa"] = 0x5E6001, - ["sCreditsText_RetsujiNomoto"] = 0x5E63B7, - ["sCreditsText_RoyalNewZealandFederationBlind"] = 0x5E6592, - ["sCreditsText_RuiKawaguchi"] = 0x5E6368, - ["sCreditsText_SachikoHamano"] = 0x5E6134, - ["sCreditsText_SachikoNakamichi"] = 0x5E6386, - ["sCreditsText_SakaeKimura"] = 0x5E6497, - ["sCreditsText_SatoruIwata"] = 0x5E62EA, - ["sCreditsText_SatoshiMitsuhara"] = 0x5E6305, - ["sCreditsText_SatoshiNohara"] = 0x5E5F3F, - ["sCreditsText_SatoshiOhta"] = 0x5E5F9D, - ["sCreditsText_SatoshiTajiri"] = 0x5E5E8E, - ["sCreditsText_Scenario"] = 0x5E5D27, - ["sCreditsText_ScenarioPlot"] = 0x5E5D19, - ["sCreditsText_ScriptDesigners"] = 0x5E5D30, - ["sCreditsText_SethMcMahill"] = 0x5E64B3, - ["sCreditsText_ShellieDow"] = 0x5E65E1, - ["sCreditsText_ShigekiMorimoto"] = 0x5E5EB9, - ["sCreditsText_ShigeruOhmori"] = 0x5E608A, - ["sCreditsText_ShunsukeKohori"] = 0x5E6376, - ["sCreditsText_ShusakuEgami"] = 0x5E63D3, - ["sCreditsText_SosukeTamada"] = 0x5E5EEA, - ["sCreditsText_SouichiYamamoto"] = 0x5E64F1, - ["sCreditsText_SoundEffectsAndPkmnVoices"] = 0x5E5CEB, - ["sCreditsText_SpecialThanks"] = 0x5E5DB7, - ["sCreditsText_SuguruNakatsui"] = 0x5E618C, - ["sCreditsText_SupportProgrammers"] = 0x5E5E4E, - ["sCreditsText_TadashiTakahashi"] = 0x5E6099, - ["sCreditsText_TakahiroOhnishi"] = 0x5E61D9, - ["sCreditsText_TakanaoKondo"] = 0x5E6256, - ["sCreditsText_TakaoNakano"] = 0x5E62AD, - ["sCreditsText_TakaoUnno"] = 0x5E5FC5, - ["sCreditsText_TakehiroIzushi"] = 0x5E627D, - ["sCreditsText_TakenoriOhta"] = 0x5E5F6C, - ["sCreditsText_TakeshiKawachimaru"] = 0x5E6061, - ["sCreditsText_TaskManagers"] = 0x5E5E09, - ["sCreditsText_TeikoSasaki"] = 0x5E6127, - ["sCreditsText_TeresaLillygren"] = 0x5E64CF, - ["sCreditsText_TerukiMurakawa"] = 0x5E6219, - ["sCreditsText_TeruyukiShimoyamada"] = 0x5E6075, - ["sCreditsText_TetsujiOhta"] = 0x5E6048, - ["sCreditsText_TetsuyaWatanabe"] = 0x5E5ECA, - ["sCreditsText_TextEditor"] = 0x5E6427, - ["sCreditsText_ThomasHertzog"] = 0x5E650F, - ["sCreditsText_TomomichiOhta"] = 0x5E5F4E, - ["sCreditsText_TomotakaKomura"] = 0x5E632D, - ["sCreditsText_ToshinobuMatsumiya"] = 0x5E60AB, - ["sCreditsText_Translator"] = 0x5E641C, - ["sCreditsText_TsunekazIshihara"] = 0x5E62D8, - ["sCreditsText_WorldDirector"] = 0x5E5E2A, - ["sCreditsText_YokoWatanabe"] = 0x5E6489, - ["sCreditsText_YoshinoriMatsuda"] = 0x5E5F10, - ["sCreditsText_YoshioTajiri"] = 0x5E6119, - ["sCreditsText_YuichiroIto"] = 0x5E6502, - ["sCreditsText_YumiFunasaka"] = 0x5E619C, - ["sCreditsText_YuriSakurai"] = 0x5E60EF, - ["sEmptyItemName"] = 0x625403, - ["sExpandedPlaceholder_PokedexDescription"] = 0x55D30C, - ["sGenderlessIconString"] = 0x624BE4, - ["sJPText_BadTvReady"] = 0x339CDF, - ["sJPText_Chat"] = 0x2EDA0C, - ["sJPText_DistMysteryEvent"] = 0x2EDA38, - ["sJPText_DistWonderCard"] = 0x2EDA1C, - ["sJPText_DistWonderNews"] = 0x2EDA28, - ["sJPText_DoubleBattle"] = 0x2ED9D8, - ["sJPText_GoodTvReady"] = 0x339CD2, - ["sJPText_HoldBerryCrush"] = 0x2EDA5C, - ["sJPText_HoldBerryPicking"] = 0x2EDA70, - ["sJPText_HoldPokemonJump"] = 0x2EDA4C, - ["sJPText_HoldSpinShop"] = 0x2EDA94, - ["sJPText_HoldSpinTrade"] = 0x2EDA80, - ["sJPText_MultiBattle"] = 0x2ED9E8, - ["sJPText_PressBButtonToQuit"] = 0x32DB2E, - ["sJPText_SearchingForParticipants"] = 0x2EDBD0, - ["sJPText_SingleBattle"] = 0x2ED9C8, - ["sJPText_TradePokemon"] = 0x2ED9F8, - ["sJPText_Vowels"] = 0x2F7B78, - ["sJapaneseEggNickname"] = 0x32B708, - ["sMemoMiscTextColor"] = 0x61CE7B, - ["sMemoNatureTextColor"] = 0x61CE74, - ["sMovesPPLayout"] = 0x61CE97, - ["sStatsLeftColumnLayout"] = 0x61CE82, - ["sStatsRightColumnLayout"] = 0x61CE8E, - ["sText_123Poof"] = 0x5CB447, - ["sText_1PlayerNeeded"] = 0x2EDAE0, - ["sText_2PlayerMode"] = 0x2EDB27, - ["sText_2PlayersNeeded"] = 0x2EDAF1, - ["sText_3PlayerMode"] = 0x2EDB35, - ["sText_3PlayersNeeded"] = 0x2EDB03, - ["sText_4PlayerMode"] = 0x2EDB43, - ["sText_4PlayersNeeded"] = 0x2EDB15, - ["sText_5PlayerMode"] = 0x2EDB51, - ["sText_ABoosted"] = 0x5C9B99, - ["sText_AarghAlmostHadIt"] = 0x5CBF6A, - ["sText_Accuracy"] = 0x5CBDE9, - ["sText_AllyPkmnPrefix"] = 0x5CBD95, - ["sText_AllyPkmnPrefix2"] = 0x5CBD9E, - ["sText_AllyPkmnPrefix3"] = 0x5CBDA7, - ["sText_AlphabetUpperLower"] = 0x58BDC8, - ["sText_AnOKWasSentToPlayer"] = 0x2EDC9C, - ["sText_AndEllipsis"] = 0x5CB473, - ["sText_ApostropheS"] = 0x5CCBB9, - ["sText_ApostropheSPokeblockCaseIsFull"] = 0x3398DD, - ["sText_AreTheseMembersOK"] = 0x2EDC5C, - ["sText_AskTrainerToMakeTrade"] = 0x2EF65C, - ["sText_Attack"] = 0x5CCAF4, - ["sText_Attack2"] = 0x5CBDC4, - ["sText_AuroraTicketBagFull"] = 0x675497, - ["sText_AuroraTicketForYou"] = 0x6753A9, - ["sText_AuroraTicketThankYou"] = 0x67546A, - ["sText_AuroraTicketUseAtPort"] = 0x675406, - ["sText_AwaitingCommunication"] = 0x2ED960, - ["sText_AwaitingCommunucation2"] = 0x2ED934, - ["sText_AwaitingLinkPressStart"] = 0x2ED990, - ["sText_AwaitingOtherMembers"] = 0x2EDE48, - ["sText_AwaitingPlayersResponse"] = 0x2EFC3C, - ["sText_AwaitingPlayersResponseAboutTrade"] = 0x2EE004, - ["sText_AwaitingResponseFromPlayer"] = 0x2EE358, - ["sText_AwaitingResponseFromTrainer"] = 0x2EE32C, - ["sText_AwaitingResponseFromTrainer2"] = 0x2EF688, - ["sText_AwaitingResponseFromWirelessSystem"] = 0x2EFC90, - ["sText_BButtonCancel"] = 0x2EDBC4, - ["sText_Battle"] = 0x2EFD60, - ["sText_BattleChallenge"] = 0x2EE3DC, - ["sText_BattleDeclinedFemale"] = 0x2EEA14, - ["sText_BattleDeclinedMale"] = 0x2EE9B4, - ["sText_BattleSurpriseFemale"] = 0x2EEE4C, - ["sText_BattleSurpriseMale"] = 0x2EED98, - ["sText_BattleTowerLv50"] = 0x2EFEC0, - ["sText_BattleTowerOpenLv"] = 0x2EFED4, - ["sText_BattleWillBeStarted"] = 0x2EE94C, - ["sText_BeautyContest"] = 0x2EFE80, - ["sText_BellChimed"] = 0x5CB8CF, - ["sText_BerryBlender"] = 0x2EFE50, - ["sText_BerryBlenderStart"] = 0x33973A, - ["sText_BerryCrush"] = 0x2EFE2C, - ["sText_BerryPicking"] = 0x2EFE38, - ["sText_BerryProgramUpdate"] = 0x617E78, - ["sText_BerryProgramWillBeUpdatedPressA"] = 0x617EA3, - ["sText_BerrySuffix"] = 0x5CC0AD, - ["sText_BerryUsed"] = 0x339909, - ["sText_BlendingResults"] = 0x3398F5, - ["sText_BoxIsFull"] = 0x5CC074, - ["sText_ButItFailed"] = 0x5CB66F, - ["sText_ButNoEffect"] = 0x5CAECF, - ["sText_ButNoPPLeft"] = 0x5CB931, - ["sText_ButNothingHappened"] = 0x5CB659, - ["sText_CallingDots"] = 0x6227F4, - ["sText_Cancel"] = 0x32DAF9, - ["sText_CancelModeWithTheseMembers"] = 0x2EDC78, - ["sText_CancelRegistrationOfEgg"] = 0x2EF590, - ["sText_CancelRegistrationOfMon"] = 0x2EF564, - ["sText_CancelTrade"] = 0x32DB20, - ["sText_CanceledReadingCard"] = 0x2F257C, - ["sText_CantEscape"] = 0x5CB622, - ["sText_CantEscape2"] = 0x5C9D95, - ["sText_CantTradeForPartnersMon"] = 0x2EF774, - ["sText_CantTransmitTrainerTooFar"] = 0x2EDCEC, - ["sText_Cards"] = 0x2EFDF8, - ["sText_Chat"] = 0x2EFDF0, - ["sText_Chat2"] = 0x2EFD68, - ["sText_ChatDeclinedFemale"] = 0x2EECEC, - ["sText_ChatDeclinedMale"] = 0x2EECA4, - ["sText_ChatDropped"] = 0x2EE544, - ["sText_ChatEnded"] = 0x2EE598, - ["sText_ChatInvitation"] = 0x2EE430, - ["sText_ChooseAPkmn"] = 0x32DB00, - ["sText_ChooseJoinCancel"] = 0x2EF7DC, - ["sText_ChooseLeaderBattleTowerLv50"] = 0x2EFB08, - ["sText_ChooseLeaderBattleTowerOpenLv"] = 0x2EFB3C, - ["sText_ChooseLeaderBeautyContest"] = 0x2EFA50, - ["sText_ChooseLeaderBerryBlender"] = 0x2EF9CC, - ["sText_ChooseLeaderBerryCrush"] = 0x2EF974, - ["sText_ChooseLeaderBerryPicking"] = 0x2EF99C, - ["sText_ChooseLeaderCoolContest"] = 0x2EFA24, - ["sText_ChooseLeaderCuteContest"] = 0x2EFA7C, - ["sText_ChooseLeaderMultiBattle"] = 0x2EF874, - ["sText_ChooseLeaderPokemonJump"] = 0x2EF940, - ["sText_ChooseLeaderRecordCorner"] = 0x2EF9F8, - ["sText_ChooseLeaderSmartContest"] = 0x2EFAA8, - ["sText_ChooseLeaderToughContest"] = 0x2EFAD8, - ["sText_ChooseRequestedMonType"] = 0x2EF47C, - ["sText_ChooseTrainer"] = 0x2EF7F8, - ["sText_ChooseTrainerDoubleBattle"] = 0x2EF844, - ["sText_ChooseTrainerSingleBattle"] = 0x2EF814, - ["sText_ChooseTrainerToShareWonderCards"] = 0x2EF8D0, - ["sText_ChooseTrainerToShareWonderNews"] = 0x2EF908, - ["sText_ChooseTrainerToTradeWith"] = 0x2EF8A4, - ["sText_ChooseTrainerToTradeWith2"] = 0x2EF614, - ["sText_Clear17"] = 0x597C8C, - ["sText_CoinsScattered"] = 0x5CB86D, - ["sText_Colon"] = 0x2ED8C8, - ["sText_Communicating"] = 0x2EE02C, - ["sText_CommunicatingPleaseWait"] = 0x2EDFE8, - ["sText_CommunicatingWithPlayer"] = 0x2EE098, - ["sText_CommunicationStandby"] = 0x32DB49, - ["sText_CoolContest"] = 0x2EFE70, - ["sText_CriticalHit"] = 0x5CB424, - ["sText_CuteContest"] = 0x2EFE90, - ["sText_DashesNoPlayer"] = 0x5B3D84, - ["sText_DashesNoScore"] = 0x5B3D8C, - ["sText_DeclineChatMale"] = 0x2EEC50, - ["sText_Defense"] = 0x5CCAFB, - ["sText_Defense2"] = 0x5CBDCB, - ["sText_Digits"] = 0x2F7B88, - ["sText_DoSomethingAgainFemale"] = 0x2EE2B8, - ["sText_DoSomethingAgainMale"] = 0x2EE294, - ["sText_DoSomethingFemale"] = 0x2EE274, - ["sText_DoSomethingMale"] = 0x2EE25C, - ["sText_DoYouWantXMode"] = 0x2EDFA8, - ["sText_DoYouWantXMode2"] = 0x2EDFC4, - ["sText_DoneWaitingBattleFemale"] = 0x2EE8D4, - ["sText_DoneWaitingBattleMale"] = 0x2EE880, - ["sText_DoneWaitingChatFemale"] = 0x2EE904, - ["sText_DoneWaitingChatMale"] = 0x2EE8B8, - ["sText_DontBeAThief"] = 0x5CBEC8, - ["sText_DontHaveEggTrainerWants"] = 0x2EF718, - ["sText_DontHaveTypeTrainerWants"] = 0x2EF6E4, - ["sText_DontLeaveBirch"] = 0x5CB631, - ["sText_Dot"] = 0x339941, - ["sText_Dot2"] = 0x339970, - ["sText_DoubleBattle"] = 0x2EFDC0, - ["sText_DownpourContinues"] = 0x5CB6F7, - ["sText_DownpourStarted"] = 0x5CB6CB, - ["sText_Dude"] = 0x3397C4, - ["sText_EggTrade"] = 0x2EF7D0, - ["sText_ElectricityWeakened"] = 0x5CADC5, - ["sText_Ellipsis"] = 0x2F2DC4, - ["sText_Emerald"] = 0x617E9B, - ["sText_EmptyStatus"] = 0x5CD422, - ["sText_EmptyString4"] = 0x5C9B98, - ["sText_EmptyString8"] = 0x5CBD90, - ["sText_EnigmaBerry"] = 0x5CC0A0, - ["sText_EnsureGBAConnectionMatches"] = 0x617F07, - ["sText_EnteringChat"] = 0x2EE96C, - ["sText_Evasiveness"] = 0x5CBDF2, - ["sText_ExclamationMark"] = 0x5CBDB7, - ["sText_ExclamationMark2"] = 0x5CBDB9, - ["sText_ExclamationMark3"] = 0x5CBDBB, - ["sText_ExclamationMark4"] = 0x5CBDBD, - ["sText_ExclamationMark5"] = 0x5CBDBF, - ["sText_Exit"] = 0x2EFD7C, - ["sText_Exit2"] = 0x2EFD84, - ["sText_FailedToSpitUp"] = 0x5CB7E5, - ["sText_FailedToSwallow"] = 0x5CB807, - ["sText_FaintInThree"] = 0x5CB8DE, - ["sText_FemaleTraded1"] = 0x2EF178, - ["sText_FemaleTraded2"] = 0x2EF198, - ["sText_Final"] = 0x5CCD17, - ["sText_FinishedCheckingPlayersTrainerCard"] = 0x2F0020, - ["sText_FireWeakened"] = 0x5CADE7, - ["sText_FoePkmnPrefix"] = 0x5CBD8B, - ["sText_FoePkmnPrefix2"] = 0x5CBD91, - ["sText_FoePkmnPrefix3"] = 0x5CBD9A, - ["sText_FoePkmnPrefix4"] = 0x5CBDA3, - ["sText_FourQuestionMarks"] = 0x32DADF, - ["sText_FullParty"] = 0x674E6A, - ["sText_GladToMeetYouFemale"] = 0x2EFFFC, - ["sText_GladToMeetYouMale"] = 0x2EFFDC, - ["sText_GotAwaySafely"] = 0x5CB4CA, - ["sText_Greetings"] = 0x2EFD70, - ["sText_HMMovesCantBeForgotten"] = 0x5CB479, - ["sText_HP"] = 0x5CCAF1, - ["sText_HP2"] = 0x5CBDC1, - ["sText_HailContinues"] = 0x5CB7BB, - ["sText_HailStopped"] = 0x5CB7D3, - ["sText_HasNoBerriesToPut"] = 0x3398B0, - ["sText_HiDoSomethingAgainFemale"] = 0x2EE218, - ["sText_HiDoSomethingAgainMale"] = 0x2EE1E0, - ["sText_HiDoSomethingFemale"] = 0x2EE1B8, - ["sText_HiDoSomethingMale"] = 0x2EE188, - ["sText_HofTime"] = 0x56FB4C, - ["sText_ID"] = 0x2ED8CC, - ["sText_IfYouWantToDoSomethingFemale"] = 0x2EEB4C, - ["sText_IfYouWantToDoSomethingMale"] = 0x2EEB10, - ["sText_Info"] = 0x2EFD8C, - ["sText_IsThisTradeOkay"] = 0x32DAE5, - ["sText_ItAppearedCaught"] = 0x5CBF4B, - ["sText_ItDodgedBall"] = 0x5CBEDA, - ["sText_ItHurtConfusion"] = 0x5CB67E, - ["sText_ItIsRaining"] = 0x5CC04E, - ["sText_ItSuckedLiquidOoze"] = 0x5CB25A, - ["sText_ItemsCantBeUsedNow"] = 0x5CAE3D, - ["sText_ItsBronzeCard"] = 0x2EFF00, - ["sText_ItsCopperCard"] = 0x2EFF14, - ["sText_ItsGoldCard"] = 0x2EFF3C, - ["sText_ItsNormalCard"] = 0x2EFEEC, - ["sText_ItsSilverCard"] = 0x2EFF28, - ["sText_JoinChatFemale"] = 0x2EE638, - ["sText_JoinChatMale"] = 0x2EE5C0, - ["sText_Laddie"] = 0x3397AF, - ["sText_Lanettes"] = 0x5CC01F, - ["sText_Lassie"] = 0x3397B6, - ["sText_LearnedSomethingFemale"] = 0x2EEF7C, - ["sText_LearnedSomethingMale"] = 0x2EEECC, - ["sText_Letters"] = 0x2F7B80, - ["sText_LinkWithFriendDropped"] = 0x2EDF40, - ["sText_Lv"] = 0x32DAD6, - ["sText_MaleTraded1"] = 0x2EF110, - ["sText_MaleTraded2"] = 0x2EF138, - ["sText_Master"] = 0x3397BD, - ["sText_MaximumSpeed"] = 0x33992E, - ["sText_MemberNoLongerAvailable"] = 0x2EDD8C, - ["sText_Min"] = 0x339921, - ["sText_MirrorMoveFailed"] = 0x5CB69F, - ["sText_Miss"] = 0x3397C9, - ["sText_Mister"] = 0x3397A8, - ["sText_ModeWithTheseMembersWillBeCanceled"] = 0x2EDD58, - ["sText_MultiBattle"] = 0x2EFDD0, - ["sText_MustHaveTwoMonsForDoubleBattle"] = 0x2EFC00, - ["sText_MysteryGiftAlteringCave"] = 0x675708, - ["sText_MysteryGiftBattleCountCard"] = 0x675184, - ["sText_MysteryGiftBattleCountCard_WonPrize"] = 0x67529C, - ["sText_MysteryGiftEgg"] = 0x674DDE, - ["sText_MysteryGiftOldSeaMapBagFull"] = 0x67596A, - ["sText_MysteryGiftOldSeaMapForYou"] = 0x675861, - ["sText_MysteryGiftOldSeaMapThankYou"] = 0x67593D, - ["sText_MysteryGiftOldSeaMapUseAtPort"] = 0x6758D9, - ["sText_MysteryGiftStampCard"] = 0x674CDD, - ["sText_MysteryGiftVisitingTrainerArrived"] = 0x675035, - ["sText_MysteryGiftVisitingTrainerInstructions"] = 0x674EEA, - ["sText_MysticTicketBagFull"] = 0x67566F, - ["sText_MysticTicketForYou"] = 0x675581, - ["sText_MysticTicketThankYou"] = 0x675642, - ["sText_MysticTicketUseAtPort"] = 0x6755DE, - ["sText_NameWantedOfferLv"] = 0x2EFD94, - ["sText_NeedTwoMonsOfLevel30OrLower1"] = 0x2EEBD0, - ["sText_NeedTwoMonsOfLevel30OrLower2"] = 0x2EEC14, - ["sText_NewParagraph"] = 0x33979B, - ["sText_No000"] = 0x55D2B8, - ["sText_NoCardsSharedRightNow"] = 0x2EFD04, - ["sText_NoNewsSharedRightNow"] = 0x2EFD30, - ["sText_NoPPLeft"] = 0x5CB90E, - ["sText_NoRunningFromTrainers"] = 0x5CB5F3, - ["sText_NobodyHasRegistered"] = 0x2EF438, - ["sText_NotRegisteredAMonForTrade"] = 0x2EF6B4, - ["sText_NotVeryEffective"] = 0x5CB49B, - ["sText_OfferDeclined1"] = 0x2EE560, - ["sText_OfferDeclined2"] = 0x2EE57C, - ["sText_OfferToTradeEgg"] = 0x2EE4F0, - ["sText_OfferToTradeMon"] = 0x2EE47C, - ["sText_OneHitKO"] = 0x5CB434, - ["sText_OnlyPkmnForBattle"] = 0x32DB9C, - ["sText_OtherTrainerUnavailableNow"] = 0x2EDCB4, - ["sText_OutOf30"] = 0x571737, - ["sText_OutOfSafariBalls"] = 0x5CBB02, - ["sText_PkmnBrokeFree"] = 0x5CBF2B, - ["sText_PlayerCantTradeForYourMon"] = 0x2EF740, - ["sText_PlayerContactedYouAddToMembers"] = 0x2EDC34, - ["sText_PlayerContactedYouForXAccept"] = 0x2EDBE8, - ["sText_PlayerContactedYouShareX"] = 0x2EDC0C, - ["sText_PlayerHasBeenAskedToRegisterYouPleaseWait"] = 0x2EFC54, - ["sText_PlayerHasContactedYou"] = 0x2EE308, - ["sText_PlayerJoinChatFemale"] = 0x2EE674, - ["sText_PlayerJoinChatMale"] = 0x2EE5F8, - ["sText_PlayerOKdRegistration"] = 0x2EDE0C, - ["sText_PlayerRepliedNo"] = 0x2EDE34, - ["sText_PlayerRepliedNo2"] = 0x2EDF6C, - ["sText_PlayerSentBackOK"] = 0x2EDDF4, - ["sText_PleaseFollowInstructionsOnScreen"] = 0x618092, - ["sText_PleaseStartOver"] = 0x2ED8D0, - ["sText_PleaseWaitForOtherTrainersToGather"] = 0x2EFCCC, - ["sText_PokeblockWasTooBitter"] = 0x5CBE4B, - ["sText_PokeblockWasTooDry"] = 0x5CBE2F, - ["sText_PokeblockWasTooSour"] = 0x5CBE5B, - ["sText_PokeblockWasTooSpicy"] = 0x5CBE20, - ["sText_PokeblockWasTooSweet"] = 0x5CBE3C, - ["sText_PokemonJump"] = 0x2EFE1C, - ["sText_PokemonTrades"] = 0x2EFDE0, - ["sText_PokenavCallEllipsis"] = 0x60EF70, - ["sText_PressAToStart"] = 0x3397E8, - ["sText_Pyramid"] = 0x57F53C, - ["sText_PyramidFloor1"] = 0x57F4CC, - ["sText_PyramidFloor2"] = 0x57F4DC, - ["sText_PyramidFloor3"] = 0x57F4EC, - ["sText_PyramidFloor4"] = 0x57F4FC, - ["sText_PyramidFloor5"] = 0x57F50C, - ["sText_PyramidFloor6"] = 0x57F51C, - ["sText_PyramidFloor7"] = 0x57F52C, - ["sText_QuestionForfeitMatch"] = 0x5CD1EC, - ["sText_QuitBeingMember"] = 0x2EDE64, - ["sText_RPM"] = 0x33993C, - ["sText_RainContinues"] = 0x5CB6DF, - ["sText_RainStopped"] = 0x5CB70F, - ["sText_RandomChatFemale1"] = 0x2EEFBC, - ["sText_RandomChatFemale2"] = 0x2EEFEC, - ["sText_RandomChatMale1"] = 0x2EEF2C, - ["sText_RandomChatMale2"] = 0x2EEF50, - ["sText_Ranking"] = 0x339947, - ["sText_RecordCorner"] = 0x2EFE60, - ["sText_RefDraw"] = 0x5CD0DB, - ["sText_RefIfNothingIsDecided"] = 0x5CCF12, - ["sText_RefJudgeBody"] = 0x5CD029, - ["sText_RefJudgeMind"] = 0x5CCF9B, - ["sText_RefJudgeSkill"] = 0x5CCFE2, - ["sText_RefThatsIt"] = 0x5CCF54, - ["sText_RegisterMonAtTradingBoard"] = 0x2EF20C, - ["sText_RegistrationCanceled"] = 0x2EF4FC, - ["sText_RegistrationCanceled2"] = 0x2EF5B8, - ["sText_RegistrationCompleted"] = 0x2EF520, - ["sText_Round1"] = 0x5CCCFD, - ["sText_Round2"] = 0x5CCD05, - ["sText_RubySapphire"] = 0x617E8D, - ["sText_RunOutOfBerriesForBlending"] = 0x339858, - ["sText_SandstormBrewed"] = 0x5CB721, - ["sText_SandstormIsRaging"] = 0x5CC05D, - ["sText_SandstormRages"] = 0x5CB735, - ["sText_SandstormSubsided"] = 0x5CB74A, - ["sText_Search"] = 0x2EFE48, - ["sText_SearchingForWirelessSystemWait"] = 0x2EFBC8, - ["sText_Sec"] = 0x339928, - ["sText_Semifinal"] = 0x5CCD0D, - ["sText_SharedPain"] = 0x5CB8AF, - ["sText_ShedinjaJP"] = 0x32DF94, - ["sText_ShedinjaJapaneseName"] = 0x5B58C4, - ["sText_ShedinjaJpnName"] = 0x31ACA0, - ["sText_ShootSoClose"] = 0x5CBF80, - ["sText_ShowTrainerCard"] = 0x2EE380, - ["sText_ShowTrainerCardDeclinedFemale"] = 0x2EEAC0, - ["sText_ShowTrainerCardDeclinedMale"] = 0x2EEA70, - ["sText_ShowTrainerCardFemale"] = 0x2EE7F8, - ["sText_ShowTrainerCardMale"] = 0x2EE754, - ["sText_ShowedTrainerCardFemale1"] = 0x2EF090, - ["sText_ShowedTrainerCardFemale2"] = 0x2EF0D0, - ["sText_ShowedTrainerCardMale1"] = 0x2EF030, - ["sText_ShowedTrainerCardMale2"] = 0x2EF06C, - ["sText_SingleBattle"] = 0x2EFDB0, - ["sText_Slash"] = 0x32DAD4, - ["sText_SmartContest"] = 0x2EFEA0, - ["sText_SomebodyHasContactedYou"] = 0x2EE2E8, - ["sText_Someones"] = 0x5CC015, - ["sText_SoothingAroma"] = 0x5CAE13, - ["sText_SpAtk"] = 0x5CCB03, - ["sText_SpAtk2"] = 0x5CBDD9, - ["sText_SpDef"] = 0x5CCB0B, - ["sText_SpDef2"] = 0x5CBDE1, - ["sText_Space"] = 0x339945, - ["sText_SpaceBerry"] = 0x339914, - ["sText_SpaceIs"] = 0x5CCBB5, - ["sText_SpaceMove"] = 0x32DACC, - ["sText_Speed"] = 0x5CBDD3, - ["sText_SpikesScattered"] = 0x5CA900, - ["sText_StartedHail"] = 0x5CB7A7, - ["sText_StartedToRain"] = 0x5CB6B7, - ["sText_StatChangesGone"] = 0x5CB84B, - ["sText_StatFell"] = 0x5CB399, - ["sText_StatHarshly"] = 0x5CB390, - ["sText_StatSharply"] = 0x5CB381, - ["sText_StoppedBeingMember"] = 0x2EDE7C, - ["sText_Summary"] = 0x32DB12, - ["sText_Summary2"] = 0x32DB3B, - ["sText_SunlightFaded"] = 0x5CB793, - ["sText_SunlightGotBright"] = 0x5CB762, - ["sText_SunlightStrong"] = 0x5CB77B, - ["sText_SuperEffective"] = 0x5CB4B4, - ["sText_SwitchedMonsFemale"] = 0x2EEE78, - ["sText_SwitchedMonsMale"] = 0x2EEDBC, - ["sText_TenDashes"] = 0x55D2FE, - ["sText_TenDashes2"] = 0x56E688, - ["sText_ThankYouForRegistering"] = 0x2EF3D4, - ["sText_ThatsFunnyFemale"] = 0x2EEFA0, - ["sText_ThatsFunnyMale"] = 0x2EEEF0, - ["sText_TheFeelIs"] = 0x33995D, - ["sText_TheLevelIs"] = 0x33994F, - ["sText_TheTradeHasBeenCanceled"] = 0x32DB76, - ["sText_TheWallShattered"] = 0x5CAEBB, - ["sText_ThreeDashes"] = 0x32DADB, - ["sText_Time"] = 0x33991B, - ["sText_TooWeakForSubstitute"] = 0x5CB889, - ["sText_ToughContest"] = 0x2EFEB0, - ["sText_Trade"] = 0x32DB1A, - ["sText_Trade2"] = 0x32DB43, - ["sText_TradeCanceled"] = 0x2EF544, - ["sText_TradeOfferRejected"] = 0x2EF7B0, - ["sText_TradeTrainersWillBeListed"] = 0x2EF5E0, - ["sText_TradeWillBeStarted"] = 0x2EE92C, - ["sText_TradingBoardInfo"] = 0x2EF298, - ["sText_TrainerAppearsBusy"] = 0x2EE6C8, - ["sText_TrainerAppearsUnavailable"] = 0x2EDDCC, - ["sText_TrainerBattleBusy"] = 0x2EEB88, - ["sText_TrainerBlockedBall"] = 0x5CBEAA, - ["sText_TrainerCardInfoPage1"] = 0x2EFF64, - ["sText_TrainerCardInfoPage2"] = 0x2EFFA4, - ["sText_TrainersNotReadyYet"] = 0x2EDD24, - ["sText_TransmissionFailureTryAgain"] = 0x6180D7, - ["sText_TransmittingPleaseWait"] = 0x618020, - ["sText_TurnOffPowerHoldingStartSelect"] = 0x617F97, - ["sText_UnusedEmpty"] = 0x32DAE4, - ["sText_UnusedTextFormat"] = 0x32DABD, - ["sText_UseNextPkmn"] = 0x5C9C6F, - ["sText_UsedGoodMoveFemale"] = 0x2EEE24, - ["sText_UsedGoodMoveMale"] = 0x2EED6C, - ["sText_WaitForBattleFemale"] = 0x2EE78C, - ["sText_WaitForBattleMale"] = 0x2EE6EC, - ["sText_WaitForChatFemale"] = 0x2EE7C0, - ["sText_WaitForChatMale"] = 0x2EE720, - ["sText_WaitForChatMale2"] = 0x2EE84C, - ["sText_WaitingForYourFriend"] = 0x32DBC1, - ["sText_WasMade"] = 0x33979D, - ["sText_WhichMonWillYouOffer"] = 0x2EF4C4, - ["sText_WildPkmnPrefix"] = 0x5CBD85, - ["sText_WindBecameHeatWave"] = 0x5CB829, - ["sText_WirelessLinkDropped"] = 0x2EDF04, - ["sText_WirelessLinkEstablished"] = 0x2EDEC4, - ["sText_WirelessSearchCanceled"] = 0x2ED8F8, - ["sText_WonderCards"] = 0x2EFE00, - ["sText_WonderNews"] = 0x2EFE10, - ["sText_WouldLikeToBlendAnotherBerry"] = 0x339831, - ["sText_XCheckedTradingBoard"] = 0x2EF1EC, - ["sText_YouMissedPkmn"] = 0x5CBF13, - ["sText_YouThrowABallNowRight"] = 0x5CC23F, - ["sText_YourFriendWantsToTrade"] = 0x32DBED, - ["sText_YourPokeblockCaseIsFull"] = 0x339896, - ["sText_YoureToughFemale"] = 0x2EEDF4, - ["sText_YoureToughMale"] = 0x2EED44, - ["sUnusedText_2"] = 0x3396FB, - ["sUnusedText_LinkPartnerNotFound"] = 0x33970C, - ["sUnusedText_Space"] = 0x3396FD, - ["sUnusedText_Terminating"] = 0x3396FF, - ["sUnusedText_YesNo"] = 0x3396F4, - ["sUnusedTransparentWhite"] = 0x2ED160, - ["stext_DeclineChatFemale"] = 0x2EEC7C, - }, - battle = { - ["gText_BattleRecordedOnPass"] = 0x5CD2CB, - ["gText_DefendersStatRose"] = 0x5CB3AA, - ["gText_Judgment"] = 0x5CCDCB, - ["gText_OpponentMon1Name"] = 0x5CCDB8, - ["gText_PkmnGettingPumped"] = 0x5CA459, - ["gText_PkmnShroudedInMist"] = 0x5CA424, - ["gText_PkmnsXPreventsSwitching"] = 0x5CB2A1, - ["gText_PlayerMon1Name"] = 0x5CCDB2, - ["gText_WhatWillPkmnDo"] = 0x5CC9F3, - ["gText_WhatWillPkmnDo2"] = 0x5CCA04, - ["sText_AttackMissed"] = 0x5C9C81, - ["sText_AttackerCantEscape"] = 0x5C9DA4, - ["sText_AttackerFainted"] = 0x5C9D16, - ["sText_AttackerUsedX"] = 0x5CBDAC, - ["sText_AttackersStatFell"] = 0x5CB3CF, - ["sText_AttackersStatRose"] = 0x5CB39F, - ["sText_AvoidedDamage"] = 0x5C9CAA, - ["sText_CantGetCloser"] = 0x5CBA5D, - ["sText_CreptCloser"] = 0x5CBA46, - ["sText_DefeatedOpponentByReferee"] = 0x5CD107, - ["sText_DefendersStatFell"] = 0x5CB3DA, - ["sText_DidNotLearnMove"] = 0x5C9C59, - ["sText_DoItPkmn"] = 0x5CBCAD, - ["sText_EnemyAboutToSwitchPkmn"] = 0x5CB9ED, - ["sText_FoeStoppedWorking"] = 0x5CA096, - ["sText_ForXCommaYZ"] = 0x5CAE59, - ["sText_ForfeitedMatch"] = 0x5CD21E, - ["sText_GiveNicknameCaptured"] = 0x5CBFDB, - ["sText_GlintAppearsInEye"] = 0x5CCE9F, - ["sText_GoForItPkmn"] = 0x5CBCB8, - ["sText_GoPkmn"] = 0x5CBC8E, - ["sText_GoPkmn2"] = 0x5CBCA5, - ["sText_GoTwoPkmn"] = 0x5CBC96, - ["sText_GotchaPkmnCaughtPlayer"] = 0x5CBF9D, - ["sText_GotchaPkmnCaughtWally"] = 0x5CBFBB, - ["sText_HitXTimes"] = 0x5C9DB5, - ["sText_InGamePartnerSentOutZGoN"] = 0x5CCE38, - ["sText_ItDoesntAffect"] = 0x5C9D00, - ["sText_ItemAllowsOnlyYMove"] = 0x5CC206, - ["sText_LegendaryPkmnAppeared"] = 0x5CBB5A, - ["sText_LinkPartnerSentOutPkmnGoPkmn"] = 0x5CBCE5, - ["sText_LinkTrainer1WithdrewPkmn"] = 0x5CBD65, - ["sText_LinkTrainer2WithdrewPkmn"] = 0x5CBD75, - ["sText_LinkTrainerMultiSentOutPkmn"] = 0x5CBC7E, - ["sText_LinkTrainerSentOutPkmn"] = 0x5CBC27, - ["sText_LinkTrainerSentOutPkmn2"] = 0x5CBC6E, - ["sText_LinkTrainerSentOutTwoPkmn"] = 0x5CBC37, - ["sText_LinkTrainerWantsToBattle"] = 0x5CBBB9, - ["sText_LinkTrainerWantsToBattlePause"] = 0x5CD301, - ["sText_LostToOpponentByReferee"] = 0x5CD13C, - ["sText_MagnitudeStrength"] = 0x5CA9A2, - ["sText_NaturePowerTurnedInto"] = 0x5CAADD, - ["sText_OpponentMon1Appeared"] = 0x5CBB39, - ["sText_PkmnAbsorbedNutrients"] = 0x5CAC35, - ["sText_PkmnAfflictedByCurse"] = 0x5CA8E2, - ["sText_PkmnAlreadyAsleep"] = 0x5C9DEC, - ["sText_PkmnAlreadyAsleep2"] = 0x5C9E02, - ["sText_PkmnAlreadyConfused"] = 0x5CA0F5, - ["sText_PkmnAlreadyHasBurn"] = 0x5C9EEB, - ["sText_PkmnAlreadyPoisoned"] = 0x5C9E68, - ["sText_PkmnAnchoredItself"] = 0x5CAC5B, - ["sText_PkmnAnchorsItselfWith"] = 0x5CB0D5, - ["sText_PkmnAttack"] = 0x5CAA94, - ["sText_PkmnAvoidedAttack"] = 0x5C9CE9, - ["sText_PkmnBadlyPoisoned"] = 0x5C9E80, - ["sText_PkmnBeganGrowlingDeeply"] = 0x5CCEDA, - ["sText_PkmnBeganToNap"] = 0x5CB98B, - ["sText_PkmnBlewAwaySpikes"] = 0x5CAA21, - ["sText_PkmnBlownAway"] = 0x5CA150, - ["sText_PkmnBracedItself"] = 0x5CA97C, - ["sText_PkmnBuffetedBySandstorm"] = 0x5CA49B, - ["sText_PkmnBurnedBy"] = 0x5C9EC0, - ["sText_PkmnCalmedDown"] = 0x5CA5B3, - ["sText_PkmnCantSleepInUproar"] = 0x5CA5C3, - ["sText_PkmnCantSleepInUproar2"] = 0x5CA613, - ["sText_PkmnCantStockpile"] = 0x5CA5F6, - ["sText_PkmnCantUseMoveSealed"] = 0x5CAF6D, - ["sText_PkmnCantUseMoveTaunt"] = 0x5CAF4B, - ["sText_PkmnCantUseMoveTorment"] = 0x5CAF12, - ["sText_PkmnCausedUproar"] = 0x5CA586, - ["sText_PkmnCenterAttention"] = 0x5CAAA1, - ["sText_PkmnChangedType"] = 0x5CA163, - ["sText_PkmnChangedTypeWith"] = 0x5CB02B, - ["sText_PkmnChargingPower"] = 0x5CAAC4, - ["sText_PkmnChoseXAsDestiny"] = 0x5CAA78, - ["sText_PkmnClamped"] = 0x5CA3D2, - ["sText_PkmnComeBack"] = 0x5CBD1B, - ["sText_PkmnCopiedFoe"] = 0x5CABE7, - ["sText_PkmnCopiedStatChanges"] = 0x5CA9D8, - ["sText_PkmnCoveredByVeil"] = 0x5CA228, - ["sText_PkmnCrashed"] = 0x5CA409, - ["sText_PkmnCuriousAboutX"] = 0x5CBA90, - ["sText_PkmnCutHPMaxedAttack"] = 0x5CA9B0, - ["sText_PkmnCutsAttackWith"] = 0x5CB0F0, - ["sText_PkmnDataAddedToDex"] = 0x5CC029, - ["sText_PkmnDreamEaten"] = 0x5CA02F, - ["sText_PkmnDugHole"] = 0x5CA333, - ["sText_PkmnEagerForMore"] = 0x5CCEF4, - ["sText_PkmnEncoreEnded"] = 0x5CA7D3, - ["sText_PkmnEnduredHit"] = 0x5CA98E, - ["sText_PkmnEnergyDrained"] = 0x5C9E96, - ["sText_PkmnEnthralledByX"] = 0x5CBAAC, - ["sText_PkmnEvadedAttack"] = 0x5CA4F3, - ["sText_PkmnFastAsleep"] = 0x5CA52E, - ["sText_PkmnFatigueConfusion"] = 0x5CA69B, - ["sText_PkmnFellAsleep"] = 0x5C9DC5, - ["sText_PkmnFellForTaunt"] = 0x5CAB53, - ["sText_PkmnFellInLove"] = 0x5CA10D, - ["sText_PkmnFellIntoNightmare"] = 0x5CA881, - ["sText_PkmnFledFromBattle"] = 0x5CAA36, - ["sText_PkmnFledUsing"] = 0x5CB4FB, - ["sText_PkmnFledUsingIts"] = 0x5CB4E0, - ["sText_PkmnFlewHigh"] = 0x5CA322, - ["sText_PkmnFlinched"] = 0x5CA184, - ["sText_PkmnForesawAttack"] = 0x5CAA4B, - ["sText_PkmnForgotMove"] = 0x5C9C35, - ["sText_PkmnFreedFrom"] = 0x5CA3F3, - ["sText_PkmnFrozenBy"] = 0x5C9F17, - ["sText_PkmnGainedEXP"] = 0x5C9B7B, - ["sText_PkmnGettingIntoPosition"] = 0x5CCEBD, - ["sText_PkmnGoodComeBack"] = 0x5CBD3D, - ["sText_PkmnGotEncore"] = 0x5CA7C1, - ["sText_PkmnGotFree"] = 0x5CA9F5, - ["sText_PkmnGrewToLv"] = 0x5C9BA4, - ["sText_PkmnHPFull"] = 0x5CA1A5, - ["sText_PkmnHasNoMovesLeft"] = 0x5CAEE5, - ["sText_PkmnHasSubstitute"] = 0x5CA711, - ["sText_PkmnHealedConfusion"] = 0x5CA0C4, - ["sText_PkmnHealedParalysis"] = 0x5CA013, - ["sText_PkmnHidUnderwater"] = 0x5CA342, - ["sText_PkmnHitWithRecoil"] = 0x5CA46F, - ["sText_PkmnHungOnWithX"] = 0x5CC225, - ["sText_PkmnHurtBy"] = 0x5CA3E1, - ["sText_PkmnHurtByBurn"] = 0x5C9ED3, - ["sText_PkmnHurtByPoison"] = 0x5C9E52, - ["sText_PkmnHurtBySpikes"] = 0x5CA936, - ["sText_PkmnHurtsWith"] = 0x5CB126, - ["sText_PkmnIdentified"] = 0x5CA94C, - ["sText_PkmnIgnoredOrders"] = 0x5CB978, - ["sText_PkmnIgnoredX"] = 0x5CBAC8, - ["sText_PkmnIgnoresAsleep"] = 0x5CB958, - ["sText_PkmnImmobilizedByLove"] = 0x5CA135, - ["sText_PkmnInLove"] = 0x5CA11E, - ["sText_PkmnIncapableOfPower"] = 0x5CCE76, - ["sText_PkmnIsAlreadyParalyzed"] = 0x5C9FFA, - ["sText_PkmnIsConfused"] = 0x5CA0B4, - ["sText_PkmnIsFrozen"] = 0x5C9F2F, - ["sText_PkmnIsGlowing"] = 0x5CA313, - ["sText_PkmnIsParalyzed"] = 0x5C9FDA, - ["sText_PkmnKnockedOff"] = 0x5CAC91, - ["sText_PkmnLaidCurse"] = 0x5CA8B8, - ["sText_PkmnLearnedMove"] = 0x5C9BBA, - ["sText_PkmnLearnedMove2"] = 0x5CBA1F, - ["sText_PkmnLoafing"] = 0x5CB99C, - ["sText_PkmnLockedInNightmare"] = 0x5CA89B, - ["sText_PkmnLostFocus"] = 0x5CAE81, - ["sText_PkmnLostPPGrudge"] = 0x5CAD1B, - ["sText_PkmnLoweredHead"] = 0x5CA2FE, - ["sText_PkmnMadeItRain"] = 0x5CAF8A, - ["sText_PkmnMadeSleep"] = 0x5C9DD5, - ["sText_PkmnMadeSubstitute"] = 0x5CA6FB, - ["sText_PkmnMadeWish"] = 0x5CABFA, - ["sText_PkmnMakesGroundMiss"] = 0x5C9CC5, - ["sText_PkmnMakingUproar"] = 0x5CA59B, - ["sText_PkmnMoveBounced"] = 0x5CAD60, - ["sText_PkmnMoveDisabledNoMore"] = 0x5CA7A9, - ["sText_PkmnMoveIsDisabled"] = 0x5CAEFC, - ["sText_PkmnMoveWasDisabled"] = 0x5CA793, - ["sText_PkmnMustRecharge"] = 0x5CA76A, - ["sText_PkmnObtainedX"] = 0x5CABA7, - ["sText_PkmnObtainedX2"] = 0x5CABB7, - ["sText_PkmnObtainedXYObtainedZ"] = 0x5CABC7, - ["sText_PkmnOkComeBack"] = 0x5CBD2A, - ["sText_PkmnPeltedByHail"] = 0x5CA4BC, - ["sText_PkmnPerishCountFell"] = 0x5CA95E, - ["sText_PkmnPlantedRoots"] = 0x5CAC1F, - ["sText_PkmnPoisonedBy"] = 0x5C9E3D, - ["sText_PkmnPretendNotNotice"] = 0x5CB9D1, - ["sText_PkmnPreventsConfusionWith"] = 0x5CB09A, - ["sText_PkmnPreventsParalysisWith"] = 0x5CB048, - ["sText_PkmnPreventsPoisoningWith"] = 0x5CB07E, - ["sText_PkmnPreventsRomanceWith"] = 0x5CB064, - ["sText_PkmnPreventsStatLossWith"] = 0x5CB10A, - ["sText_PkmnPreventsUsage"] = 0x5CAFD2, - ["sText_PkmnProtectedBy"] = 0x5CAFBA, - ["sText_PkmnProtectedByMist"] = 0x5CA440, - ["sText_PkmnProtectedItself"] = 0x5C9C95, - ["sText_PkmnProtectedItself2"] = 0x5CA486, - ["sText_PkmnRageBuilding"] = 0x5CA77C, - ["sText_PkmnRaisedDef"] = 0x5CA1EF, - ["sText_PkmnRaisedDefALittle"] = 0x5CA207, - ["sText_PkmnRaisedFirePowerWith"] = 0x5CB0B6, - ["sText_PkmnRaisedSpDef"] = 0x5CA1B6, - ["sText_PkmnRaisedSpDefALittle"] = 0x5CA1CE, - ["sText_PkmnRaisedSpeed"] = 0x5CAFA0, - ["sText_PkmnReadyToHelp"] = 0x5CAB6A, - ["sText_PkmnReducedPP"] = 0x5CA843, - ["sText_PkmnRegainedHealth"] = 0x5CA191, - ["sText_PkmnRestoredHPUsing"] = 0x5CAFF5, - ["sText_PkmnSafeguardExpired"] = 0x5CA26F, - ["sText_PkmnSappedByLeechSeed"] = 0x5CA509, - ["sText_PkmnSealedOpponentMove"] = 0x5CACD1, - ["sText_PkmnSeeded"] = 0x5CA4E4, - ["sText_PkmnSentToPC"] = 0x5CBFFF, - ["sText_PkmnShedLeechSeed"] = 0x5CAA0D, - ["sText_PkmnShroudedItself"] = 0x5CAD46, - ["sText_PkmnSketchedMove"] = 0x5CA7F9, - ["sText_PkmnSleptHealthy"] = 0x5CA2B1, - ["sText_PkmnSnatchedMove"] = 0x5CADAE, - ["sText_PkmnSprangUp"] = 0x5CA355, - ["sText_PkmnSqueezedByBind"] = 0x5CA363, - ["sText_PkmnStatusNormal"] = 0x5CAAFA, - ["sText_PkmnStayedAwakeUsing"] = 0x5CA652, - ["sText_PkmnStockpiled"] = 0x5CA5E4, - ["sText_PkmnStoleItem"] = 0x5CA85A, - ["sText_PkmnStoringEnergy"] = 0x5CA670, - ["sText_PkmnSubjectedToTorment"] = 0x5CAB1A, - ["sText_PkmnSubstituteFaded"] = 0x5CA752, - ["sText_PkmnSwappedAbilities"] = 0x5CACA9, - ["sText_PkmnSwitchedItems"] = 0x5CAB82, - ["sText_PkmnThatsEnough"] = 0x5CBCFD, - ["sText_PkmnTighteningFocus"] = 0x5CAB37, - ["sText_PkmnTookAim"] = 0x5CA7E6, - ["sText_PkmnTookAttack"] = 0x5CAA61, - ["sText_PkmnTookFoe"] = 0x5CA82F, - ["sText_PkmnTookSunlight"] = 0x5CA2E9, - ["sText_PkmnTraced"] = 0x5CB137, - ["sText_PkmnTransformed"] = 0x5CB278, - ["sText_PkmnTransformedInto"] = 0x5CA6E3, - ["sText_PkmnTrappedBySandTomb"] = 0x5CA39F, - ["sText_PkmnTrappedInVortex"] = 0x5CA381, - ["sText_PkmnTryingToTakeFoe"] = 0x5CA809, - ["sText_PkmnTurnedAway"] = 0x5CB9C1, - ["sText_PkmnUnaffected"] = 0x5CA6D1, - ["sText_PkmnUnleashedEnergy"] = 0x5CA686, - ["sText_PkmnUproarKeptAwake"] = 0x5CA54D, - ["sText_PkmnUsedSafeguard"] = 0x5CA249, - ["sText_PkmnUsedXToGetPumped"] = 0x5CAE67, - ["sText_PkmnWaitsForTarget"] = 0x5CAD88, - ["sText_PkmnWantsGrudge"] = 0x5CACF3, - ["sText_PkmnWasBurned"] = 0x5C9EB1, - ["sText_PkmnWasConfused"] = 0x5CA0E1, - ["sText_PkmnWasDefrosted"] = 0x5C9F43, - ["sText_PkmnWasDefrosted2"] = 0x5C9F55, - ["sText_PkmnWasDefrostedBy"] = 0x5C9F67, - ["sText_PkmnWasDraggedOut"] = 0x5CAEA6, - ["sText_PkmnWasFrozen"] = 0x5C9F02, - ["sText_PkmnWasMadeDrowsy"] = 0x5CAC7E, - ["sText_PkmnWasParalyzed"] = 0x5C9F7F, - ["sText_PkmnWasParalyzedBy"] = 0x5C9FAA, - ["sText_PkmnWasPoisoned"] = 0x5C9E2C, - ["sText_PkmnWasntAffected"] = 0x5C9E18, - ["sText_PkmnWatchingCarefully"] = 0x5CBA76, - ["sText_PkmnWentToSleep"] = 0x5CA29F, - ["sText_PkmnWhippedWhirlwind"] = 0x5CA2CE, - ["sText_PkmnWishCameTrue"] = 0x5CAC0A, - ["sText_PkmnWokeUp"] = 0x5CA541, - ["sText_PkmnWokeUpInUproar"] = 0x5CA56C, - ["sText_PkmnWontObey"] = 0x5CB9B2, - ["sText_PkmnWrappedBy"] = 0x5CA3BC, - ["sText_PkmnsItemCuredParalysis"] = 0x5CC0B4, - ["sText_PkmnsItemCuredPoison"] = 0x5CC0CD, - ["sText_PkmnsItemCuredProblem"] = 0x5CC157, - ["sText_PkmnsItemDefrostedIt"] = 0x5CC0FC, - ["sText_PkmnsItemHealedBurn"] = 0x5CC0E3, - ["sText_PkmnsItemNormalizedStatus"] = 0x5CC175, - ["sText_PkmnsItemRestoredHPALittle"] = 0x5CC1E4, - ["sText_PkmnsItemRestoredHealth"] = 0x5CC194, - ["sText_PkmnsItemRestoredPP"] = 0x5CC1AD, - ["sText_PkmnsItemRestoredStatus"] = 0x5CC1C7, - ["sText_PkmnsItemSnappedOut"] = 0x5CC132, - ["sText_PkmnsItemWokeIt"] = 0x5CC112, - ["sText_PkmnsXBlocksY"] = 0x5CB162, - ["sText_PkmnsXBlocksY2"] = 0x5CB175, - ["sText_PkmnsXCuredItsYProblem"] = 0x5CB346, - ["sText_PkmnsXCuredYProblem"] = 0x5CB23C, - ["sText_PkmnsXHadNoEffectOnY"] = 0x5CB364, - ["sText_PkmnsXInfatuatedY"] = 0x5CB208, - ["sText_PkmnsXIntensifiedSun"] = 0x5CB1CA, - ["sText_PkmnsXMadeItIneffective"] = 0x5CB2E6, - ["sText_PkmnsXMadeYIneffective"] = 0x5CB21F, - ["sText_PkmnsXMadeYUseless"] = 0x5CB012, - ["sText_PkmnsXPreventsBurns"] = 0x5CB14A, - ["sText_PkmnsXPreventsFlinching"] = 0x5CB303, - ["sText_PkmnsXPreventsYLoss"] = 0x5CB1EE, - ["sText_PkmnsXPreventsYsZ"] = 0x5CB31F, - ["sText_PkmnsXRestoredHPALittle2"] = 0x5CB188, - ["sText_PkmnsXTookAttack"] = 0x5CB288, - ["sText_PkmnsXWhippedUpSandstorm"] = 0x5CB1AA, - ["sText_PkmnsXWoreOff"] = 0x5CA4D2, - ["sText_PlayerBattledToDrawLinkTrainer"] = 0x5CB581, - ["sText_PlayerBattledToDrawTrainer1"] = 0x5CD267, - ["sText_PlayerBattledToDrawVsTwo"] = 0x5CB5A6, - ["sText_PlayerDefeatedLinkTrainer"] = 0x5CB524, - ["sText_PlayerDefeatedLinkTrainerTrainer1"] = 0x5CBA2E, - ["sText_PlayerGotMoney"] = 0x5C9D30, - ["sText_PlayerLostAgainstLinkTrainer"] = 0x5CB54F, - ["sText_PlayerLostAgainstTrainer1"] = 0x5CD24C, - ["sText_PlayerLostToTwo"] = 0x5CB567, - ["sText_PlayerPickedUpMoney"] = 0x5CA6BE, - ["sText_PlayerUsedItem"] = 0x5CBE80, - ["sText_PlayerWhiteout"] = 0x5C9D49, - ["sText_PlayerWhiteout2"] = 0x5C9D67, - ["sText_PreventedFromWorking"] = 0x5CB2BE, - ["sText_PreventsEscape"] = 0x5C9D78, - ["sText_RefCommenceBattle"] = 0x5CD1A1, - ["sText_RefOpponentWon"] = 0x5CD0A6, - ["sText_RefPlayerWon"] = 0x5CD071, - ["sText_StatsWontDecrease"] = 0x5CA05E, - ["sText_StatsWontDecrease2"] = 0x5CB405, - ["sText_StatsWontIncrease"] = 0x5CA045, - ["sText_StatsWontIncrease2"] = 0x5CB3E5, - ["sText_StopLearningMove"] = 0x5C9C44, - ["sText_SubstituteDamaged"] = 0x5CA72E, - ["sText_TargetCantEscapeNow"] = 0x5CA86C, - ["sText_TargetFainted"] = 0x5C9D23, - ["sText_TeamStoppedWorking"] = 0x5CA076, - ["sText_ThrewPokeblockAtPkmn"] = 0x5CBAE6, - ["sText_TiedOpponentByReferee"] = 0x5CD170, - ["sText_Trainer1Fled"] = 0x5CD23C, - ["sText_Trainer1LoseText"] = 0x5C9B78, - ["sText_Trainer1SentOutPkmn"] = 0x5CBBE7, - ["sText_Trainer1SentOutPkmn2"] = 0x5CBC14, - ["sText_Trainer1SentOutTwoPkmn"] = 0x5CBBFA, - ["sText_Trainer1UsedItem"] = 0x5CBE9B, - ["sText_Trainer1WantsToBattle"] = 0x5CBB9C, - ["sText_Trainer1WinText"] = 0x5CD236, - ["sText_Trainer1WithdrewPkmn"] = 0x5CBD52, - ["sText_Trainer2LoseText"] = 0x5CCE73, - ["sText_Trainer2SentOutPkmn"] = 0x5CCE04, - ["sText_Trainer2WinText"] = 0x5CD239, - ["sText_TryToLearnMove1"] = 0x5C9BCC, - ["sText_TryToLearnMove2"] = 0x5C9BE7, - ["sText_TryToLearnMove3"] = 0x5C9C12, - ["sText_TwoInGameTrainersDefeated"] = 0x5CCE53, - ["sText_TwoLinkTrainersDefeated"] = 0x5CB538, - ["sText_TwoLinkTrainersSentOutPkmn"] = 0x5CBC4E, - ["sText_TwoLinkTrainersWantToBattle"] = 0x5CBBCD, - ["sText_TwoLinkTrainersWantToBattlePause"] = 0x5CD318, - ["sText_TwoTrainersSentPkmn"] = 0x5CCDDE, - ["sText_TwoTrainersWantToBattle"] = 0x5CCE17, - ["sText_TwoWildFled"] = 0x5CB5DF, - ["sText_TwoWildPkmnAppeared"] = 0x5CBB82, - ["sText_UproarKeptPkmnAwake"] = 0x5CA634, - ["sText_UsingItemTheStatOfPkmnRose"] = 0x5CB3B5, - ["sText_WallyUsedItem"] = 0x5CBE8C, - ["sText_WildFled"] = 0x5CB5D2, - ["sText_WildPkmnAppeared"] = 0x5CBB47, - ["sText_WildPkmnAppearedPause"] = 0x5CBB6D, - ["sText_WildPkmnFled"] = 0x5CB512, - ["sText_XFoundOneY"] = 0x5CAE02, - ["sText_YourFoesWeakGetEmPkmn"] = 0x5CBCC7, - }, -} diff --git a/src/mods/rom_text_labels/firered.lua b/src/mods/rom_text_labels/firered.lua deleted file mode 100644 index b32284efd..000000000 --- a/src/mods/rom_text_labels/firered.lua +++ /dev/null @@ -1,3708 +0,0 @@ --- Generated label/address facts for firered; no cartridge prose. --- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Regenerate with tools/rom-text-contract-check/generate-labels.py. -return { - text = { - ["Aide_Text_DontHaveAnyRoomForItem"] = 0x1A594D, - ["Aide_Text_GetEnoughMonsComeBackForItem"] = 0x1A5909, - ["Aide_Text_HaventCaughtEnoughMonsForItem"] = 0x1A58A7, - ["CableClub_Text_AdapterNotConnected"] = 0x1BCA13, - ["CableClub_Text_AskAboutLinking"] = 0x1BDB85, - ["CableClub_Text_AwaitingLinkupBCancel"] = 0x1BC54C, - ["CableClub_Text_BattleUsingLinkCable"] = 0x1BC3C7, - ["CableClub_Text_CanMakeBerryPowder"] = 0x1BCAF2, - ["CableClub_Text_CanMixRecords"] = 0x1BCB27, - ["CableClub_Text_CancelSelectedItem"] = 0x1BCB42, - ["CableClub_Text_CantDoubleBattleWithXPlayers"] = 0x1BC700, - ["CableClub_Text_CantSingleBattleWithXPlayers"] = 0x1BC6CA, - ["CableClub_Text_CantTradeEnigmaBerry"] = 0x1BC47C, - ["CableClub_Text_ChooseGroupLeader"] = 0x1BD5F1, - ["CableClub_Text_ChooseGroupLeaderOfFour"] = 0x1BD582, - ["CableClub_Text_ChooseGroupLeaderOfTwo"] = 0x1BD51B, - ["CableClub_Text_CloseThisMenu"] = 0x1BC403, - ["CableClub_Text_ComeAgain"] = 0x1BCFD1, - ["CableClub_Text_DirectYouToYourRoom"] = 0x1BC59E, - ["CableClub_Text_EnjoyUnionRoom"] = 0x1BD86A, - ["CableClub_Text_ExplainBattleModes"] = 0x1BD390, - ["CableClub_Text_ExplainWirelessClub"] = 0x1BDBF8, - ["CableClub_Text_ExplainWirelessClubFirstTime"] = 0x1BD966, - ["CableClub_Text_FirstTimeRightThisWay"] = 0x1BD898, - ["CableClub_Text_Hello"] = 0x1BCA7F, - ["CableClub_Text_HopeToSeeYouAgain"] = 0x1BC943, - ["CableClub_Text_HopeYouEnjoyWirelessSystem"] = 0x1BDEDF, - ["CableClub_Text_IncorrectNumberOfParticipants"] = 0x1BC6A1, - ["CableClub_Text_LinkErrorPleaseReset"] = 0x1BC615, - ["CableClub_Text_NeedBerryForBerryCrush"] = 0x1BCB81, - ["CableClub_Text_NeedFourPlayers"] = 0x1BC736, - ["CableClub_Text_NeedTwoMonsForDoubleBattle"] = 0x1BC409, - ["CableClub_Text_NeedTwoMonsForUnionRoom"] = 0x1BCBC3, - ["CableClub_Text_NeedTwoMonsToTrade"] = 0x1BC442, - ["CableClub_Text_NoEnigmaBerryInUnionRoom"] = 0x1BCC00, - ["CableClub_Text_NotSetUpForFarAwayRegion"] = 0x1BC95C, - ["CableClub_Text_OhExcuseMe"] = 0x1BCCBE, - ["CableClub_Text_OkayToSaveProgress"] = 0x1BC572, - ["CableClub_Text_OtherTrainerNotReady"] = 0x1BC9C0, - ["CableClub_Text_ParticipantsStepUpToCounter"] = 0x1BCA43, - ["CableClub_Text_PlayWhichBattleMode"] = 0x1BD338, - ["CableClub_Text_PlayersMadeDifferentSelections"] = 0x1BC64E, - ["CableClub_Text_PleaseConfirmNumberAndRestart"] = 0x1BC76B, - ["CableClub_Text_PleaseEnter"] = 0x1BC590, - ["CableClub_Text_PleaseVisitAgain"] = 0x1BC68A, - ["CableClub_Text_PleaseWait"] = 0x1BCA86, - ["CableClub_Text_PleaseWaitBCancel"] = 0x1BC4AC, - ["CableClub_Text_ReturnsToPreviousStep"] = 0x1BCB73, - ["CableClub_Text_SomeoneIsNotReadyToLink"] = 0x1BC5C0, - ["CableClub_Text_StartLinkWithXPlayersAConfirmBCancel"] = 0x1BC50D, - ["CableClub_Text_TradeMonsUsingLinkCable"] = 0x1BC388, - ["CableClub_Text_TradePokemon"] = 0x1BD317, - ["CableClub_Text_TrainerCardDataOverwritten"] = 0x1BC918, - ["CableClub_Text_UnionRoomAdapterNotConnected"] = 0x1BCC3A, - ["CableClub_Text_UnionRoomInfo"] = 0x1BD706, - ["CableClub_Text_UseBerryCrush"] = 0x1BD362, - ["CableClub_Text_WelcomeUnionRoomEnter"] = 0x1BD65B, - ["CableClub_Text_WelcomeWhichCableClubService"] = 0x1BC311, - ["CableClub_Text_WelcomeWhichDirectCornerRoom"] = 0x1BD28D, - ["CableClub_Text_WhenAllPlayersReadyAConfirmBCancel"] = 0x1BC4CE, - ["CableClub_Text_WhichBattleMode"] = 0x1BCB62, - ["CableClub_Text_WhichService"] = 0x1BC35E, - ["CableClub_Text_YouHaveAMonThatCantBeTaken"] = 0x1BC9E0, - ["CableClub_Text_YouMayBattleHere"] = 0x1BCACB, - ["CableClub_Text_YouMayTradeHere"] = 0x1BCA95, - ["CeladonCity_GameCorner_Text_BigSmileForPhoto"] = 0x1B1E42, - ["CeladonCity_GameCorner_Text_ChoosePrintType"] = 0x1B1E24, - ["CeladonCity_GameCorner_Text_DontHaveEnoughMoney"] = 0x1B1E07, - ["CeladonCity_GameCorner_Text_PrintIsReadyCheckTrainerCard"] = 0x1B1E7D, - ["CeladonCity_GameCorner_Text_TryPokemonPrinter"] = 0x1B1D7D, - ["DayCare_Text_ComeAgain"] = 0x1BF976, - ["DayCare_Text_DoYouWantEgg"] = 0x1BF5E3, - ["DayCare_Text_FineThenComeAgain"] = 0x1BF916, - ["DayCare_Text_GoodToSeeYou"] = 0x1BF988, - ["DayCare_Text_HeresYourMon"] = 0x1BFA67, - ["DayCare_Text_Huh"] = 0x1BFB5A, - ["DayCare_Text_HusbandWasLookingForYou"] = 0x1BF8F6, - ["DayCare_Text_IWillKeepDoYouWantIt"] = 0x1BF7B6, - ["DayCare_Text_IllKeepIt"] = 0x1BF6CF, - ["DayCare_Text_ImDaycareManSpeakToMyWife"] = 0x1BF555, - ["DayCare_Text_ItWillCostX"] = 0x1BFA3B, - ["DayCare_Text_NotEnoughMoney"] = 0x1BF932, - ["DayCare_Text_ReceivedEgg"] = 0x1BF72A, - ["DayCare_Text_SeeWifeIfYouWantToPickUpMon"] = 0x1BF76B, - ["DayCare_Text_TakeBackWhichMon"] = 0x1BFA28, - ["DayCare_Text_TakeGoodCareOfIt"] = 0x1BF755, - ["DayCare_Text_TakeOtherOneBackToo"] = 0x1BF94F, - ["DayCare_Text_TakeYourMonBack"] = 0x1BFAE8, - ["DayCare_Text_TookBackMon"] = 0x1BFA85, - ["DayCare_Text_WeCanRaiseOneMore"] = 0x1BF89F, - ["DayCare_Text_WellRaiseYourMon"] = 0x1BF860, - ["DayCare_Text_WhatWillYouBattleWith"] = 0x1BFB09, - ["DayCare_Text_WhichMonShouldWeRaise"] = 0x1BF839, - ["DayCare_Text_WouldYouLikeUsToRaiseMon"] = 0x1BF7E4, - ["DayCare_Text_YouHaveJustOneMon"] = 0x1BFAAD, - ["DayCare_Text_YouHaveNoRoomForIt"] = 0x1BF6F0, - ["DayCare_Text_YourMonHasGrownXLevels"] = 0x1BF9CC, - ["DayCare_Text_YourMonIsDoingFine"] = 0x1BF69A, - ["DayCare_Text_YourMonsAreDoingFine"] = 0x1BF789, - ["DayCare_Text_YourPartyIsFull"] = 0x1BF9EF, - ["EventScript_ExplainPokemonJumpRequirements"] = 0x1BD0CF, - ["FiveIsland_MemorialPillar_Text_ChazRematchIntro"] = 0x1C3E0F, - ["FiveIsland_MemorialPillar_Text_HaroldRematchIntro"] = 0x1C3E6A, - ["FiveIsland_MemorialPillar_Text_MiloRematchIntro"] = 0x1C3DA2, - ["FiveIsland_ResortGorgeous_Text_CelinaRematchIntro"] = 0x1C3C28, - ["FiveIsland_ResortGorgeous_Text_DaisyRematchIntro"] = 0x1C3BF6, - ["FiveIsland_ResortGorgeous_Text_DestinRematchIntro"] = 0x1C3D47, - ["FiveIsland_ResortGorgeous_Text_GillianRematchIntro"] = 0x1C3CF1, - ["FiveIsland_ResortGorgeous_Text_JackiRematchIntro"] = 0x1C3CB0, - ["FiveIsland_ResortGorgeous_Text_RaynaRematchIntro"] = 0x1C3C70, - ["FiveIsland_ResortGorgeous_Text_TobyRematchIntro"] = 0x1C3D73, - ["FiveIsland_WaterLabyrinth_Text_AlizeRematchIntro"] = 0x1C3BB7, - ["FourIsland_House2_Text_BragAboutSomethingForMe"] = 0x1B1FEF, - ["FourIsland_House2_Text_BragAboutWhatToday"] = 0x1B2025, - ["FourIsland_House2_Text_ComeBackWithBetterStoryEggs"] = 0x1B251B, - ["FourIsland_House2_Text_ComeBackWithBetterStoryHoF"] = 0x1B227C, - ["FourIsland_House2_Text_ComeBackWithBetterStoryLinkWins"] = 0x1B27DB, - ["FourIsland_House2_Text_EggStickerApplied"] = 0x1B24EA, - ["FourIsland_House2_Text_GiveYouStickerIfYouBrag"] = 0x1B1EF8, - ["FourIsland_House2_Text_HoFStickerApplied"] = 0x1B2242, - ["FourIsland_House2_Text_LivingLegendEggs"] = 0x1B254F, - ["FourIsland_House2_Text_LivingLegendHoF"] = 0x1B22B0, - ["FourIsland_House2_Text_LivingLegendLinkWins"] = 0x1B280F, - ["FourIsland_House2_Text_VictoryStickerApplied"] = 0x1B27A6, - ["FourIsland_House2_Text_WishICouldShowOffStickers"] = 0x1B1EB3, - ["FourIsland_House2_Text_WowEggsHigh"] = 0x1B23E0, - ["FourIsland_House2_Text_WowEggsHighest"] = 0x1B2448, - ["FourIsland_House2_Text_WowEggsLow"] = 0x1B2372, - ["FourIsland_House2_Text_WowEggsLowest"] = 0x1B2308, - ["FourIsland_House2_Text_WowHoFHigh"] = 0x1B2133, - ["FourIsland_House2_Text_WowHoFHighest"] = 0x1B21C4, - ["FourIsland_House2_Text_WowHoFLow"] = 0x1B20CF, - ["FourIsland_House2_Text_WowHoFLowest"] = 0x1B206E, - ["FourIsland_House2_Text_WowLinkWinsHigh"] = 0x1B267B, - ["FourIsland_House2_Text_WowLinkWinsHighest"] = 0x1B26F6, - ["FourIsland_House2_Text_WowLinkWinsLow"] = 0x1B2609, - ["FourIsland_House2_Text_WowLinkWinsLowest"] = 0x1B25A7, - ["GiddyText_DontYouAgree"] = 0x418B1A, - ["GiddyText_IBoughtCrayonsWith120Colors"] = 0x418B40, - ["GiddyText_ISoWantToGoOnAVacation"] = 0x418B25, - ["GiddyText_Is"] = 0x418B17, - ["GiddyText_LyingBackInTheGreenGrass"] = 0x418BDA, - ["GiddyText_SecretBasesAreSoWonderful"] = 0x418BF3, - ["GiddyText_SoAmusing"] = 0x418B02, - ["GiddyText_SoDarling"] = 0x418ACA, - ["GiddyText_SoDesirable"] = 0x418AEB, - ["GiddyText_SoExciting"] = 0x418AF6, - ["GiddyText_SoMagical"] = 0x418B0D, - ["GiddyText_SoPretty"] = 0x418AC0, - ["GiddyText_SoRelaxed"] = 0x418AD4, - ["GiddyText_SoSunny"] = 0x418ADF, - ["GiddyText_WhatsTheBottomOfTheSeaLike"] = 0x418B99, - ["GiddyText_WhenYouSeeTheSettingSunDoesIt"] = 0x418BC0, - ["GiddyText_WhenYouWriteOnASandyBeach"] = 0x418B77, - ["GiddyText_WouldntItBeNiceIfWeCouldFloat"] = 0x418B5C, - ["Help_Text_Ability"] = 0x1B7D1F, - ["Help_Text_AboutThisGame"] = 0x1B2E48, - ["Help_Text_Accuracy"] = 0x1B7D4E, - ["Help_Text_AnswerCanIBuyPokeBalls"] = 0x1B4645, - ["Help_Text_AnswerCantCatchMons"] = 0x1B4483, - ["Help_Text_AnswerCantFindPersonIWant"] = 0x1B379A, - ["Help_Text_AnswerCantGetOutOfRoom"] = 0x1B36EB, - ["Help_Text_AnswerFoeMonsTooStrong"] = 0x1B4E0B, - ["Help_Text_AnswerHowAreCavesDifferent"] = 0x1B3D1B, - ["Help_Text_AnswerHowAreRoadsForestsDifferent"] = 0x1B3C99, - ["Help_Text_AnswerHowDoIPrepareForBattle"] = 0x1B406C, - ["Help_Text_AnswerHowDoIProgress"] = 0x1B3DE3, - ["Help_Text_AnswerHowDoIWinAgainstTrainer"] = 0x1B47F0, - ["Help_Text_AnswerICantGoOn"] = 0x1B3972, - ["Help_Text_AnswerMyMonsAreHurt"] = 0x1B41D7, - ["Help_Text_AnswerNothingIWantToKnow"] = 0x1B4FB2, - ["Help_Text_AnswerOutOfThingsToDo"] = 0x1B3A51, - ["Help_Text_AnswerPlayingForFirstTime"] = 0x1B3525, - ["Help_Text_AnswerRanOutOfPotions"] = 0x1B457C, - ["Help_Text_AnswerSomeoneBlockingMyWay"] = 0x1B3876, - ["Help_Text_AnswerTalkedToEveryoneNowWhat"] = 0x1B3849, - ["Help_Text_AnswerWantToAddMoreMoves"] = 0x1B4C54, - ["Help_Text_AnswerWantToEndGame"] = 0x1B51B1, - ["Help_Text_AnswerWantToEndSafari"] = 0x1B5589, - ["Help_Text_AnswerWantToMakeMonStronger"] = 0x1B4D26, - ["Help_Text_AnswerWhatAreHiddenMoves"] = 0x1B4A72, - ["Help_Text_AnswerWhatAreMoves"] = 0x1B497A, - ["Help_Text_AnswerWhatAreMyAdventureBasics"] = 0x1B3BB6, - ["Help_Text_AnswerWhatAreSafariRules"] = 0x1B54E1, - ["Help_Text_AnswerWhatDoIDoInCave"] = 0x1B4ED8, - ["Help_Text_AnswerWhatDoIDoInSafari"] = 0x1B547C, - ["Help_Text_AnswerWhatDoesHiddenMoveDo"] = 0x1B5382, - ["Help_Text_AnswerWhatHappenedToItemIGot"] = 0x1B3ACC, - ["Help_Text_AnswerWhatHappensIfAllMyMonsFaint"] = 0x1B439D, - ["Help_Text_AnswerWhatIsAGym"] = 0x1B55F4, - ["Help_Text_AnswerWhatIsAMonsVitality"] = 0x1B410B, - ["Help_Text_AnswerWhatIsStatusProblem"] = 0x1B42B3, - ["Help_Text_AnswerWhatIsThatPersonLike"] = 0x1B5325, - ["Help_Text_AnswerWhatMovesShouldIUse"] = 0x1B4B65, - ["Help_Text_AnswerWhatShouldIBeDoing"] = 0x1B35E6, - ["Help_Text_AnswerWhatsABattle"] = 0x1B3F7F, - ["Help_Text_AnswerWhatsAMon"] = 0x1B5272, - ["Help_Text_AnswerWhatsATrainer"] = 0x1B470A, - ["Help_Text_AnswerWhatsPokemonCenter"] = 0x1B4FFD, - ["Help_Text_AnswerWhatsPokemonMart"] = 0x1B50FF, - ["Help_Text_AnswerWhenCanIUseItem"] = 0x1B3EBC, - ["Help_Text_AnswerWhereDoMonsAppear"] = 0x1B48C6, - ["Help_Text_Attack"] = 0x1B7CDF, - ["Help_Text_Badges"] = 0x1B7D88, - ["Help_Text_Badges2"] = 0x1B7E0F, - ["Help_Text_BattleScene"] = 0x1B7D9A, - ["Help_Text_BattleStyle"] = 0x1B7DA7, - ["Help_Text_ButtonMode"] = 0x1B7DBA, - ["Help_Text_CanIBuyPokeBalls"] = 0x1B32E3, - ["Help_Text_Cancel"] = 0x1B2E6F, - ["Help_Text_Cancel2"] = 0x1B7DCC, - ["Help_Text_CantCatchMons"] = 0x1B32B6, - ["Help_Text_CantFindPersonIWant"] = 0x1B30DC, - ["Help_Text_CantGetOutOfRoom"] = 0x1B30C1, - ["Help_Text_Defense"] = 0x1B7CE6, - ["Help_Text_DefineAbility"] = 0x1B87B8, - ["Help_Text_DefineAccuracy"] = 0x1B8C94, - ["Help_Text_DefineAttack"] = 0x1B80EC, - ["Help_Text_DefineBadges"] = 0x1B90E8, - ["Help_Text_DefineBadges2"] = 0x1B9B2F, - ["Help_Text_DefineBattleScene"] = 0x1B91C2, - ["Help_Text_DefineBattleStyle"] = 0x1B91F9, - ["Help_Text_DefineButtonMode"] = 0x1B92ED, - ["Help_Text_DefineCancel2"] = 0x1B9439, - ["Help_Text_DefineDefense"] = 0x1B81C2, - ["Help_Text_DefineEXP"] = 0x1B7F0A, - ["Help_Text_DefineEvolution"] = 0x1B9749, - ["Help_Text_DefineFNT"] = 0x1B8D1D, - ["Help_Text_DefineFrame"] = 0x1B93D8, - ["Help_Text_DefineHM"] = 0x1B9560, - ["Help_Text_DefineHMMove"] = 0x1B9656, - ["Help_Text_DefineHP"] = 0x1B7E16, - ["Help_Text_DefineIDNo"] = 0x1B8A84, - ["Help_Text_DefineIDNo2"] = 0x1B99C4, - ["Help_Text_DefineItem"] = 0x1B86E2, - ["Help_Text_DefineItems"] = 0x1B8DD4, - ["Help_Text_DefineKeyItems"] = 0x1B8E67, - ["Help_Text_DefineLevel"] = 0x1B847B, - ["Help_Text_DefineMoney"] = 0x1B8897, - ["Help_Text_DefineMoney2"] = 0x1B9AA2, - ["Help_Text_DefineMoveType"] = 0x1B8924, - ["Help_Text_DefineMoves"] = 0x1B800A, - ["Help_Text_DefineNature"] = 0x1B8A04, - ["Help_Text_DefineOT"] = 0x1B8647, - ["Help_Text_DefinePP"] = 0x1B8B62, - ["Help_Text_DefinePlayTime"] = 0x1B90A7, - ["Help_Text_DefinePokeBalls"] = 0x1B8F4D, - ["Help_Text_DefinePokedex"] = 0x1B901B, - ["Help_Text_DefinePokemon"] = 0x1B991C, - ["Help_Text_DefinePower"] = 0x1B8C18, - ["Help_Text_DefineSound"] = 0x1B92B8, - ["Help_Text_DefineSpAtk"] = 0x1B8256, - ["Help_Text_DefineSpDef"] = 0x1B8348, - ["Help_Text_DefineSpeed"] = 0x1B83EF, - ["Help_Text_DefineStatusProblem"] = 0x1B984F, - ["Help_Text_DefineTM"] = 0x1B9497, - ["Help_Text_DefineTextSpeed"] = 0x1B9170, - ["Help_Text_DefineType"] = 0x1B8550, - ["Help_Text_DescAboutThisGame"] = 0x1B2F43, - ["Help_Text_DescExit"] = 0x1B2FA9, - ["Help_Text_DescGameFundamentals1"] = 0x1B9E75, - ["Help_Text_DescGameFundamentals2"] = 0x1B9F09, - ["Help_Text_DescGameFundamentals3"] = 0x1B9FCE, - ["Help_Text_DescHowDoIDoThis"] = 0x1B2EC8, - ["Help_Text_DescTheGame"] = 0x1B9D04, - ["Help_Text_DescTheHelpSystem"] = 0x1B9C2F, - ["Help_Text_DescTypeMatchupList"] = 0x1B2F74, - ["Help_Text_DescWhatArePokemon"] = 0x1BA027, - ["Help_Text_DescWhatDoesThisTermMean"] = 0x1B2F00, - ["Help_Text_DescWhatShouldIDo"] = 0x1B2E88, - ["Help_Text_DescWirelessAdapter"] = 0x1B9DC5, - ["Help_Text_EXP"] = 0x1B7CC4, - ["Help_Text_EnteringName"] = 0x1B5824, - ["Help_Text_Evolution"] = 0x1B7DE1, - ["Help_Text_Exit"] = 0x1B2E6A, - ["Help_Text_FNT"] = 0x1B7D57, - ["Help_Text_FoeMonsTooStrong"] = 0x1B33CA, - ["Help_Text_Frame"] = 0x1B7DC6, - ["Help_Text_GameFundamentals1"] = 0x1B9BE1, - ["Help_Text_GameFundamentals2"] = 0x1B9BF5, - ["Help_Text_GameFundamentals3"] = 0x1B9C09, - ["Help_Text_Greetings"] = 0x1B2FC9, - ["Help_Text_HM"] = 0x1B7DD6, - ["Help_Text_HMMove"] = 0x1B7DD9, - ["Help_Text_HP"] = 0x1B7CC1, - ["Help_Text_HowAreCavesDifferent"] = 0x1B31D3, - ["Help_Text_HowAreRoadsForestsDifferent"] = 0x1B31AE, - ["Help_Text_HowDoIDoThis"] = 0x1B2E1C, - ["Help_Text_HowDoIPrepareForBattle"] = 0x1B3226, - ["Help_Text_HowDoIProgress"] = 0x1B31EC, - ["Help_Text_HowDoIWinAgainstTrainer"] = 0x1B330B, - ["Help_Text_HowToEnterName"] = 0x1B678E, - ["Help_Text_HowToMoveItems"] = 0x1B6C4F, - ["Help_Text_HowToOpenMenu"] = 0x1B7108, - ["Help_Text_HowToReadPokedex"] = 0x1B752C, - ["Help_Text_HowToRegisterKeyItem"] = 0x1B79CB, - ["Help_Text_HowToRideBicycle"] = 0x1B66BA, - ["Help_Text_HowToUseAnItem"] = 0x1B5E41, - ["Help_Text_HowToUseBag"] = 0x1B5D87, - ["Help_Text_HowToUseBag2"] = 0x1B747E, - ["Help_Text_HowToUseBait"] = 0x1B7AEE, - ["Help_Text_HowToUseBall"] = 0x1B7A60, - ["Help_Text_HowToUseBillsPC"] = 0x1B68CD, - ["Help_Text_HowToUseDeposit"] = 0x1B6A9A, - ["Help_Text_HowToUseDepositItem"] = 0x1B6EC1, - ["Help_Text_HowToUseDepositItem2"] = 0x1B77DD, - ["Help_Text_HowToUseFight"] = 0x1B71EA, - ["Help_Text_HowToUseHM"] = 0x1B6525, - ["Help_Text_HowToUseHallOfFame"] = 0x1B7C57, - ["Help_Text_HowToUseHomePC"] = 0x1B7611, - ["Help_Text_HowToUseItem"] = 0x1B5CDF, - ["Help_Text_HowToUseItemStorage"] = 0x1B7692, - ["Help_Text_HowToUseKeyItem"] = 0x1B5F10, - ["Help_Text_HowToUseMailbox"] = 0x1B6FA8, - ["Help_Text_HowToUseMailbox2"] = 0x1B7884, - ["Help_Text_HowToUseMove"] = 0x1B6B6E, - ["Help_Text_HowToUseMoveOutsideOfBattle"] = 0x1B65E7, - ["Help_Text_HowToUseOption"] = 0x1B6203, - ["Help_Text_HowToUsePC"] = 0x1B6883, - ["Help_Text_HowToUsePlayer"] = 0x1B606C, - ["Help_Text_HowToUsePlayersPC"] = 0x1B6D4A, - ["Help_Text_HowToUsePokeBall"] = 0x1B5FA6, - ["Help_Text_HowToUsePokedex"] = 0x1B5A4D, - ["Help_Text_HowToUsePokemon"] = 0x1B5B0C, - ["Help_Text_HowToUsePokemon2"] = 0x1B723B, - ["Help_Text_HowToUsePotion"] = 0x1B62E4, - ["Help_Text_HowToUseProfOaksPC"] = 0x1B7075, - ["Help_Text_HowToUseRock"] = 0x1B7BBE, - ["Help_Text_HowToUseRun"] = 0x1B7931, - ["Help_Text_HowToUseSave"] = 0x1B6140, - ["Help_Text_HowToUseShift"] = 0x1B7319, - ["Help_Text_HowToUseSummary"] = 0x1B5B7D, - ["Help_Text_HowToUseSummary2"] = 0x1B73E8, - ["Help_Text_HowToUseSwitch"] = 0x1B5C13, - ["Help_Text_HowToUseTM"] = 0x1B6478, - ["Help_Text_HowToUseTownMap"] = 0x1B6397, - ["Help_Text_HowToUseTypeMatchupList"] = 0x1BA416, - ["Help_Text_HowToUseWithdraw"] = 0x1B69B9, - ["Help_Text_HowToUseWithdrawItem"] = 0x1B6E02, - ["Help_Text_HowToUseWithdrawItem2"] = 0x1B771E, - ["Help_Text_ICantGoOn"] = 0x1B3140, - ["Help_Text_IDNo"] = 0x1B7D3E, - ["Help_Text_IDNo2"] = 0x1B7E02, - ["Help_Text_Item"] = 0x1B7D1A, - ["Help_Text_Items"] = 0x1B7D5B, - ["Help_Text_KeyItems"] = 0x1B7D61, - ["Help_Text_Level"] = 0x1B7D04, - ["Help_Text_Money"] = 0x1B7D27, - ["Help_Text_Money2"] = 0x1B7E09, - ["Help_Text_MoveType"] = 0x1B7D2D, - ["Help_Text_Moves"] = 0x1B7CD9, - ["Help_Text_MovingItems"] = 0x1B5884, - ["Help_Text_MyMonsAreHurt"] = 0x1B3261, - ["Help_Text_Nature"] = 0x1B7D37, - ["Help_Text_NothingIWantToKnow"] = 0x1B3402, - ["Help_Text_OT"] = 0x1B7D17, - ["Help_Text_OpeningMenu"] = 0x1B58FD, - ["Help_Text_OutOfThingsToDo"] = 0x1B314F, - ["Help_Text_OwnMoveBug"] = 0x1BA3ED, - ["Help_Text_OwnMoveDark"] = 0x1BA10D, - ["Help_Text_OwnMoveDragon"] = 0x1BA2DB, - ["Help_Text_OwnMoveElectric"] = 0x1BA279, - ["Help_Text_OwnMoveFighting"] = 0x1BA194, - ["Help_Text_OwnMoveFire"] = 0x1BA395, - ["Help_Text_OwnMoveFlying"] = 0x1BA366, - ["Help_Text_OwnMoveGhost"] = 0x1BA1F4, - ["Help_Text_OwnMoveGrass"] = 0x1BA1C7, - ["Help_Text_OwnMoveGround"] = 0x1BA24A, - ["Help_Text_OwnMoveIce"] = 0x1BA221, - ["Help_Text_OwnMoveNormal"] = 0x1BA30A, - ["Help_Text_OwnMovePoison"] = 0x1BA2AC, - ["Help_Text_OwnMovePsychic"] = 0x1BA163, - ["Help_Text_OwnMoveRock"] = 0x1BA138, - ["Help_Text_OwnMoveSteel"] = 0x1BA339, - ["Help_Text_OwnMoveWater"] = 0x1BA3C0, - ["Help_Text_OwnPokemonBug"] = 0x1BA400, - ["Help_Text_OwnPokemonDark"] = 0x1BA121, - ["Help_Text_OwnPokemonDragon"] = 0x1BA2F1, - ["Help_Text_OwnPokemonElectric"] = 0x1BA291, - ["Help_Text_OwnPokemonFighting"] = 0x1BA1AC, - ["Help_Text_OwnPokemonFire"] = 0x1BA3A9, - ["Help_Text_OwnPokemonFlying"] = 0x1BA37C, - ["Help_Text_OwnPokemonGhost"] = 0x1BA209, - ["Help_Text_OwnPokemonGrass"] = 0x1BA1DC, - ["Help_Text_OwnPokemonGround"] = 0x1BA260, - ["Help_Text_OwnPokemonIce"] = 0x1BA234, - ["Help_Text_OwnPokemonNormal"] = 0x1BA320, - ["Help_Text_OwnPokemonPoison"] = 0x1BA2C2, - ["Help_Text_OwnPokemonPsychic"] = 0x1BA17A, - ["Help_Text_OwnPokemonRock"] = 0x1BA14C, - ["Help_Text_OwnPokemonSteel"] = 0x1BA34E, - ["Help_Text_OwnPokemonWater"] = 0x1BA3D5, - ["Help_Text_PP"] = 0x1B7D45, - ["Help_Text_PlayTime"] = 0x1B7D7E, - ["Help_Text_PlayingForFirstTime"] = 0x1B3083, - ["Help_Text_PokeBalls"] = 0x1B7D6B, - ["Help_Text_Pokedex"] = 0x1B7D76, - ["Help_Text_Pokemon"] = 0x1B7DFA, - ["Help_Text_Power"] = 0x1B7D48, - ["Help_Text_RanOutOfPotions"] = 0x1B32CD, - ["Help_Text_ReadingPokedex"] = 0x1B595D, - ["Help_Text_RegisterKeyItem"] = 0x1B59F5, - ["Help_Text_RidingBicycle"] = 0x1B580D, - ["Help_Text_SomeoneBlockingMyWay"] = 0x1B311F, - ["Help_Text_Sound"] = 0x1B7DB4, - ["Help_Text_SpAtk"] = 0x1B7CEE, - ["Help_Text_SpDef"] = 0x1B7CF6, - ["Help_Text_Speed"] = 0x1B7CFE, - ["Help_Text_StatusProblem"] = 0x1B7DEB, - ["Help_Text_TM"] = 0x1B7DD3, - ["Help_Text_TalkedToEveryoneNowWhat"] = 0x1B30FC, - ["Help_Text_TextSpeed"] = 0x1B7D8F, - ["Help_Text_TheGame"] = 0x1B9BC7, - ["Help_Text_TheHelpSystem"] = 0x1B9BB7, - ["Help_Text_TryDoingItOnYourOwn"] = 0x1B2E76, - ["Help_Text_Type"] = 0x1B7D12, - ["Help_Text_TypeMatchupList"] = 0x1B2E58, - ["Help_Text_TypeMatchupOwnMoveBug"] = 0x1BB0DF, - ["Help_Text_TypeMatchupOwnMoveDark"] = 0x1BA4E6, - ["Help_Text_TypeMatchupOwnMoveDragon"] = 0x1BAC89, - ["Help_Text_TypeMatchupOwnMoveElectric"] = 0x1BAB18, - ["Help_Text_TypeMatchupOwnMoveFighting"] = 0x1BA71F, - ["Help_Text_TypeMatchupOwnMoveFire"] = 0x1BAF6B, - ["Help_Text_TypeMatchupOwnMoveFlying"] = 0x1BAEA8, - ["Help_Text_TypeMatchupOwnMoveGhost"] = 0x1BA8D3, - ["Help_Text_TypeMatchupOwnMoveGrass"] = 0x1BA7E9, - ["Help_Text_TypeMatchupOwnMoveGround"] = 0x1BAA44, - ["Help_Text_TypeMatchupOwnMoveIce"] = 0x1BA98D, - ["Help_Text_TypeMatchupOwnMoveNormal"] = 0x1BAD20, - ["Help_Text_TypeMatchupOwnMovePoison"] = 0x1BABCC, - ["Help_Text_TypeMatchupOwnMovePsychic"] = 0x1BA66F, - ["Help_Text_TypeMatchupOwnMoveRock"] = 0x1BA595, - ["Help_Text_TypeMatchupOwnMoveSteel"] = 0x1BADA2, - ["Help_Text_TypeMatchupOwnMoveWater"] = 0x1BB02E, - ["Help_Text_TypeMatchupOwnPokemonBug"] = 0x1BB156, - ["Help_Text_TypeMatchupOwnPokemonDark"] = 0x1BA539, - ["Help_Text_TypeMatchupOwnPokemonDragon"] = 0x1BACC4, - ["Help_Text_TypeMatchupOwnPokemonElectric"] = 0x1BAB7A, - ["Help_Text_TypeMatchupOwnPokemonFighting"] = 0x1BA796, - ["Help_Text_TypeMatchupOwnPokemonFire"] = 0x1BAFCA, - ["Help_Text_TypeMatchupOwnPokemonFlying"] = 0x1BAF01, - ["Help_Text_TypeMatchupOwnPokemonGhost"] = 0x1BA92A, - ["Help_Text_TypeMatchupOwnPokemonGrass"] = 0x1BA862, - ["Help_Text_TypeMatchupOwnPokemonGround"] = 0x1BAAB6, - ["Help_Text_TypeMatchupOwnPokemonIce"] = 0x1BA9F1, - ["Help_Text_TypeMatchupOwnPokemonNormal"] = 0x1BAD60, - ["Help_Text_TypeMatchupOwnPokemonPoison"] = 0x1BAC29, - ["Help_Text_TypeMatchupOwnPokemonPsychic"] = 0x1BA6C9, - ["Help_Text_TypeMatchupOwnPokemonRock"] = 0x1BA5F2, - ["Help_Text_TypeMatchupOwnPokemonSteel"] = 0x1BADF7, - ["Help_Text_TypeMatchupOwnPokemonWater"] = 0x1BB084, - ["Help_Text_UsingAnItem"] = 0x1B5744, - ["Help_Text_UsingBag"] = 0x1B5737, - ["Help_Text_UsingBag2"] = 0x1B5950, - ["Help_Text_UsingBait"] = 0x1B5A1B, - ["Help_Text_UsingBall"] = 0x1B5A0D, - ["Help_Text_UsingBillsPC"] = 0x1B583F, - ["Help_Text_UsingDeposit"] = 0x1B5863, - ["Help_Text_UsingDepositItem"] = 0x1B58BC, - ["Help_Text_UsingDepositItem2"] = 0x1B59BF, - ["Help_Text_UsingFight"] = 0x1B590E, - ["Help_Text_UsingHM"] = 0x1B57DE, - ["Help_Text_UsingHallOfFame"] = 0x1B5A37, - ["Help_Text_UsingHomePC"] = 0x1B5974, - ["Help_Text_UsingItem"] = 0x1B5728, - ["Help_Text_UsingItemStorage"] = 0x1B5989, - ["Help_Text_UsingKeyItem"] = 0x1B5754, - ["Help_Text_UsingMailbox"] = 0x1B58D3, - ["Help_Text_UsingMailbox2"] = 0x1B59D6, - ["Help_Text_UsingMove"] = 0x1B5875, - ["Help_Text_UsingMoveOutsideOfBattle"] = 0x1B57EE, - ["Help_Text_UsingOption"] = 0x1B5795, - ["Help_Text_UsingPC"] = 0x1B5834, - ["Help_Text_UsingPlayer"] = 0x1B577B, - ["Help_Text_UsingPlayersPC"] = 0x1B5893, - ["Help_Text_UsingPokeBall"] = 0x1B5767, - ["Help_Text_UsingPokedex"] = 0x1B56E3, - ["Help_Text_UsingPokemon"] = 0x1B56F4, - ["Help_Text_UsingPokemon2"] = 0x1B591D, - ["Help_Text_UsingPotion"] = 0x1B57A5, - ["Help_Text_UsingProfOaksPC"] = 0x1B58E5, - ["Help_Text_UsingRock"] = 0x1B5A29, - ["Help_Text_UsingRun"] = 0x1B59E8, - ["Help_Text_UsingSave"] = 0x1B5787, - ["Help_Text_UsingShift"] = 0x1B592E, - ["Help_Text_UsingSummary"] = 0x1B5705, - ["Help_Text_UsingSummary2"] = 0x1B593E, - ["Help_Text_UsingSwitch"] = 0x1B5717, - ["Help_Text_UsingTM"] = 0x1B57CF, - ["Help_Text_UsingTownMap"] = 0x1B57B8, - ["Help_Text_UsingTypeMatchupList"] = 0x1BA0F1, - ["Help_Text_UsingWithdraw"] = 0x1B5850, - ["Help_Text_UsingWithdrawItem"] = 0x1B58A4, - ["Help_Text_UsingWithdrawItem2"] = 0x1B59A7, - ["Help_Text_WantToAddMoreMoves"] = 0x1B338C, - ["Help_Text_WantToEndGame"] = 0x1B3457, - ["Help_Text_WantToEndSafari"] = 0x1B34F6, - ["Help_Text_WantToMakeMonStronger"] = 0x1B33A6, - ["Help_Text_WhatAreHiddenMoves"] = 0x1B335C, - ["Help_Text_WhatAreMoves"] = 0x1B3344, - ["Help_Text_WhatAreMyAdventureBasics"] = 0x1B3189, - ["Help_Text_WhatArePokemon"] = 0x1B9C1D, - ["Help_Text_WhatAreSafariRules"] = 0x1B34D6, - ["Help_Text_WhatDoIDoInCave"] = 0x1B33EA, - ["Help_Text_WhatDoIDoInSafari"] = 0x1B34B7, - ["Help_Text_WhatDoesHiddenMoveDo"] = 0x1B349B, - ["Help_Text_WhatDoesThisTermMean"] = 0x1B2E2E, - ["Help_Text_WhatHappenedToItemIGot"] = 0x1B3168, - ["Help_Text_WhatHappensIfAllMyMonsFaint"] = 0x1B3290, - ["Help_Text_WhatIsAGym"] = 0x1B3516, - ["Help_Text_WhatIsAMonsVitality"] = 0x1B3243, - ["Help_Text_WhatIsStatusProblem"] = 0x1B3276, - ["Help_Text_WhatIsThatPersonLike"] = 0x1B3481, - ["Help_Text_WhatMovesShouldIUse"] = 0x1B3373, - ["Help_Text_WhatShouldIBeDoing"] = 0x1B30A9, - ["Help_Text_WhatShouldIDo"] = 0x1B2DF8, - ["Help_Text_WhatsABattle"] = 0x1B3215, - ["Help_Text_WhatsAMon"] = 0x1B346F, - ["Help_Text_WhatsATrainer"] = 0x1B32F9, - ["Help_Text_WhatsPokemonCenter"] = 0x1B3427, - ["Help_Text_WhatsPokemonMart"] = 0x1B3440, - ["Help_Text_WhenCanIUseItem"] = 0x1B31FF, - ["Help_Text_WhereDoMonsAppear"] = 0x1B332B, - ["Help_Text_WirelessAdapter"] = 0x1B9BD0, - ["LilycoveCity_Text_BecauseYoureTrainer"] = 0x1C07DF, - ["LilycoveCity_Text_BerrySuitsYou"] = 0x1C0799, - ["LilycoveCity_Text_PokeblocksSuitPokemon"] = 0x1C07FB, - ["OneIsland_KindleRoad_Text_AbigailRematchIntro"] = 0x1C37E7, - ["OneIsland_KindleRoad_Text_BryceRematchIntro"] = 0x1C3987, - ["OneIsland_KindleRoad_Text_ClaireRematchIntro"] = 0x1C39C6, - ["OneIsland_KindleRoad_Text_FinnRematchIntro"] = 0x1C3807, - ["OneIsland_KindleRoad_Text_GarrettRematchIntro"] = 0x1C3835, - ["OneIsland_KindleRoad_Text_HughRematchIntro"] = 0x1C3943, - ["OneIsland_KindleRoad_Text_KiaRematchIntro"] = 0x1C3A05, - ["OneIsland_KindleRoad_Text_MariaRematchIntro"] = 0x1C37B5, - ["OneIsland_KindleRoad_Text_MikRematchIntro"] = 0x1C3A55, - ["OneIsland_KindleRoad_Text_SharonRematchIntro"] = 0x1C389F, - ["OneIsland_KindleRoad_Text_SheaRematchIntro"] = 0x1C38FA, - ["OneIsland_KindleRoad_Text_TanyaRematchIntro"] = 0x1C38CA, - ["OneIsland_KindleRoad_Text_TommyRematchIntro"] = 0x1C386A, - ["OneIsland_TreasureBeach_Text_AmaraRematchIntro"] = 0x1C3773, - ["PalletTown_Text_HmmIsThatRight"] = 0x1B1C8B, - ["PalletTown_Text_LookCopiedTrainerTipsSign"] = 0x1B1D0B, - ["PalletTown_Text_OhLookLook"] = 0x1B1C9F, - ["PalletTown_Text_PressStartToOpenMenu"] = 0x1B1CC1, - ["PalletTown_Text_PressStartToOpenMenuCopy"] = 0x1B1D51, - ["PalletTown_Text_ReadItReadIt"] = 0x1B1CAF, - ["PalletTown_Text_SignsAreUsefulArentThey"] = 0x1B1CEC, - ["PetalburgCity_Gym_Text_GiveEnigmaBerry"] = 0x1C0523, - ["PokedexRating_Text_Complete"] = 0x1A7175, - ["PokedexRating_Text_HaHa"] = 0x1A72A6, - ["PokedexRating_Text_HowIsPokedexComingAlong"] = 0x1A6C51, - ["PokedexRating_Text_LessThan10"] = 0x1A6D17, - ["PokedexRating_Text_LessThan100"] = 0x1A7031, - ["PokedexRating_Text_LessThan110"] = 0x1A7063, - ["PokedexRating_Text_LessThan120"] = 0x1A70A5, - ["PokedexRating_Text_LessThan130"] = 0x1A70D8, - ["PokedexRating_Text_LessThan140"] = 0x1A7108, - ["PokedexRating_Text_LessThan150"] = 0x1A7137, - ["PokedexRating_Text_LessThan20"] = 0x1A6D6D, - ["PokedexRating_Text_LessThan30"] = 0x1A6DDF, - ["PokedexRating_Text_LessThan40"] = 0x1A6E36, - ["PokedexRating_Text_LessThan50"] = 0x1A6EA4, - ["PokedexRating_Text_LessThan60"] = 0x1A6F0B, - ["PokedexRating_Text_LessThan70"] = 0x1A6F71, - ["PokedexRating_Text_LessThan80"] = 0x1A6FAB, - ["PokedexRating_Text_LessThan90"] = 0x1A6FF1, - ["PokedexRating_Text_LookForwardToFilledNationalDex"] = 0x1A71EE, - ["PokedexRating_Text_LoveSeeingYourPokedex"] = 0x1A72F2, - ["PokedexRating_Text_NationalDexSeenXOwnedY"] = 0x1A71AA, - ["PokedexRating_Text_SeenXOwnedY"] = 0x1A6CA3, - ["PokedexRating_Text_ThankYouMadeDreamReality"] = 0x1A72AD, - ["PokedexRating_Text_Wroooaaarrr"] = 0x1A7291, - ["PokedexRating_Text_YouveCompletedDex"] = 0x1A722F, - ["Pokedude_Text_BattlersTakeTurnsAttacking"] = 0x1C5FDC, - ["Pokedude_Text_BestIfTargetStatused"] = 0x1C66CF, - ["Pokedude_Text_ButterfreeDoubleResistsGrass"] = 0x1C6446, - ["Pokedude_Text_ButterfreeGoodAgainstOddish"] = 0x1C657A, - ["Pokedude_Text_CantDoubleUpOnStatus"] = 0x1C6787, - ["Pokedude_Text_GrassEffectiveAgainstWater"] = 0x1C6301, - ["Pokedude_Text_HealStatusRightAway"] = 0x1C615A, - ["Pokedude_Text_LetMeThrowBall"] = 0x1C684B, - ["Pokedude_Text_LetsTryShiftingMons"] = 0x1C63A9, - ["Pokedude_Text_MyRattataFasterThanPidgey"] = 0x1C5FA7, - ["Pokedude_Text_MyRattataWonGetsEXP"] = 0x1C601C, - ["Pokedude_Text_PickBestKindOfBall"] = 0x1C686C, - ["Pokedude_Text_ShiftingUsesTurn"] = 0x1C63F9, - ["Pokedude_Text_SpeedierBattlerGoesFirst"] = 0x1C5F69, - ["Pokedude_Text_UhOhRattataPoisoned"] = 0x1C60FA, - ["Pokedude_Text_UsingItemTakesTurn"] = 0x1C6196, - ["Pokedude_Text_WaterNotVeryEffectiveAgainstGrass"] = 0x1C6202, - ["Pokedude_Text_WeakenMonBeforeCatching"] = 0x1C6645, - ["Pokedude_Text_YayWeManagedToWin"] = 0x1C61EA, - ["Pokedude_Text_YeahWeWon"] = 0x1C6637, - ["PokemonJournal_Text_SpecialFeatureAgatha"] = 0x1B19A8, - ["PokemonJournal_Text_SpecialFeatureBlaine"] = 0x1B17E9, - ["PokemonJournal_Text_SpecialFeatureBrock"] = 0x1B146A, - ["PokemonJournal_Text_SpecialFeatureBruno"] = 0x1B1913, - ["PokemonJournal_Text_SpecialFeatureDaisyOak"] = 0x1B1B3D, - ["PokemonJournal_Text_SpecialFeatureErika"] = 0x1B161B, - ["PokemonJournal_Text_SpecialFeatureKoga"] = 0x1B16A8, - ["PokemonJournal_Text_SpecialFeatureLance"] = 0x1B1A31, - ["PokemonJournal_Text_SpecialFeatureLorelei"] = 0x1B187F, - ["PokemonJournal_Text_SpecialFeatureLtSurge"] = 0x1B1558, - ["PokemonJournal_Text_SpecialFeatureMisty"] = 0x1B14E8, - ["PokemonJournal_Text_SpecialFeatureMrFuji"] = 0x1B1BD1, - ["PokemonJournal_Text_SpecialFeatureProfOak"] = 0x1B1AB9, - ["PokemonJournal_Text_SpecialFeatureSabrina"] = 0x1B1753, - ["RecordCorner_Text_ThanksForComing"] = 0x1BC906, - ["Route104_PrettyPetalFlowerShop_Text_BerriesExplanation"] = 0x1C0A4E, - ["Route104_PrettyPetalFlowerShop_Text_FlowersBringHappiness"] = 0x1C0B0B, - ["Route104_PrettyPetalFlowerShop_Text_ImGrowingFlowers"] = 0x1C0BE5, - ["Route104_PrettyPetalFlowerShop_Text_IntroLearnAboutBerries"] = 0x1C0A1A, - ["Route104_PrettyPetalFlowerShop_Text_LearnAboutBerries"] = 0x1C0A07, - ["Route104_PrettyPetalFlowerShop_Text_MachineMixesBerries"] = 0x1C0C12, - ["Route104_PrettyPetalFlowerShop_Text_ThisIsPrettyPetalFlowerShop"] = 0x1C09DF, - ["Route104_PrettyPetalFlowerShop_Text_WailmerPailExplanation"] = 0x1C0B73, - ["Route104_PrettyPetalFlowerShop_Text_YouCanHaveThis"] = 0x1C0B29, - ["Route104_Text_PlantBerriesInSoilTakeThis"] = 0x1C054C, - ["Route104_Text_TrainersOftenMakeMonHoldBerries"] = 0x1C05A8, - ["Route10_Text_CarolRematchIntro"] = 0x1C1D14, - ["Route10_Text_ClarkRematchIntro"] = 0x1C1C4F, - ["Route10_Text_HeidiRematchIntro"] = 0x1C1C9A, - ["Route10_Text_HermanRematchIntro"] = 0x1C1C76, - ["Route10_Text_MarkRematchIntro"] = 0x1C1BFA, - ["Route10_Text_TrentRematchIntro"] = 0x1C1CD3, - ["Route111_Text_TryToMakeRedPokeblock"] = 0x1C0629, - ["Route111_Text_WateredPlantsEveryDayTakeBerry"] = 0x1C05ED, - ["Route111_Text_WhatColorBerriesToLookForToday"] = 0x1C064A, - ["Route114_Text_GatheringBerriesForContest"] = 0x1C0662, - ["Route114_Text_GoodLuckToYouToo"] = 0x1C069C, - ["Route114_Text_WhatBerriesShouldIPlantToday"] = 0x1C06A6, - ["Route11_Text_BernieRematchIntro"] = 0x1C1F1D, - ["Route11_Text_BraxtonRematchIntro"] = 0x1C1DE1, - ["Route11_Text_DarianRematchIntro"] = 0x1C1E9F, - ["Route11_Text_DaveRematchIntro"] = 0x1C1F40, - ["Route11_Text_DillonRematchIntro"] = 0x1C1E1F, - ["Route11_Text_DirkRematchIntro"] = 0x1C1E57, - ["Route11_Text_EddieRematchIntro"] = 0x1C1DB5, - ["Route11_Text_HugoRematchIntro"] = 0x1C1D50, - ["Route11_Text_JasperRematchIntro"] = 0x1C1D79, - ["Route11_Text_YasuRematchIntro"] = 0x1C1EE2, - ["Route120_Text_BerryIsRareRaiseItWithCare"] = 0x1C075F, - ["Route120_Text_IllGetMoreBerriesFromBerryMaster"] = 0x1C0782, - ["Route120_Text_OwnImpressionsImportantTakeThis"] = 0x1C073B, - ["Route120_Text_PokeblocksExpressionOfLove"] = 0x1C06DE, - ["Route120_Text_YouUnderstandTakeThis"] = 0x1C071B, - ["Route121_SafariZoneEntrance_Text_TrainerTip"] = 0x1C0159, - ["Route123_BerryMastersHouse_Text_Ah"] = 0x1C09DA, - ["Route123_BerryMastersHouse_Text_DoneForToday"] = 0x1C08D5, - ["Route123_BerryMastersHouse_Text_GoodSayingTakeThis"] = 0x1C0974, - ["Route123_BerryMastersHouse_Text_HeardAGoodSayingLately"] = 0x1C08FD, - ["Route123_BerryMastersHouse_Text_InspirationalTakeThis"] = 0x1C0948, - ["Route123_BerryMastersHouse_Text_JoyNeverGoesOutOfMyLife"] = 0x1C09A4, - ["Route123_BerryMastersHouse_Text_VisitPrettyPetalFlowerShop"] = 0x1C089C, - ["Route123_BerryMastersHouse_Text_WhyBeStingyTakeAnother"] = 0x1C0888, - ["Route123_BerryMastersHouse_Text_YoureDeservingOfBerry"] = 0x1C0825, - ["Route12_Text_AndrewRematchIntro"] = 0x1C2134, - ["Route12_Text_ChipRematchIntro"] = 0x1C1FD8, - ["Route12_Text_ElliotRematchIntro"] = 0x1C20D4, - ["Route12_Text_GiaRematchIntro"] = 0x1C219B, - ["Route12_Text_HankRematchIntro"] = 0x1C209C, - ["Route12_Text_JesRematchIntro"] = 0x1C216B, - ["Route12_Text_JustinRematchIntro"] = 0x1C2008, - ["Route12_Text_LucaRematchIntro"] = 0x1C203B, - ["Route12_Text_NedRematchIntro"] = 0x1C1F9D, - ["Route13_Text_AlmaRematchIntro"] = 0x1C2299, - ["Route13_Text_GwenRematchIntro"] = 0x1C225B, - ["Route13_Text_JaredRematchIntro"] = 0x1C236B, - ["Route13_Text_LolaRematchIntro"] = 0x1C2306, - ["Route13_Text_PerryRematchIntro"] = 0x1C22CA, - ["Route13_Text_RobertRematchIntro"] = 0x1C2383, - ["Route13_Text_SebastianRematchIntro"] = 0x1C21EE, - ["Route13_Text_SheilaRematchIntro"] = 0x1C2340, - ["Route13_Text_SusieRematchIntro"] = 0x1C220C, - ["Route13_Text_ValerieRematchIntro"] = 0x1C223C, - ["Route14_Text_BeckRematchIntro"] = 0x1C2425, - ["Route14_Text_BennyRematchIntro"] = 0x1C2505, - ["Route14_Text_CarterRematchIntro"] = 0x1C23C1, - ["Route14_Text_DonaldRematchIntro"] = 0x1C24CB, - ["Route14_Text_GeraldRematchIntro"] = 0x1C259E, - ["Route14_Text_IsaacRematchIntro"] = 0x1C2572, - ["Route14_Text_JanRematchIntro"] = 0x1C261B, - ["Route14_Text_KiriRematchIntro"] = 0x1C25FB, - ["Route14_Text_LukasRematchIntro"] = 0x1C2531, - ["Route14_Text_MalikRematchIntro"] = 0x1C25D6, - ["Route14_Text_MarlonRematchIntro"] = 0x1C2461, - ["Route14_Text_MitchRematchIntro"] = 0x1C23EF, - ["Route15_Text_AlexRematchIntro"] = 0x1C2814, - ["Route15_Text_BeckyRematchIntro"] = 0x1C268D, - ["Route15_Text_CeliaRematchIntro"] = 0x1C2846, - ["Route15_Text_ChesterRematchIntro"] = 0x1C2717, - ["Route15_Text_EdwinRematchIntro"] = 0x1C26D3, - ["Route15_Text_ErnestRematchIntro"] = 0x1C27E7, - ["Route15_Text_GraceRematchIntro"] = 0x1C2753, - ["Route15_Text_KindraRematchIntro"] = 0x1C2650, - ["Route15_Text_MyaRematchIntro"] = 0x1C28A1, - ["Route15_Text_OliviaRematchIntro"] = 0x1C279D, - ["Route15_Text_RonRematchIntro"] = 0x1C28EC, - ["Route15_Text_YazminRematchIntro"] = 0x1C287D, - ["Route16_Text_CamronRematchIntro"] = 0x1C29B0, - ["Route16_Text_HideoRematchIntro"] = 0x1C297B, - ["Route16_Text_JedRematchIntro"] = 0x1C2A19, - ["Route16_Text_KojiRematchIntro"] = 0x1C2925, - ["Route16_Text_LaoRematchIntro"] = 0x1C2913, - ["Route16_Text_LeaRematchIntro"] = 0x1C2A53, - ["Route16_Text_LukeRematchIntro"] = 0x1C2944, - ["Route16_Text_RubenRematchIntro"] = 0x1C29EB, - ["Route17_Text_BillyRematchIntro"] = 0x1C2B06, - ["Route17_Text_CoreyRematchIntro"] = 0x1C2BDA, - ["Route17_Text_IsaiahRematchIntro"] = 0x1C2AC4, - ["Route17_Text_JamalRematchIntro"] = 0x1C2B9C, - ["Route17_Text_JaxonRematchIntro"] = 0x1C2BE6, - ["Route17_Text_NikolasRematchIntro"] = 0x1C2B2C, - ["Route17_Text_RaulRematchIntro"] = 0x1C2A88, - ["Route17_Text_VirgilRematchIntro"] = 0x1C2AF0, - ["Route17_Text_WilliamRematchIntro"] = 0x1C2C10, - ["Route17_Text_ZeekRematchIntro"] = 0x1C2B5E, - ["Route18_Text_JacobRematchIntro"] = 0x1C2CA8, - ["Route18_Text_RamiroRematchIntro"] = 0x1C2C7B, - ["Route18_Text_WiltonRematchIntro"] = 0x1C2C2B, - ["Route19_Text_AliceRematchIntro"] = 0x1C2E9D, - ["Route19_Text_AnyaRematchIntro"] = 0x1C2E4A, - ["Route19_Text_AxleRematchIntro"] = 0x1C2EC0, - ["Route19_Text_ConnieRematchIntro"] = 0x1C2EFC, - ["Route19_Text_DavidRematchIntro"] = 0x1C2DA7, - ["Route19_Text_DouglasRematchIntro"] = 0x1C2D7D, - ["Route19_Text_LiaRematchIntro"] = 0x1C2F41, - ["Route19_Text_LucRematchIntro"] = 0x1C2FAE, - ["Route19_Text_MatthewRematchIntro"] = 0x1C2D4B, - ["Route19_Text_ReeceRematchIntro"] = 0x1C2D19, - ["Route19_Text_RichardRematchIntro"] = 0x1C2CEE, - ["Route19_Text_TonyRematchIntro"] = 0x1C2DE9, - ["Route20_Text_BarryRematchIntro"] = 0x1C2FF3, - ["Route20_Text_DarrinRematchIntro"] = 0x1C30ED, - ["Route20_Text_DeanRematchIntro"] = 0x1C30B0, - ["Route20_Text_IreneRematchIntro"] = 0x1C3095, - ["Route20_Text_MelissaRematchIntro"] = 0x1C31C4, - ["Route20_Text_MissyRematchIntro"] = 0x1C3185, - ["Route20_Text_NoraRematchIntro"] = 0x1C3149, - ["Route20_Text_RogerRematchIntro"] = 0x1C312C, - ["Route20_Text_ShirleyRematchIntro"] = 0x1C302E, - ["Route20_Text_TiffanyRematchIntro"] = 0x1C305F, - ["Route21_North_Text_CueBallRematchIntro"] = 0x1C3287, - ["Route21_North_Text_IanRematchIntro"] = 0x1C33AE, - ["Route21_North_Text_LilRematchIntro"] = 0x1C3378, - ["Route21_North_Text_RonaldRematchIntro"] = 0x1C3208, - ["Route21_North_Text_SpencerRematchIntro"] = 0x1C3264, - ["Route21_North_Text_WadeRematchIntro"] = 0x1C3231, - ["Route21_South_Text_ClaudeRematchIntro"] = 0x1C331D, - ["Route21_South_Text_JackRematchIntro"] = 0x1C3298, - ["Route21_South_Text_JeromeRematchIntro"] = 0x1C32D3, - ["Route21_South_Text_NolanRematchIntro"] = 0x1C3356, - ["Route21_South_Text_RolandRematchIntro"] = 0x1C32FD, - ["Route24_Text_AliRematchIntro"] = 0x1C3685, - ["Route24_Text_CaleRematchIntro"] = 0x1C36DA, - ["Route24_Text_EthanRematchIntro"] = 0x1C360E, - ["Route24_Text_ReliRematchIntro"] = 0x1C3624, - ["Route24_Text_ShaneRematchIntro"] = 0x1C35EE, - ["Route24_Text_TimmyRematchIntro"] = 0x1C3657, - ["Route25_Text_ChadRematchIntro"] = 0x1C34D4, - ["Route25_Text_DanRematchIntro"] = 0x1C3404, - ["Route25_Text_FlintRematchIntro"] = 0x1C3445, - ["Route25_Text_FranklinRematchIntro"] = 0x1C353B, - ["Route25_Text_HaleyRematchIntro"] = 0x1C350A, - ["Route25_Text_JoeyRematchIntro"] = 0x1C33E7, - ["Route25_Text_KelseyRematchIntro"] = 0x1C349C, - ["Route25_Text_NobRematchIntro"] = 0x1C357E, - ["Route25_Text_WayneRematchIntro"] = 0x1C35BC, - ["Route3_Text_BenRematchIntro"] = 0x1C149D, - ["Route3_Text_CalvinRematchIntro"] = 0x1C1588, - ["Route3_Text_ColtonRematchIntro"] = 0x1C147A, - ["Route3_Text_GregRematchIntro"] = 0x1C1521, - ["Route3_Text_JamesRematchIntro"] = 0x1C15C5, - ["Route3_Text_JaniceRematchIntro"] = 0x1C14F3, - ["Route3_Text_RobinRematchIntro"] = 0x1C15F9, - ["Route3_Text_SallyRematchIntro"] = 0x1C155D, - ["Route4_Text_CrissyRematchIntro"] = 0x1C160F, - ["Route6_Text_ElijahRematchIntro"] = 0x1C176B, - ["Route6_Text_IsabelleRematchIntro"] = 0x1C1746, - ["Route6_Text_JeffRematchIntro"] = 0x1C1723, - ["Route6_Text_KeigoRematchIntro"] = 0x1C16E2, - ["Route6_Text_NancyRematchIntro"] = 0x1C166D, - ["Route6_Text_RickyRematchIntro"] = 0x1C163C, - ["Route8_Text_AidanRematchIntro"] = 0x1C1793, - ["Route8_Text_AndreaRematchIntro"] = 0x1C18AA, - ["Route8_Text_AnneRematchIntro"] = 0x1C1A0D, - ["Route8_Text_EliRematchIntro"] = 0x1C19E5, - ["Route8_Text_GlennRematchIntro"] = 0x1C17F1, - ["Route8_Text_JarenRematchIntro"] = 0x1C19BC, - ["Route8_Text_JuliaRematchIntro"] = 0x1C1955, - ["Route8_Text_LeslieRematchIntro"] = 0x1C1873, - ["Route8_Text_MeganRematchIntro"] = 0x1C18DC, - ["Route8_Text_PaigeRematchIntro"] = 0x1C1834, - ["Route8_Text_RicardoRematchIntro"] = 0x1C199C, - ["Route8_Text_RichRematchIntro"] = 0x1C191F, - ["Route8_Text_StanRematchIntro"] = 0x1C17CD, - ["Route9_Text_AlanRematchIntro"] = 0x1C1BBE, - ["Route9_Text_AliciaRematchIntro"] = 0x1C1A2E, - ["Route9_Text_BrentRematchIntro"] = 0x1C1B83, - ["Route9_Text_BriceRematchIntro"] = 0x1C1B5E, - ["Route9_Text_CaitlinRematchIntro"] = 0x1C1AFB, - ["Route9_Text_ChrisRematchIntro"] = 0x1C1A5D, - ["Route9_Text_ConnerRematchIntro"] = 0x1C1BDC, - ["Route9_Text_DrewRematchIntro"] = 0x1C1A9B, - ["Route9_Text_JeremyRematchIntro"] = 0x1C1B37, - ["SafariZone_North_Text_Fisherman"] = 0x1C00B6, - ["SafariZone_North_Text_Man"] = 0x1C00EF, - ["SafariZone_Northwest_Text_Man"] = 0x1C0079, - ["SafariZone_RestHouse_Text_FatMan"] = 0x1C0243, - ["SafariZone_RestHouse_Text_PsychicM"] = 0x1C01FB, - ["SafariZone_RestHouse_Text_Youngster"] = 0x1C01B4, - ["SafariZone_South_Text_Boy"] = 0x1BFFCE, - ["SafariZone_South_Text_EnjoyTheRestOfYourAdventure"] = 0x1BFF51, - ["SafariZone_South_Text_ExitEarlyThankYouForPlaying"] = 0x1BFF66, - ["SafariZone_South_Text_GoodLuck"] = 0x1BFFA1, - ["SafariZone_South_Text_Man"] = 0x1BFFFD, - ["SafariZone_South_Text_StillHaveTimeExit"] = 0x1BFF30, - ["SafariZone_South_Text_Youngster"] = 0x1C011B, - ["SafariZone_Southwest_Text_RestHouseSign"] = 0x1C0190, - ["SafariZone_Southwest_Text_Woman"] = 0x1C003F, - ["SafariZone_Text_ComeInAndEnjoy"] = 0x1BFD52, - ["SafariZone_Text_ExcuseMeYourPCBoxIsFull"] = 0x1BFEAC, - ["SafariZone_Text_FirstTimeInfo"] = 0x1BFD67, - ["SafariZone_Text_HereAreYourSafariBalls"] = 0x1BFE47, - ["SafariZone_Text_NotEnoughMoney"] = 0x1BFE28, - ["SafariZone_Text_OutOfBalls"] = 0x1BFC53, - ["SafariZone_Text_PlayAnotherTime"] = 0x1BFE0F, - ["SafariZone_Text_PleaseEnjoyYourself"] = 0x1BFE70, - ["SafariZone_Text_Received30SafariBalls"] = 0x1BFE58, - ["SafariZone_Text_ThatWillBe500Please"] = 0x1BFE35, - ["SafariZone_Text_TimesUp"] = 0x1BFC1B, - ["SafariZone_Text_WelcomeFirstTime"] = 0x1BFD30, - ["SafariZone_Text_WelcomeToSafariZone"] = 0x1BFC9D, - ["SafariZone_Text_WouldYouLikeToExit"] = 0x1BFBE9, - ["SafariZone_Text_WouldYouLikeToPlay"] = 0x1BFDD7, - ["SafariZone_Text_YouNeedPokeblockCase"] = 0x1BFECC, - ["SevenIsland_SevaultCanyon_Entrance_Text_EveRematchIntro"] = 0x1C4416, - ["SevenIsland_SevaultCanyon_Entrance_Text_JonRematchIntro"] = 0x1C444C, - ["SevenIsland_SevaultCanyon_Entrance_Text_MadelineRematchIntro"] = 0x1C43EC, - ["SevenIsland_SevaultCanyon_Entrance_Text_MasonRematchIntro"] = 0x1C4374, - ["SevenIsland_SevaultCanyon_Entrance_Text_MiahRematchIntro"] = 0x1C4327, - ["SevenIsland_SevaultCanyon_Entrance_Text_NicolasRematchIntro"] = 0x1C43AD, - ["SevenIsland_SevaultCanyon_Text_CyndyRematchIntro"] = 0x1C4491, - ["SevenIsland_SevaultCanyon_Text_EvanRematchIntro"] = 0x1C44CB, - ["SevenIsland_SevaultCanyon_Text_JacksonRematchIntro"] = 0x1C454E, - ["SevenIsland_SevaultCanyon_Text_KatelynRematchIntro"] = 0x1C458E, - ["SevenIsland_SevaultCanyon_Text_LeroyRematchIntro"] = 0x1C45C2, - ["SevenIsland_SevaultCanyon_Text_LexRematchIntro"] = 0x1C4662, - ["SevenIsland_SevaultCanyon_Text_MichelleRematchIntro"] = 0x1C45FB, - ["SevenIsland_SevaultCanyon_Text_NyaRematchIntro"] = 0x1C4699, - ["SevenIsland_TanobyRuins_Text_BenjaminRematchIntro"] = 0x1C470A, - ["SevenIsland_TanobyRuins_Text_BrandonRematchIntro"] = 0x1C46D3, - ["SevenIsland_TanobyRuins_Text_CliffordRematchIntro"] = 0x1C476A, - ["SevenIsland_TanobyRuins_Text_EdnaRematchIntro"] = 0x1C4739, - ["SevenIsland_TrainerTower_Text_DarioRematchIntro"] = 0x1C42D6, - ["SevenIsland_TrainerTower_Text_RodetteRematchIntro"] = 0x1C42EE, - ["SixIsland_GreenPath_Text_JaclynRematchIntro"] = 0x1C3FE9, - ["SixIsland_OutcastIsland_Text_AvaRematchIntro"] = 0x1C3F7B, - ["SixIsland_OutcastIsland_Text_GebRematchIntro"] = 0x1C3FB3, - ["SixIsland_OutcastIsland_Text_MymoRematchIntro"] = 0x1C3F11, - ["SixIsland_OutcastIsland_Text_NicoleRematchIntro"] = 0x1C3F51, - ["SixIsland_OutcastIsland_Text_TylorRematchIntro"] = 0x1C3ED0, - ["SixIsland_RuinValley_Text_DarylRematchIntro"] = 0x1C4280, - ["SixIsland_RuinValley_Text_FosterRematchIntro"] = 0x1C41D4, - ["SixIsland_RuinValley_Text_HectorRematchIntro"] = 0x1C42A0, - ["SixIsland_RuinValley_Text_LarryRematchIntro"] = 0x1C4210, - ["SixIsland_RuinValley_Text_StanlyRematchIntro"] = 0x1C4196, - ["SixIsland_WaterPath_Text_DeniseRematchIntro"] = 0x1C40D9, - ["SixIsland_WaterPath_Text_EarlRematchIntro"] = 0x1C40FA, - ["SixIsland_WaterPath_Text_EdwardRematchIntro"] = 0x1C4057, - ["SixIsland_WaterPath_Text_MiaRematchIntro"] = 0x1C4166, - ["SixIsland_WaterPath_Text_MiuRematchIntro"] = 0x1C4138, - ["SixIsland_WaterPath_Text_RoseRematchIntro"] = 0x1C4028, - ["SixIsland_WaterPath_Text_SamirRematchIntro"] = 0x1C407F, - ["SootopolisCity_Text_BiggestMonPlayersXCmColossus"] = 0x1C0399, - ["SootopolisCity_Text_GiveYouThisBerryToo"] = 0x1C0CF5, - ["SootopolisCity_Text_GrandMonThanksForShowingMe"] = 0x1C02CB, - ["SootopolisCity_Text_HuhBagIsCrammedFull"] = 0x1C0317, - ["SootopolisCity_Text_IveSeenBiggerMonThanThisOne"] = 0x1C032B, - ["SootopolisCity_Text_LikeSeasonBornIn"] = 0x1C0D32, - ["SootopolisCity_Text_NameIsKiriHaveOneOfThese"] = 0x1C0C74, - ["SootopolisCity_Text_OhDoesntMatter"] = 0x1C0D8F, - ["SootopolisCity_Text_PleaseShowMeBigMon"] = 0x1C0283, - ["SootopolisCity_Text_QuiteSomethingButNotMon"] = 0x1C034D, - ["SootopolisCity_Text_ReceivedOneItem"] = 0x1C0309, - ["SootopolisCity_Text_ThenILoveAutumn"] = 0x1C0D66, - ["SootopolisCity_Text_TooBadDontHaveBigMon"] = 0x1C036C, - ["SootopolisCity_Text_WhatKindOfWishInYourName"] = 0x1C0D16, - ["SouthernIsland_Exterior_Text_Sign"] = 0x1C0500, - ["SouthernIsland_Interior_Text_Sign"] = 0x1C04DC, - ["Test_Text_Empty"] = 0x1C55C8, - ["Test_Text_ThisIsACoordEvent"] = 0x1C55A4, - ["Test_Text_ThisIsASignpost"] = 0x1C558D, - ["Test_Text_WelcomeToWorldOfPokemon"] = 0x1C556D, - ["Text_ATownMap"] = 0x1C0DF1, - ["Text_AccessWhichPC"] = 0x1A508A, - ["Text_AccessedBillsPC"] = 0x1A50EF, - ["Text_AccessedPlayersPC"] = 0x1A50DD, - ["Text_AccessedProfOaksPC"] = 0x1A5BC6, - ["Text_AccessedSomeonesPC"] = 0x1A50A7, - ["Text_AdapterNotConnectedMinigame"] = 0x1BCFE8, - ["Text_AdvertisingPoster"] = 0x1C10FC, - ["Text_AllAboardSeagallopNum"] = 0x1A641B, - ["Text_AllGoodToGo"] = 0x1BD080, - ["Text_AppearsToBeUndergoingAdjustments"] = 0x1A5667, - ["Text_AsYouLikeThen"] = 0x1C04C9, - ["Text_BagIsFull"] = 0x1A5207, - ["Text_BagItemCanBeRegistered"] = 0x1A5375, - ["Text_BeautifulSkyWindow"] = 0x1C11DC, - ["Text_BigHoleInTheWall"] = 0x1A5606, - ["Text_BlinkingLights"] = 0x1C1217, - ["Text_Blueprints"] = 0x1C0FD8, - ["Text_Bookshelf"] = 0x1C0DB0, - ["Text_BootedUpPC"] = 0x1A5075, - ["Text_Burglary"] = 0x1C12D6, - ["Text_CantLetYouPass"] = 0x1A5776, - ["Text_CardKeyOpenedDoor"] = 0x1A5B88, - ["Text_ChangeYourProfile"] = 0x1BF1FB, - ["Text_ClosedLinkToProfOaksPC"] = 0x1A5C2E, - ["Text_CoinCaseIsFull"] = 0x1A5294, - ["Text_Computer"] = 0x1C10C1, - ["Text_Cup"] = 0x1C1194, - ["Text_CurrentTooFast"] = 0x1A55B3, - ["Text_CutTreeDown"] = 0x1BDF94, - ["Text_DadLetterArrivedForYouPlayer"] = 0x1C03B5, - ["Text_DadPassForShipNeverSeenBefore"] = 0x1C03D7, - ["Text_DecidedNotTo"] = 0x1BF1B2, - ["Text_DecorTransferredToPC"] = 0x1A532F, - ["Text_DescribeWhichGame"] = 0x1BCCFF, - ["Text_DishesPlatesNeatlyLinedUp"] = 0x1C0E19, - ["Text_DiveCantBeUsedHere"] = 0x1BE52F, - ["Text_DodrioBerryPickingInfo"] = 0x1BCE73, - ["Text_DontBeLikeThat"] = 0x1BF17B, - ["Text_DoorOpenedFarAway"] = 0x1A55EA, - ["Text_Dresser"] = 0x1C0E73, - ["Text_DugUpItemFromGround"] = 0x1A5700, - ["Text_EnterWhichPokemon"] = 0x1BD059, - ["Text_ExplainProfile"] = 0x1BF053, - ["Text_FerryToSlateportOhThatPass"] = 0x1C0407, - ["Text_FillOutQuestionnaire"] = 0x1A622C, - ["Text_FirstShouldRestoreMonsHealth"] = 0x1A5E89, - ["Text_Food"] = 0x1C0F99, - ["Text_FoundOneItem"] = 0x1A5231, - ["Text_FoundTMHMContainsMove"] = 0x1A63E8, - ["Text_FoundXCoins"] = 0x1A525C, - ["Text_FoundXItems"] = 0x1A52DD, - ["Text_GiveNicknameToReceivedMon"] = 0x1A5446, - ["Text_GiveNicknameToThisMon"] = 0x1A56A7, - ["Text_Gyaoo"] = 0x1A6448, - ["Text_HadQuiteAnExperienceTakeRest"] = 0x1A5FDF, - ["Text_HandedOverItem"] = 0x1A5690, - ["Text_HavePokedexRated"] = 0x1A5C03, - ["Text_HavingDiscountSaleToday"] = 0x1A51A3, - ["Text_HeardThatBefore"] = 0x1BF30D, - ["Text_HiWeMeetAgain"] = 0x1BF1E8, - ["Text_ISee"] = 0x1A5CCE, - ["Text_IdLikeToSeeRequest"] = 0x1A5C79, - ["Text_IdLoveToHearYourProfile"] = 0x1BEF88, - ["Text_ImpressiveMachine"] = 0x1C1273, - ["Text_Indigo_HighestAuthority"] = 0x1C0F59, - ["Text_Indigo_UltimateGoal"] = 0x1C0F19, - ["Text_ItNeedsCardKey"] = 0x1A5BAD, - ["Text_ItemfinderResponding"] = 0x1A56D2, - ["Text_Kitchen"] = 0x1C0E40, - ["Text_LeavingDoComeAgain"] = 0x1BD0BC, - ["Text_LightFilteringUseDive"] = 0x1BE4EF, - ["Text_LikeProfileWayItIs"] = 0x1BF252, - ["Text_LinkWasCanceled"] = 0x1A5435, - ["Text_LookedAtPlayersTrainerCard"] = 0x1BC84B, - ["Text_LookedAtPlayersTrainerCardColored"] = 0x1BC874, - ["Text_LooksLikeNothingHere"] = 0x1BE56D, - ["Text_MakingPreparations"] = 0x1A5351, - ["Text_MayIHelpYou"] = 0x1A6211, - ["Text_MomExplainHPGetPotions"] = 0x1A6046, - ["Text_MonFlewAway"] = 0x1A63C4, - ["Text_MonMayGoUnderwater"] = 0x1BE42B, - ["Text_MonMayPushBoulder"] = 0x1BE244, - ["Text_MonMaySmashRock"] = 0x1BE0E2, - ["Text_MonMaySurface"] = 0x1BE4A9, - ["Text_MonSentToBoxBillsBoxFull"] = 0x1A5DB1, - ["Text_MonSentToBoxInBillsPC"] = 0x1A5D31, - ["Text_MonSentToBoxInPC"] = 0x1A5E05, - ["Text_MonSentToBoxInSomeonesPC"] = 0x1A5CF1, - ["Text_MonSentToBoxSomeonesBoxFull"] = 0x1A5D6E, - ["Text_MonUsedDive"] = 0x1BE49B, - ["Text_MonUsedMove"] = 0x1BDFD7, - ["Text_MonUsedStrengthCanMoveBoulders"] = 0x1BE1FA, - ["Text_MonUsedWaterfall"] = 0x1BE378, - ["Text_MonsHealed"] = 0x1A5F9B, - ["Text_MonsHealedShouldBuyPotions"] = 0x1A5EC0, - ["Text_MoveCanOnlyBeLearnedOnce"] = 0x1A644F, - ["Text_NeatlyLinedUpTools"] = 0x1C124B, - ["Text_NoMoreRoomForPokemon"] = 0x1A5E31, - ["Text_NoRoomForAnotherDecor"] = 0x1A5301, - ["Text_NothingButPokemonBooks"] = 0x1C0DD4, - ["Text_NothingHereSailBackToLilycove"] = 0x1C049D, - ["Text_NothingToPutThemIn"] = 0x1A52B4, - ["Text_ObtainedTheDecor"] = 0x1A52F0, - ["Text_ObtainedTheX"] = 0x1A51F6, - ["Text_OhThatsBadgeGoRightAhead"] = 0x1A579D, - ["Text_OnlyDodrioAllowed"] = 0x1BD213, - ["Text_OnlyPassWithBadgeDontHaveYet"] = 0x1A57C1, - ["Text_OnlyPassWithBadgeOhGoAhead"] = 0x1A5839, - ["Text_OnlySkilledTrainersAllowedThrough"] = 0x1A572A, - ["Text_OpenedPkmnStorage"] = 0x1A50BE, - ["Text_Painting"] = 0x1C101B, - ["Text_PlayWhichGame"] = 0x1BD02D, - ["Text_PlayerBootedUpPC"] = 0x1A5420, - ["Text_PlayerFacingTVScreen"] = 0x1C1300, - ["Text_PlayerIsPlayingRightNowGoForIt"] = 0x1BCCCA, - ["Text_PlayerObtainedTheMon"] = 0x1A5DF1, - ["Text_PlayerWhatCanIDoForYou"] = 0x1A51D0, - ["Text_PleaseComeAgain"] = 0x1A5190, - ["Text_PokeMartShelf"] = 0x1C0DFD, - ["Text_PokecenterSign"] = 0x1C0EF7, - ["Text_PokemartSign"] = 0x1C0ECB, - ["Text_PokemonJumpInfo"] = 0x1BCD4D, - ["Text_PolishedWindow"] = 0x1C11BA, - ["Text_PowerPlantMachine"] = 0x1C1060, - ["Text_ProfileSaysSomethingAboutYou"] = 0x1BF28E, - ["Text_PutCoinsAwayInCoinCase"] = 0x1A526C, - ["Text_PutItemAway"] = 0x1A5218, - ["Text_QuestionnaireThankYou"] = 0x1A6265, - ["Text_RepelWoreOff"] = 0x1BFB6E, - ["Text_RestoredPkmnToFullHealth"] = 0x1A552B, - ["Text_RetryFromStartPlease"] = 0x1BD274, - ["Text_SailHomeWeWill"] = 0x1C04BB, - ["Text_SeaIsDeepUseDive"] = 0x1BE469, - ["Text_ShortJumpingPokemonAllowed"] = 0x1BD14B, - ["Text_Snacks"] = 0x1C0EAC, - ["Text_StrengthMadeMovingBouldersPossible"] = 0x1BE284, - ["Text_TakeSeatStartBattle"] = 0x1BC8AD, - ["Text_TakeSeatStartTrade"] = 0x1BC8DA, - ["Text_TakeYourPkmnForFewSeconds"] = 0x1A54E1, - ["Text_TalkToManToPlay"] = 0x1BCF2E, - ["Text_TastyFood"] = 0x1C1134, - ["Text_Telephone"] = 0x1C109C, - ["Text_TellMeWhatsYourProfile"] = 0x1BF017, - ["Text_TellMoreNextTime"] = 0x1BF36A, - ["Text_TerminateLinkConfirmation"] = 0x1BC7E2, - ["Text_TerminateLinkIfYouLeaveRoom"] = 0x1BC7A1, - ["Text_TestMsg"] = 0x1ACD45, - ["Text_ThankYouForAccessingMysteryGift"] = 0x1A6393, - ["Text_ThankYouForShowingMe"] = 0x1A5C9F, - ["Text_ThatsNotRight"] = 0x1A5CC3, - ["Text_TheDoorIsClosed"] = 0x1A5CD3, - ["Text_TheDoorIsOpen"] = 0x1A5CDF, - ["Text_TheresBedLetsRest"] = 0x1A63D6, - ["Text_TooBadBagFull"] = 0x1A5242, - ["Text_TrainerSchoolEmail"] = 0x1A53B2, - ["Text_TrainerTooBusyToNotice"] = 0x1BC827, - ["Text_TrashBin"] = 0x1C116E, - ["Text_TreeCanBeCutDown"] = 0x1BDFE3, - ["Text_UseRockSmash"] = 0x1BE09D, - ["Text_UseStrength"] = 0x1BE19A, - ["Text_UseWaterfall"] = 0x1BE33F, - ["Text_UsedSurf"] = 0x1A55A5, - ["Text_VideoGame"] = 0x1C12A7, - ["Text_VoiceRangOutDontRunAway"] = 0x1A5C4D, - ["Text_WallOfWaterCrashingDown"] = 0x1BE30A, - ["Text_WantToSurf"] = 0x1A556E, - ["Text_WantWhichFloor"] = 0x1A535C, - ["Text_WeHopeToSeeYouAgain"] = 0x1A5511, - ["Text_WeHopeToSeeYouAgain2"] = 0x1A505B, - ["Text_WelcomeCanYouWait"] = 0x1BCF69, - ["Text_WelcomeColosseum"] = 0x1A5133, - ["Text_WelcomeTimeCapsule"] = 0x1A5160, - ["Text_WelcomeTradeCenter"] = 0x1A5103, - ["Text_WelcomeWantToHealPkmn"] = 0x1A5483, - ["Text_WhatAreYouLikeNow"] = 0x1BF222, - ["Text_WhatsYourProfile"] = 0x1BF166, - ["Text_WhereDoYouWantToGo"] = 0x1A6407, - ["Text_WhitedOut"] = 0x1A61E5, - ["Text_WhitedOutLostMoney"] = 0x1A6197, - ["Text_WirelessClubUndergoingAdjustments"] = 0x1A561A, - ["Text_WouldYouLikeToMixRecords"] = 0x1A5028, - ["Text_YouCanAccessMysteryGift"] = 0x1A6359, - ["Text_YouKnowThoseWords"] = 0x1A62D3, - ["Text_YoureOneWhoBroughtOddPass"] = 0x1C0426, - ["ThreeIsland_BondBridge_Text_AlexisRematchIntro"] = 0x1C3B1D, - ["ThreeIsland_BondBridge_Text_AmiraRematchIntro"] = 0x1C3AF2, - ["ThreeIsland_BondBridge_Text_JoyRematchIntro"] = 0x1C3B6E, - ["ThreeIsland_BondBridge_Text_MegRematchIntro"] = 0x1C3B99, - ["ThreeIsland_BondBridge_Text_NikkiRematchIntro"] = 0x1C3AA7, - ["ThreeIsland_BondBridge_Text_TishaRematchIntro"] = 0x1C3B30, - ["ThreeIsland_BondBridge_Text_VioletRematchIntro"] = 0x1C3ABF, - ["Trade_Text_AwwOhWell"] = 0x1A59B6, - ["Trade_Text_DoYouHaveMonWantToTradeForMon"] = 0x1A5AD6, - ["Trade_Text_DoYouHaveMonWouldYouTradeForMon"] = 0x1A59FE, - ["Trade_Text_HasTradedMonGrownStronger"] = 0x1A5AA5, - ["Trade_Text_HeyThanks"] = 0x1A59DA, - ["Trade_Text_HowIsMyOldMon"] = 0x1A5B60, - ["Trade_Text_IsntMyOldMonGreat"] = 0x1A59E7, - ["Trade_Text_LookingForMonWannaTradeForMon"] = 0x1A597B, - ["Trade_Text_Thanks"] = 0x1A5A9D, - ["Trade_Text_ThanksYoureAPal"] = 0x1A5B4A, - ["Trade_Text_ThatsTooBad"] = 0x1A5B08, - ["Trade_Text_ThisIsNoMon"] = 0x1A5B18, - ["Trade_Text_ThisIsntMon"] = 0x1A5A6A, - ["Trade_Text_WellIfYouDontWantTo"] = 0x1A5A4E, - ["Trade_Text_WhatThatsNoMon"] = 0x1A59C6, - ["VSSeeker_Text_BatteryNotChargedNeedXSteps"] = 0x1C137C, - ["VSSeeker_Text_NoTrainersWithinRange"] = 0x1C13D6, - ["VSSeeker_Text_TrainersNotReady"] = 0x1C1429, - ["gBattleText_Rose"] = 0x3FCB4A, - ["gControlsGuide_Text_AButton"] = 0x1C58BA, - ["gControlsGuide_Text_BButton"] = 0x1C58F9, - ["gControlsGuide_Text_DPad"] = 0x1C5875, - ["gControlsGuide_Text_Intro"] = 0x1C582D, - ["gControlsGuide_Text_LRButtons"] = 0x1C5981, - ["gControlsGuide_Text_SelectButton"] = 0x1C594F, - ["gControlsGuide_Text_StartButton"] = 0x1C592B, - ["gCreditsString_Akira_Kinashi"] = 0x41DE3C, - ["gCreditsString_Art_Director_Battle_Director"] = 0x41D200, - ["gCreditsString_Atsuko_Nishida_Muneo_Saito_Rena_Yoshikawa_Jun_Okutani"] = 0x41D6B0, - ["gCreditsString_Azusa_Tajima_Akira_Kinashi_Kazuki_Yoshihara_Retsuji_Nomoto"] = 0x41D94C, - ["gCreditsString_Braille_Code_Check"] = 0x41D894, - ["gCreditsString_Braille_Code_Check_2"] = 0x41DC2C, - ["gCreditsString_Braille_Code_Check_3"] = 0x41DCAC, - ["gCreditsString_Braille_Code_Check_4"] = 0x41DDC8, - ["gCreditsString_Coordinators"] = 0x41D938, - ["gCreditsString_Director"] = 0x41D1D8, - ["gCreditsString_English_Version_Coordinators"] = 0x41DAA0, - ["gCreditsString_Environment_Tool_Programmers"] = 0x41DB7C, - ["gCreditsString_Executive_Director"] = 0x41DA08, - ["gCreditsString_Executive_Producer"] = 0x41DA3C, - ["gCreditsString_Executive_Producer_2"] = 0x41DA6C, - ["gCreditsString_Game_Designers"] = 0x41D45C, - ["gCreditsString_Game_Designers_2"] = 0x41D4A4, - ["gCreditsString_Game_Scenario"] = 0x41D4F0, - ["gCreditsString_Go_Ichinose"] = 0x41D448, - ["gCreditsString_Go_Ichinose_Junichi_Masuda"] = 0x41D414, - ["gCreditsString_Graphic_Designer"] = 0x41DE24, - ["gCreditsString_Graphic_Designers"] = 0x41D370, - ["gCreditsString_Graphic_Designers_2"] = 0x41D3C0, - ["gCreditsString_Hiro_Nakamura_Hiroyuki_Uesugi_Teruki_Murakawa_Kazuya_Suyama"] = 0x41D79C, - ["gCreditsString_Hiro_Nakamura_Seth_McMahill"] = 0x41DAC4, - ["gCreditsString_Hiroki_Enomoto_Kazuyuki_Terada_Yuri_Sakurai_Yumi_Funasaka_Naoko_Yanase"] = 0x41D8EC, - ["gCreditsString_Hiroyuki_Jinnai_Takehiro_Izushi_Hiroaki_Tsuru"] = 0x41D9D4, - ["gCreditsString_Hiroyuki_Nakamura_Masao_Taya_Satoshi_Nohara_Miyuki_Iwasawa_Daisuke_Goto"] = 0x41D2C8, - ["gCreditsString_Hitomi_Sato_Satoshi_Tajiri"] = 0x41D504, - ["gCreditsString_Hitomi_Sato_Shigeru_Ohmori_Tadashi_Takahashi"] = 0x41D4BC, - ["gCreditsString_Hitoshi_Yamagami_Gakuji_Nomoto"] = 0x41D9A0, - ["gCreditsString_Information_Supervisors"] = 0x41D8CC, - ["gCreditsString_Japan_Braille_Library"] = 0x41D8B0, - ["gCreditsString_Junichi_Masuda"] = 0x41D1E8, - ["gCreditsString_Junichi_Masuda_Koji_Nishino_Tetsuji_Ohta"] = 0x41D474, - ["gCreditsString_Ken_Sugimori_Hironobu_Yoshida"] = 0x41D3D8, - ["gCreditsString_Ken_Sugimori_Motofumi_Fujiwara_Shigeki_Morimoto_Hironobu_Yoshida"] = 0x41D5FC, - ["gCreditsString_Ken_Sugimori_Shigeki_Morimoto"] = 0x41D224, - ["gCreditsString_Kenji_Matsushima"] = 0x41D5CC, - ["gCreditsString_Kenji_Tominaga_Kenjiro_Ito_Tomotaka_Komura_Michiko_Takizawa"] = 0x41D7F4, - ["gCreditsString_Koji_Nishino_Tetsuji_Ohta_Shigeki_Morimoto"] = 0x41D588, - ["gCreditsString_Makiko_Takada_Mikiko_Ohashi_Shusaku_Egami_Takanao_Kondo_Rui_Kawaguchi"] = 0x41D84C, - ["gCreditsString_Music_Composition"] = 0x41D3FC, - ["gCreditsString_NCL_Product_Testing"] = 0x41D74C, - ["gCreditsString_NCL_Super_Mario_Club"] = 0x41D768, - ["gCreditsString_NOA_Product_Testing"] = 0x41DBDC, - ["gCreditsString_National_Federation_of_the_Blind_Patricia_A_Maurer_Japan_Braille_Library_European_Blind_Union"] = 0x41DC48, - ["gCreditsString_National_Information_Library_Service_Margaret_Campion"] = 0x41DCC8, - ["gCreditsString_Nicola_Pratt_Barlow_Shellie_Dow_Anthony_Howitt_Naoko_Saeki_Kyoko_Onishi"] = 0x41DD7C, - ["gCreditsString_Nob_Ogasawara_Teresa_Lillygren"] = 0x41DB04, - ["gCreditsString_POKeDEX_Text"] = 0x41D5B8, - ["gCreditsString_POKeMON_Designers"] = 0x41D5E4, - ["gCreditsString_POKeMON_Designers_2"] = 0x41D640, - ["gCreditsString_POKeMON_Designers_3"] = 0x41D698, - ["gCreditsString_Parametric_Designers"] = 0x41D56C, - ["gCreditsString_Producers"] = 0x41D9C4, - ["gCreditsString_Program_Leader_Planning_Leader_Graphic_Design_Leader"] = 0x41D248, - ["gCreditsString_Programmers"] = 0x41D2B4, - ["gCreditsString_Programmers_2"] = 0x41DB28, - ["gCreditsString_Satoru_Iwata"] = 0x41DA58, - ["gCreditsString_Satoshi_Nohara_Shigeru_Ohmori"] = 0x41D548, - ["gCreditsString_Satoshi_Ohta_Asuka_Iwashita_Takao_Unno_Kanako_Eo_Aimi_Tomita"] = 0x41D658, - ["gCreditsString_Satoshi_Tajiri"] = 0x41DA24, - ["gCreditsString_Script_Designer_Map_Designer"] = 0x41D524, - ["gCreditsString_Sound_Effects"] = 0x41D434, - ["gCreditsString_Special_Thanks"] = 0x41D784, - ["gCreditsString_Special_Thanks_2"] = 0x41D7DC, - ["gCreditsString_Special_Thanks_3"] = 0x41D834, - ["gCreditsString_Special_Thanks_4"] = 0x41DD08, - ["gCreditsString_Special_Thanks_5"] = 0x41DD64, - ["gCreditsString_Supporting_Programmers"] = 0x41D6EC, - ["gCreditsString_System_Programmers"] = 0x41D314, - ["gCreditsString_Takao_Unno_Asuka_Iwashita_Kanako_Eo_Hiroki_Fuchino"] = 0x41D388, - ["gCreditsString_Takehiro_Izushi_Motoyasu_Tojima_Hitoshi_Yamagami_Hiroyuki_Uesugi"] = 0x41DD20, - ["gCreditsString_Task_Managers"] = 0x41D98C, - ["gCreditsString_Teruki_Murakawa_Souichi_Yamamoto_Kimiko_Nakamichi"] = 0x41DBA4, - ["gCreditsString_Teruki_Murakawa_Souichi_Yamamoto_Yuichiro_Ito_Akira_Kinashi"] = 0x41DB3C, - ["gCreditsString_Teruyuki_Yoshioka_Takao_Nakano_Satoshi_Mitsuhara_Daisuke_Hoshino"] = 0x41D708, - ["gCreditsString_Tetsuya_Watanabe_Akito_Mori_Hisashi_Sogabe_Sousuke_Tamada"] = 0x41D330, - ["gCreditsString_Tetsuya_Watanabe_Koji_Nishino_Takao_Unno"] = 0x41D284, - ["gCreditsString_The_Royal_New_Zealand_Foundation_of_the_Blind_Greg_Moran"] = 0x41DDE4, - ["gCreditsString_Thomas_Hertzog_Kathy_Huguenard_Mika_Kurosawa"] = 0x41DBF8, - ["gCreditsString_Translator_Text_Editor"] = 0x41DAE8, - ["gCreditsString_Tsunekaz_Ishihara"] = 0x41DA88, - ["gDaycareText_DontLikeOther"] = 0x41E37C, - ["gDaycareText_GetAlong"] = 0x41E361, - ["gDaycareText_GetAlongVeryWell"] = 0x41E33C, - ["gDaycareText_PlayOther"] = 0x41E3A8, - ["gEasyChatGroupName_Actions"] = 0x41E037, - ["gEasyChatGroupName_Adjectives"] = 0x41E05C, - ["gEasyChatGroupName_Battle"] = 0x41DFF5, - ["gEasyChatGroupName_Conditions"] = 0x41E02C, - ["gEasyChatGroupName_Endings"] = 0x41E01B, - ["gEasyChatGroupName_Events"] = 0x41E067, - ["gEasyChatGroupName_Feelings"] = 0x41E023, - ["gEasyChatGroupName_Greetings"] = 0x41DFFC, - ["gEasyChatGroupName_Hobbies"] = 0x41E049, - ["gEasyChatGroupName_Lifestyle"] = 0x41E03F, - ["gEasyChatGroupName_Misc"] = 0x41E056, - ["gEasyChatGroupName_Move1"] = 0x41E06E, - ["gEasyChatGroupName_Move2"] = 0x41E075, - ["gEasyChatGroupName_People"] = 0x41E006, - ["gEasyChatGroupName_Pokemon"] = 0x41DFDE, - ["gEasyChatGroupName_Pokemon2"] = 0x41E08A, - ["gEasyChatGroupName_Speech"] = 0x41E014, - ["gEasyChatGroupName_Status"] = 0x41DFEE, - ["gEasyChatGroupName_Time"] = 0x41E051, - ["gEasyChatGroupName_Trainer"] = 0x41DFE6, - ["gEasyChatGroupName_TrendySaying"] = 0x41E07C, - ["gEasyChatGroupName_Voices"] = 0x41E00D, - ["gExpandedPlaceholder_Aqua"] = 0x415A31, - ["gExpandedPlaceholder_Archie"] = 0x415A3C, - ["gExpandedPlaceholder_Chan"] = 0x415A22, - ["gExpandedPlaceholder_Empty"] = 0x415A20, - ["gExpandedPlaceholder_Green"] = 0x415A5C, - ["gExpandedPlaceholder_Groudon"] = 0x415A50, - ["gExpandedPlaceholder_Kun"] = 0x415A21, - ["gExpandedPlaceholder_Kyogre"] = 0x415A49, - ["gExpandedPlaceholder_Magma"] = 0x415A36, - ["gExpandedPlaceholder_Maxie"] = 0x415A43, - ["gExpandedPlaceholder_PokedexDescription"] = 0x444C34, - ["gExpandedPlaceholder_Red"] = 0x415A58, - ["gExpandedPlaceholder_Ruby"] = 0x415A2C, - ["gExpandedPlaceholder_Sapphire"] = 0x415A23, - ["gFameCheckerBillName"] = 0x41E5F3, - ["gFameCheckerDaisyName"] = 0x41E5ED, - ["gFameCheckerFlavorTextOriginLocation_Agatha0"] = 0x1B0FAE, - ["gFameCheckerFlavorTextOriginLocation_Agatha1"] = 0x1B0FBD, - ["gFameCheckerFlavorTextOriginLocation_Agatha2"] = 0x1B0FCC, - ["gFameCheckerFlavorTextOriginLocation_Agatha3"] = 0x1B0FDB, - ["gFameCheckerFlavorTextOriginLocation_Agatha4"] = 0x1B0FE8, - ["gFameCheckerFlavorTextOriginLocation_Agatha5"] = 0x1B0FF7, - ["gFameCheckerFlavorTextOriginLocation_Bill0"] = 0x1B1059, - ["gFameCheckerFlavorTextOriginLocation_Bill1"] = 0x1B1067, - ["gFameCheckerFlavorTextOriginLocation_Bill2"] = 0x1B1075, - ["gFameCheckerFlavorTextOriginLocation_Bill3"] = 0x1B1082, - ["gFameCheckerFlavorTextOriginLocation_Bill4"] = 0x1B108D, - ["gFameCheckerFlavorTextOriginLocation_Bill5"] = 0x1B1098, - ["gFameCheckerFlavorTextOriginLocation_Blaine0"] = 0x1B0EBA, - ["gFameCheckerFlavorTextOriginLocation_Blaine1"] = 0x1B0ECA, - ["gFameCheckerFlavorTextOriginLocation_Blaine2"] = 0x1B0ED7, - ["gFameCheckerFlavorTextOriginLocation_Blaine3"] = 0x1B0EE4, - ["gFameCheckerFlavorTextOriginLocation_Blaine4"] = 0x1B0EF4, - ["gFameCheckerFlavorTextOriginLocation_Blaine5"] = 0x1B0F01, - ["gFameCheckerFlavorTextOriginLocation_Brock0"] = 0x1B0CEB, - ["gFameCheckerFlavorTextOriginLocation_Brock1"] = 0x1B0CF7, - ["gFameCheckerFlavorTextOriginLocation_Brock2"] = 0x1B0D02, - ["gFameCheckerFlavorTextOriginLocation_Brock3"] = 0x1B0D0E, - ["gFameCheckerFlavorTextOriginLocation_Brock4"] = 0x1B0D16, - ["gFameCheckerFlavorTextOriginLocation_Brock5"] = 0x1B0D1F, - ["gFameCheckerFlavorTextOriginLocation_Bruno0"] = 0x1B0F5F, - ["gFameCheckerFlavorTextOriginLocation_Bruno1"] = 0x1B0F6E, - ["gFameCheckerFlavorTextOriginLocation_Bruno2"] = 0x1B0F7D, - ["gFameCheckerFlavorTextOriginLocation_Bruno3"] = 0x1B0F8A, - ["gFameCheckerFlavorTextOriginLocation_Bruno4"] = 0x1B0F94, - ["gFameCheckerFlavorTextOriginLocation_Bruno5"] = 0x1B0F9F, - ["gFameCheckerFlavorTextOriginLocation_Daisy0"] = 0x1B0C95, - ["gFameCheckerFlavorTextOriginLocation_Daisy1"] = 0x1B0CA2, - ["gFameCheckerFlavorTextOriginLocation_Daisy2"] = 0x1B0CB1, - ["gFameCheckerFlavorTextOriginLocation_Daisy3"] = 0x1B0CC1, - ["gFameCheckerFlavorTextOriginLocation_Daisy4"] = 0x1B0CCF, - ["gFameCheckerFlavorTextOriginLocation_Daisy5"] = 0x1B0CDF, - ["gFameCheckerFlavorTextOriginLocation_Erika0"] = 0x1B0DD7, - ["gFameCheckerFlavorTextOriginLocation_Erika1"] = 0x1B0DE4, - ["gFameCheckerFlavorTextOriginLocation_Erika2"] = 0x1B0DF0, - ["gFameCheckerFlavorTextOriginLocation_Erika3"] = 0x1B0DFC, - ["gFameCheckerFlavorTextOriginLocation_Erika4"] = 0x1B0E08, - ["gFameCheckerFlavorTextOriginLocation_Erika5"] = 0x1B0E14, - ["gFameCheckerFlavorTextOriginLocation_Giovanni0"] = 0x1B10F8, - ["gFameCheckerFlavorTextOriginLocation_Giovanni1"] = 0x1B1107, - ["gFameCheckerFlavorTextOriginLocation_Giovanni2"] = 0x1B1111, - ["gFameCheckerFlavorTextOriginLocation_Giovanni3"] = 0x1B111B, - ["gFameCheckerFlavorTextOriginLocation_Giovanni4"] = 0x1B1128, - ["gFameCheckerFlavorTextOriginLocation_Giovanni5"] = 0x1B1135, - ["gFameCheckerFlavorTextOriginLocation_Koga0"] = 0x1B0E24, - ["gFameCheckerFlavorTextOriginLocation_Koga1"] = 0x1B0E31, - ["gFameCheckerFlavorTextOriginLocation_Koga2"] = 0x1B0E3D, - ["gFameCheckerFlavorTextOriginLocation_Koga3"] = 0x1B0E49, - ["gFameCheckerFlavorTextOriginLocation_Koga4"] = 0x1B0E56, - ["gFameCheckerFlavorTextOriginLocation_Koga5"] = 0x1B0E63, - ["gFameCheckerFlavorTextOriginLocation_Lance0"] = 0x1B1002, - ["gFameCheckerFlavorTextOriginLocation_Lance1"] = 0x1B1011, - ["gFameCheckerFlavorTextOriginLocation_Lance2"] = 0x1B1020, - ["gFameCheckerFlavorTextOriginLocation_Lance3"] = 0x1B102D, - ["gFameCheckerFlavorTextOriginLocation_Lance4"] = 0x1B103B, - ["gFameCheckerFlavorTextOriginLocation_Lance5"] = 0x1B104A, - ["gFameCheckerFlavorTextOriginLocation_Lorelei0"] = 0x1B0F11, - ["gFameCheckerFlavorTextOriginLocation_Lorelei1"] = 0x1B0F20, - ["gFameCheckerFlavorTextOriginLocation_Lorelei2"] = 0x1B0F2F, - ["gFameCheckerFlavorTextOriginLocation_Lorelei3"] = 0x1B0F3B, - ["gFameCheckerFlavorTextOriginLocation_Lorelei4"] = 0x1B0F47, - ["gFameCheckerFlavorTextOriginLocation_Lorelei5"] = 0x1B0F53, - ["gFameCheckerFlavorTextOriginLocation_LtSurge0"] = 0x1B0D81, - ["gFameCheckerFlavorTextOriginLocation_LtSurge1"] = 0x1B0D90, - ["gFameCheckerFlavorTextOriginLocation_LtSurge2"] = 0x1B0D9E, - ["gFameCheckerFlavorTextOriginLocation_LtSurge3"] = 0x1B0DAC, - ["gFameCheckerFlavorTextOriginLocation_LtSurge4"] = 0x1B0DBA, - ["gFameCheckerFlavorTextOriginLocation_LtSurge5"] = 0x1B0DC8, - ["gFameCheckerFlavorTextOriginLocation_Misty0"] = 0x1B0D2D, - ["gFameCheckerFlavorTextOriginLocation_Misty1"] = 0x1B0D3B, - ["gFameCheckerFlavorTextOriginLocation_Misty2"] = 0x1B0D48, - ["gFameCheckerFlavorTextOriginLocation_Misty3"] = 0x1B0D55, - ["gFameCheckerFlavorTextOriginLocation_Misty4"] = 0x1B0D65, - ["gFameCheckerFlavorTextOriginLocation_Misty5"] = 0x1B0D73, - ["gFameCheckerFlavorTextOriginLocation_MrFuji0"] = 0x1B10A3, - ["gFameCheckerFlavorTextOriginLocation_MrFuji1"] = 0x1B10B1, - ["gFameCheckerFlavorTextOriginLocation_MrFuji2"] = 0x1B10BF, - ["gFameCheckerFlavorTextOriginLocation_MrFuji3"] = 0x1B10CD, - ["gFameCheckerFlavorTextOriginLocation_MrFuji4"] = 0x1B10DB, - ["gFameCheckerFlavorTextOriginLocation_MrFuji5"] = 0x1B10E8, - ["gFameCheckerFlavorTextOriginLocation_ProfOak1"] = 0x1B0C51, - ["gFameCheckerFlavorTextOriginLocation_ProfOak2"] = 0x1B0C5E, - ["gFameCheckerFlavorTextOriginLocation_ProfOak3"] = 0x1B0C6B, - ["gFameCheckerFlavorTextOriginLocation_ProfOak4"] = 0x1B0C79, - ["gFameCheckerFlavorTextOriginLocation_ProfOak5"] = 0x1B0C88, - ["gFameCheckerFlavorTextOriginLocation_Sabrina0"] = 0x1B0E6F, - ["gFameCheckerFlavorTextOriginLocation_Sabrina1"] = 0x1B0E7C, - ["gFameCheckerFlavorTextOriginLocation_Sabrina2"] = 0x1B0E89, - ["gFameCheckerFlavorTextOriginLocation_Sabrina3"] = 0x1B0E95, - ["gFameCheckerFlavorTextOriginLocation_Sabrina4"] = 0x1B0EA1, - ["gFameCheckerFlavorTextOriginLocation_Sabrina5"] = 0x1B0EAE, - ["gFameCheckerFlavorTextOriginObjectName_Agatha0"] = 0x1B1367, - ["gFameCheckerFlavorTextOriginObjectName_Agatha1"] = 0x1B136E, - ["gFameCheckerFlavorTextOriginObjectName_Agatha2"] = 0x1B1376, - ["gFameCheckerFlavorTextOriginObjectName_Agatha3"] = 0x1B137E, - ["gFameCheckerFlavorTextOriginObjectName_Agatha4"] = 0x1B138E, - ["gFameCheckerFlavorTextOriginObjectName_Agatha5"] = 0x1B1395, - ["gFameCheckerFlavorTextOriginObjectName_Bill0"] = 0x1B13D6, - ["gFameCheckerFlavorTextOriginObjectName_Bill1"] = 0x1B13D9, - ["gFameCheckerFlavorTextOriginObjectName_Bill2"] = 0x1B13E4, - ["gFameCheckerFlavorTextOriginObjectName_Bill3"] = 0x1B13EC, - ["gFameCheckerFlavorTextOriginObjectName_Bill4"] = 0x1B13F2, - ["gFameCheckerFlavorTextOriginObjectName_Bill5"] = 0x1B13F8, - ["gFameCheckerFlavorTextOriginObjectName_Blaine0"] = 0x1B12CA, - ["gFameCheckerFlavorTextOriginObjectName_Blaine1"] = 0x1B12CF, - ["gFameCheckerFlavorTextOriginObjectName_Blaine2"] = 0x1B12D6, - ["gFameCheckerFlavorTextOriginObjectName_Blaine3"] = 0x1B12DC, - ["gFameCheckerFlavorTextOriginObjectName_Blaine4"] = 0x1B12E2, - ["gFameCheckerFlavorTextOriginObjectName_Blaine5"] = 0x1B12E8, - ["gFameCheckerFlavorTextOriginObjectName_Brock0"] = 0x1B11AE, - ["gFameCheckerFlavorTextOriginObjectName_Brock1"] = 0x1B11B3, - ["gFameCheckerFlavorTextOriginObjectName_Brock2"] = 0x1B11B9, - ["gFameCheckerFlavorTextOriginObjectName_Brock3"] = 0x1B11C3, - ["gFameCheckerFlavorTextOriginObjectName_Brock4"] = 0x1B11C7, - ["gFameCheckerFlavorTextOriginObjectName_Brock5"] = 0x1B11CB, - ["gFameCheckerFlavorTextOriginObjectName_Bruno0"] = 0x1B1335, - ["gFameCheckerFlavorTextOriginObjectName_Bruno1"] = 0x1B133B, - ["gFameCheckerFlavorTextOriginObjectName_Bruno2"] = 0x1B1341, - ["gFameCheckerFlavorTextOriginObjectName_Bruno3"] = 0x1B1351, - ["gFameCheckerFlavorTextOriginObjectName_Bruno4"] = 0x1B1359, - ["gFameCheckerFlavorTextOriginObjectName_Bruno5"] = 0x1B135F, - ["gFameCheckerFlavorTextOriginObjectName_Daisy0"] = 0x1B1176, - ["gFameCheckerFlavorTextOriginObjectName_Daisy1"] = 0x1B117B, - ["gFameCheckerFlavorTextOriginObjectName_Daisy2"] = 0x1B1181, - ["gFameCheckerFlavorTextOriginObjectName_Daisy3"] = 0x1B1185, - ["gFameCheckerFlavorTextOriginObjectName_Daisy4"] = 0x1B1195, - ["gFameCheckerFlavorTextOriginObjectName_Daisy5"] = 0x1B119E, - ["gFameCheckerFlavorTextOriginObjectName_Erika0"] = 0x1B123E, - ["gFameCheckerFlavorTextOriginObjectName_Erika1"] = 0x1B1243, - ["gFameCheckerFlavorTextOriginObjectName_Erika2"] = 0x1B1249, - ["gFameCheckerFlavorTextOriginObjectName_Erika3"] = 0x1B124E, - ["gFameCheckerFlavorTextOriginObjectName_Erika4"] = 0x1B1254, - ["gFameCheckerFlavorTextOriginObjectName_Erika5"] = 0x1B125A, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni0"] = 0x1B1439, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni1"] = 0x1B1442, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni2"] = 0x1B144C, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni3"] = 0x1B1456, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni4"] = 0x1B145F, - ["gFameCheckerFlavorTextOriginObjectName_Giovanni5"] = 0x1B1463, - ["gFameCheckerFlavorTextOriginObjectName_Koga0"] = 0x1B126A, - ["gFameCheckerFlavorTextOriginObjectName_Koga1"] = 0x1B126F, - ["gFameCheckerFlavorTextOriginObjectName_Koga2"] = 0x1B1274, - ["gFameCheckerFlavorTextOriginObjectName_Koga3"] = 0x1B1279, - ["gFameCheckerFlavorTextOriginObjectName_Koga4"] = 0x1B1281, - ["gFameCheckerFlavorTextOriginObjectName_Koga5"] = 0x1B1291, - ["gFameCheckerFlavorTextOriginObjectName_Lance0"] = 0x1B139E, - ["gFameCheckerFlavorTextOriginObjectName_Lance1"] = 0x1B13A4, - ["gFameCheckerFlavorTextOriginObjectName_Lance2"] = 0x1B13AA, - ["gFameCheckerFlavorTextOriginObjectName_Lance3"] = 0x1B13B0, - ["gFameCheckerFlavorTextOriginObjectName_Lance4"] = 0x1B13BC, - ["gFameCheckerFlavorTextOriginObjectName_Lance5"] = 0x1B13CC, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei0"] = 0x1B12F8, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei1"] = 0x1B1300, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei2"] = 0x1B1308, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei3"] = 0x1B1310, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei4"] = 0x1B1320, - ["gFameCheckerFlavorTextOriginObjectName_Lorelei5"] = 0x1B132C, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge0"] = 0x1B120A, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge1"] = 0x1B120F, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge2"] = 0x1B1219, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge3"] = 0x1B1220, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge4"] = 0x1B1227, - ["gFameCheckerFlavorTextOriginObjectName_LtSurge5"] = 0x1B122E, - ["gFameCheckerFlavorTextOriginObjectName_Misty0"] = 0x1B11DB, - ["gFameCheckerFlavorTextOriginObjectName_Misty1"] = 0x1B11E0, - ["gFameCheckerFlavorTextOriginObjectName_Misty2"] = 0x1B11E6, - ["gFameCheckerFlavorTextOriginObjectName_Misty3"] = 0x1B11EB, - ["gFameCheckerFlavorTextOriginObjectName_Misty4"] = 0x1B11F5, - ["gFameCheckerFlavorTextOriginObjectName_Misty5"] = 0x1B11FA, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji0"] = 0x1B13FE, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji1"] = 0x1B140A, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji2"] = 0x1B1416, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji3"] = 0x1B141F, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji4"] = 0x1B1423, - ["gFameCheckerFlavorTextOriginObjectName_MrFuji5"] = 0x1B1429, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak0"] = 0x1B1146, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak1"] = 0x1B114B, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak2"] = 0x1B1155, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak3"] = 0x1B115A, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak4"] = 0x1B116A, - ["gFameCheckerFlavorTextOriginObjectName_ProfOak5"] = 0x1B1171, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina0"] = 0x1B1295, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina1"] = 0x1B129A, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina2"] = 0x1B12A4, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina3"] = 0x1B12AC, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina4"] = 0x1B12B4, - ["gFameCheckerFlavorTextOriginObjectName_Sabrina5"] = 0x1B12C4, - ["gFameCheckerMrFujiName"] = 0x41E5F8, - ["gFameCheckerOakName"] = 0x41E5E9, - ["gFameCheckerText_Cancel"] = 0x4161C1, - ["gFameCheckerText_ClearTextbox"] = 0x4181E4, - ["gFameCheckerText_FameCheckerWillBeClosed"] = 0x4181C3, - ["gFameCheckerText_FlavorTextUI"] = 0x41E5D2, - ["gFameCheckerText_MainScreenUI"] = 0x41E5A4, - ["gFameCheckerText_PickScreenUI"] = 0x41E5B9, - ["gJPText_AllowEReaderToLoadCard"] = 0x41DE98, - ["gJPText_CardReadingHasBeenHalted"] = 0x41DE7D, - ["gJPText_Connecting"] = 0x41DE95, - ["gJPText_ConnectionComplete"] = 0x41DE99, - ["gJPText_ConnectionErrorCheckLink"] = 0x41DE96, - ["gJPText_ConnectionErrorTryAgain"] = 0x41DE97, - ["gJPText_DecideStop"] = 0x41DE51, - ["gJPText_LinkIsIncorrect"] = 0x41DE7C, - ["gJPText_MysteryGift"] = 0x41DE50, - ["gJPText_NewTrainerHasComeToSevii"] = 0x41DE9A, - ["gJPText_PleaseWaitAMoment"] = 0x41DE9B, - ["gJPText_ReceiveMysteryGiftWithEReader"] = 0x41DE52, - ["gJPText_SelectConnectAndPressA"] = 0x41DE55, - ["gJPText_SelectConnectFromEReaderMenu"] = 0x41DE53, - ["gJPText_SelectConnectWithGBA"] = 0x41DE54, - ["gJPText_UnableConnectWithEReader"] = 0x41DE7E, - ["gJPText_WriteErrorUnableToSaveData"] = 0x41DE9C, - ["gMenuText_Confirm"] = 0x4161B7, - ["gNameChoice_Amanda"] = 0x1C57D9, - ["gNameChoice_Ash"] = 0x1C577A, - ["gNameChoice_Cassie"] = 0x1C57FF, - ["gNameChoice_Fire"] = 0x1C5767, - ["gNameChoice_Gary"] = 0x1C576C, - ["gNameChoice_Geki"] = 0x1C5783, - ["gNameChoice_Green"] = 0x1C5758, - ["gNameChoice_Hillary"] = 0x1C57E0, - ["gNameChoice_Hiro"] = 0x1C57AF, - ["gNameChoice_Jak"] = 0x1C5788, - ["gNameChoice_Janne"] = 0x1C578C, - ["gNameChoice_Jodi"] = 0x1C57D4, - ["gNameChoice_Jon"] = 0x1C57B8, - ["gNameChoice_Jonn"] = 0x1C5792, - ["gNameChoice_June"] = 0x1C57FA, - ["gNameChoice_Kamon"] = 0x1C5797, - ["gNameChoice_Karl"] = 0x1C579D, - ["gNameChoice_Kay"] = 0x1C57C2, - ["gNameChoice_Kaz"] = 0x1C5771, - ["gNameChoice_Kene"] = 0x1C577E, - ["gNameChoice_Kiko"] = 0x1C580F, - ["gNameChoice_Leaf"] = 0x1C5762, - ["gNameChoice_Makey"] = 0x1C57E8, - ["gNameChoice_Max"] = 0x1C57B4, - ["gNameChoice_Michi"] = 0x1C57EE, - ["gNameChoice_Mina"] = 0x1C5814, - ["gNameChoice_Momo"] = 0x1C5823, - ["gNameChoice_Norie"] = 0x1C5819, - ["gNameChoice_Omi"] = 0x1C57D0, - ["gNameChoice_Oscar"] = 0x1C57A9, - ["gNameChoice_Paula"] = 0x1C57F4, - ["gNameChoice_Ralph"] = 0x1C57BC, - ["gNameChoice_Red"] = 0x1C575E, - ["gNameChoice_Rey"] = 0x1C5806, - ["gNameChoice_Roak"] = 0x1C57CB, - ["gNameChoice_Sai"] = 0x1C581F, - ["gNameChoice_Seda"] = 0x1C580A, - ["gNameChoice_Suzi"] = 0x1C5828, - ["gNameChoice_Taylor"] = 0x1C57A2, - ["gNameChoice_Toru"] = 0x1C5775, - ["gNameChoice_Tosh"] = 0x1C57C6, - ["gOakSpeech_Text_AskPlayerGender"] = 0x1C59D5, - ["gOakSpeech_Text_ConfirmRivalName"] = 0x1C5EB5, - ["gOakSpeech_Text_IStudyPokemon"] = 0x1C5D4B, - ["gOakSpeech_Text_IsInhabitedFarAndWide"] = 0x1C5D12, - ["gOakSpeech_Text_LetsGo"] = 0x1C5EF4, - ["gOakSpeech_Text_RememberRivalsName"] = 0x1C5EC5, - ["gOakSpeech_Text_SoYourNameIsPlayer"] = 0x1C5E13, - ["gOakSpeech_Text_TellMeALittleAboutYourself"] = 0x1C5DBD, - ["gOakSpeech_Text_ThisWorld"] = 0x1C5D06, - ["gOakSpeech_Text_WelcomeToTheWorld"] = 0x1C5C78, - ["gOakSpeech_Text_WhatWasHisName"] = 0x1C5E2E, - ["gOakSpeech_Text_YourNameWhatIsIt"] = 0x1C5DEA, - ["gOakSpeech_Text_YourRivalsNameWhatWasIt"] = 0x1C5E91, - ["gOtherText_Check"] = 0x4161E9, - ["gOtherText_Deselect"] = 0x4161F9, - ["gOtherText_Double"] = 0x417F89, - ["gOtherText_Exit"] = 0x4161C8, - ["gOtherText_Give"] = 0x4161B2, - ["gOtherText_Give2"] = 0x4177E9, - ["gOtherText_Knockout"] = 0x417F90, - ["gOtherText_Mixed"] = 0x417F99, - ["gOtherText_MoveToBag"] = 0x4177DD, - ["gOtherText_NewName"] = 0x1C574F, - ["gOtherText_Open"] = 0x4161F4, - ["gOtherText_Powder"] = 0x416655, - ["gOtherText_Quit"] = 0x417929, - ["gOtherText_Read"] = 0x4177D8, - ["gOtherText_Register"] = 0x4161A9, - ["gOtherText_Single"] = 0x417F82, - ["gOtherText_Toss"] = 0x4161A4, - ["gOtherText_Use"] = 0x4161A0, - ["gOtherText_Walk"] = 0x4161BC, - ["gOtherText_WhereShouldTheStrVar1BePlaced"] = 0x41633F, - ["gPCText_Bag"] = 0x4184C9, - ["gPCText_Beach"] = 0x41852B, - ["gPCText_Cancel"] = 0x418468, - ["gPCText_Cave"] = 0x418526, - ["gPCText_City"] = 0x418500, - ["gPCText_Crag"] = 0x418514, - ["gPCText_Desert"] = 0x418505, - ["gPCText_Etcetera"] = 0x4184F0, - ["gPCText_Forest"] = 0x4184F9, - ["gPCText_Give"] = 0x4184BD, - ["gPCText_Info"] = 0x4184CD, - ["gPCText_Jump"] = 0x4184A9, - ["gPCText_Machine"] = 0x418555, - ["gPCText_Mark"] = 0x41849F, - ["gPCText_Move"] = 0x418484, - ["gPCText_Name"] = 0x4184A4, - ["gPCText_Place"] = 0x418489, - ["gPCText_Pokecenter"] = 0x41854A, - ["gPCText_PolkaDot"] = 0x418544, - ["gPCText_Release"] = 0x418497, - ["gPCText_River"] = 0x41853A, - ["gPCText_Savanna"] = 0x41850C, - ["gPCText_Scenery1"] = 0x4184D2, - ["gPCText_Scenery2"] = 0x4184DC, - ["gPCText_Scenery3"] = 0x4184E6, - ["gPCText_Seafloor"] = 0x418531, - ["gPCText_Shift"] = 0x41847E, - ["gPCText_Simple"] = 0x41855B, - ["gPCText_Sky"] = 0x418540, - ["gPCText_Snow"] = 0x418521, - ["gPCText_Store"] = 0x41846F, - ["gPCText_Summary"] = 0x41848F, - ["gPCText_Switch"] = 0x4184C2, - ["gPCText_Take"] = 0x4184B8, - ["gPCText_Volcano"] = 0x418519, - ["gPCText_Wallpaper"] = 0x4184AE, - ["gPCText_Withdraw"] = 0x418475, - ["gPikachuIntro_Text_Page1"] = 0x1C5A04, - ["gPikachuIntro_Text_Page2"] = 0x1C5AEB, - ["gPikachuIntro_Text_Page3"] = 0x1C5BB9, - ["gPokedudeText_ReadTMDescription"] = 0x41C693, - ["gPokedudeText_TMTypes"] = 0x41C587, - ["gSaveStatName_Badges"] = 0x416181, - ["gSaveStatName_Player"] = 0x41617A, - ["gSaveStatName_Pokedex"] = 0x416188, - ["gSaveStatName_Time"] = 0x416190, - ["gStartMenuDesc_Bag"] = 0x41A001, - ["gStartMenuDesc_Exit"] = 0x41A0F9, - ["gStartMenuDesc_Option"] = 0x41A0B7, - ["gStartMenuDesc_Player"] = 0x41A049, - ["gStartMenuDesc_Pokedex"] = 0x419F76, - ["gStartMenuDesc_Pokemon"] = 0x419FBB, - ["gStartMenuDesc_Retire"] = 0x41A111, - ["gStartMenuDesc_Save"] = 0x41A06F, - ["gStatusConditionString_BurnJpn"] = 0x2500AC, - ["gStatusConditionString_ConfusionJpn"] = 0x2500BC, - ["gStatusConditionString_IceJpn"] = 0x2500B4, - ["gStatusConditionString_LoveJpn"] = 0x2500C4, - ["gStatusConditionString_ParalysisJpn"] = 0x2500A4, - ["gStatusConditionString_PoisonJpn"] = 0x250094, - ["gStatusConditionString_SleepJpn"] = 0x25009C, - ["gString_BattleRecords_4Dashes"] = 0x4181BE, - ["gString_BattleRecords_7Dashes"] = 0x4181B6, - ["gString_BattleRecords_ColumnHeaders"] = 0x4181A4, - ["gString_BattleRecords_PlayersBattleResults"] = 0x418174, - ["gString_BattleRecords_TotalRecord"] = 0x418188, - ["gString_Bill"] = 0x41CB3C, - ["gString_Dummy"] = 0x4161CD, - ["gString_Help"] = 0x41CB49, - ["gString_HelpSystem_ClearTo8"] = 0x41CB4E, - ["gString_OutOfCoins"] = 0x41B747, - ["gString_PokemonFireRed_Staff"] = 0x41D198, - ["gString_PokemonLeafGreen_Staff"] = 0x41D1B8, - ["gString_QuitPlaying"] = 0x41B76B, - ["gString_SlotMachineControls"] = 0x41B779, - ["gString_Someone"] = 0x41CB41, - ["gTeachyTvString_AboutTMs"] = 0x41B807, - ["gTeachyTvString_Cancel"] = 0x41B836, - ["gTeachyTvString_CatchPkmn"] = 0x41B7EE, - ["gTeachyTvString_RegisterItem"] = 0x41B81B, - ["gTeachyTvString_StatusProblems"] = 0x41B7BC, - ["gTeachyTvString_TeachBattle"] = 0x41B7A4, - ["gTeachyTvString_TypeMatchups"] = 0x41B7D6, - ["gTeachyTvText_BattleScript1"] = 0x41B8BF, - ["gTeachyTvText_BattleScript2"] = 0x41BA41, - ["gTeachyTvText_CatchingScript1"] = 0x41C23B, - ["gTeachyTvText_CatchingScript2"] = 0x41C384, - ["gTeachyTvText_MatchupsScript1"] = 0x41BE76, - ["gTeachyTvText_MatchupsScript2"] = 0x41C0AF, - ["gTeachyTvText_PokedudeSaysHello"] = 0x41B83D, - ["gTeachyTvText_RegisterScript1"] = 0x41C82A, - ["gTeachyTvText_RegisterScript2"] = 0x41C994, - ["gTeachyTvText_StatusScript1"] = 0x41BB40, - ["gTeachyTvText_StatusScript2"] = 0x41BD10, - ["gTeachyTvText_TMsScript1"] = 0x41C459, - ["gTeachyTvText_TMsScript2"] = 0x41C7B4, - ["gTextJPDummy_Hiki"] = 0x41B6EB, - ["gTextJPDummy_Ko"] = 0x41B6F3, - ["gTextJP_HowToUsePokedex"] = 0x415EBC, - ["gTextJP_InternalBatteryHasRunDry"] = 0x415C8F, - ["gTextJP_Options"] = 0x415A80, - ["gTextJP_PokemonData"] = 0x415FA5, - ["gTextJP_Type"] = 0x419E42, - ["gText_10F"] = 0x418055, - ["gText_10P30P50P50P"] = 0x41CD25, - ["gText_11F"] = 0x418059, - ["gText_12PoofForgotMove"] = 0x416EC6, - ["gText_1BigMushroom"] = 0x417EA4, - ["gText_1Colon"] = 0x41CD6B, - ["gText_1F"] = 0x41803A, - ["gText_1F_2"] = 0x417AB0, - ["gText_1MSubCircuitBoardNotInstalled"] = 0x415C64, - ["gText_1MinutePlus"] = 0x417FA9, - ["gText_1_2_and_Poof"] = 0x41E493, - ["gText_1_ClrBluShdwLtBlu_Dynamic0"] = 0x41D020, - ["gText_1_Dynamic0"] = 0x41D02C, - ["gText_2Colon"] = 0x41CD6E, - ["gText_2F"] = 0x41803D, - ["gText_2F_2"] = 0x417AB3, - ["gText_2Tinymushrooms"] = 0x417E94, - ["gText_3Colon"] = 0x41CD71, - ["gText_3F"] = 0x418040, - ["gText_3F_2"] = 0x417AB6, - ["gText_4Colon"] = 0x41CD74, - ["gText_4F"] = 0x418043, - ["gText_4F_2"] = 0x417AB9, - ["gText_4Qmark"] = 0x41E09F, - ["gText_500Coins_10000"] = 0x4179D6, - ["gText_50Coins_1000"] = 0x4179C0, - ["gText_5Colon"] = 0x41CD77, - ["gText_5Dashes"] = 0x415F66, - ["gText_5F"] = 0x418046, - ["gText_5F_2"] = 0x417ABC, - ["gText_6F"] = 0x418049, - ["gText_7F"] = 0x41804C, - ["gText_8F"] = 0x41804F, - ["gText_99TimesPlus"] = 0x417F9F, - ["gText_9F"] = 0x418052, - ["gText_ABUTTONExit"] = 0x415D8C, - ["gText_ABUTTONNext"] = 0x415D48, - ["gText_ABUTTONNext_BBUTTONBack"] = 0x415D50, - ["gText_ABtnRegistrationCounter"] = 0x41DF4C, - ["gText_ABtnTitleScreen"] = 0x41DF6B, - ["gText_ABugMove"] = 0x3FE8E6, - ["gText_AButton"] = 0x415CF6, - ["gText_ADarkMove"] = 0x3FE976, - ["gText_ADragonMove"] = 0x3FE968, - ["gText_AFightingMove"] = 0x3FE8A0, - ["gText_AFireMove"] = 0x3FE916, - ["gText_AFlyingMove"] = 0x3FE8B0, - ["gText_AGhostMove"] = 0x3FE8F1, - ["gText_AGrassMove"] = 0x3FE92F, - ["gText_AGroundMove"] = 0x3FE8CC, - ["gText_AMysteryMove"] = 0x3FE90B, - ["gText_ANormalMove"] = 0x3FE892, - ["gText_APoisonMove"] = 0x3FE8BE, - ["gText_APsychicMove"] = 0x3FE94D, - ["gText_ARockMove"] = 0x3FE8DA, - ["gText_ASteelMove"] = 0x3FE8FE, - ["gText_AToZMode"] = 0x415EA4, - ["gText_AWaterMove"] = 0x3FE922, - ["gText_Able"] = 0x417419, - ["gText_Able2"] = 0x417431, - ["gText_Able3"] = 0x41743F, - ["gText_Abra_120Coins"] = 0x417CD1, - ["gText_Abra_180Coins"] = 0x417C55, - ["gText_AllTextBeingEditedWill"] = 0x418937, - ["gText_AlphabetUpperLower"] = 0x418E8A, - ["gText_AlreadyHadCard"] = 0x41E946, - ["gText_AlreadyHadNews"] = 0x41E968, - ["gText_AlreadyHadStamp"] = 0x41E98F, - ["gText_AlreadySaveFile_WouldLikeToOverwrite"] = 0x1C55EA, - ["gText_AlreadySurfing"] = 0x417281, - ["gText_AnElectricMove"] = 0x3FE93C, - ["gText_AnIceMove"] = 0x3FE95C, - ["gText_And"] = 0x41B315, - ["gText_AndFillOutTheQuestionnaire"] = 0x4187EE, - ["gText_AndImproveTheBardsSong"] = 0x4187D1, - ["gText_AndMakeAMessage"] = 0x41879E, - ["gText_AndMakeATrendySaying"] = 0x4188D6, - ["gText_AndMakeYourProfile"] = 0x4186EF, - ["gText_AnnouncingPrizes"] = 0x41CD58, - ["gText_AnnouncingRankings"] = 0x41CD43, - ["gText_AnythingElseICanHelp"] = 0x41689E, - ["gText_Area"] = 0x415FE8, - ["gText_AreaUnknown"] = 0x415FF2, - ["gText_AtTheBattlesStart"] = 0x418A33, - ["gText_AwesomeWonF701F700"] = 0x41CBA9, - ["gText_B1F"] = 0x41805D, - ["gText_B2F"] = 0x418061, - ["gText_B3F"] = 0x418065, - ["gText_B4F"] = 0x418069, - ["gText_BButton"] = 0x415CFB, - ["gText_BButtonCancel"] = 0x41E2EC, - ["gText_BackupMemoryDamaged"] = 0x418CD9, - ["gText_BadEgg"] = 0x3FE868, - ["gText_Badges"] = 0x41B6EC, - ["gText_BadgesUnused"] = 0x415CED, - ["gText_BagFullCouldNotRemoveItem"] = 0x416D78, - ["gText_BagIsFull"] = 0x417858, - ["gText_BagIsFull2"] = 0x418408, - ["gText_Battle"] = 0x41B524, - ["gText_BattleMenu"] = 0x3FE725, - ["gText_BattlePoints"] = 0x419D02, - ["gText_BattleScene"] = 0x419DDE, - ["gText_BattleSceneOff"] = 0x419E28, - ["gText_BattleSceneOn"] = 0x419E25, - ["gText_BattleStyle"] = 0x419DEB, - ["gText_BattleStyleSet"] = 0x419E32, - ["gText_BattleStyleShift"] = 0x419E2C, - ["gText_BattleSwitchWhich"] = 0x3FE7A0, - ["gText_BattleTower"] = 0x417A30, - ["gText_BattleTowerBan_Is1"] = 0x3FE860, - ["gText_BattleTowerBan_Is2"] = 0x3FE864, - ["gText_BattleTowerBan_Newline1"] = 0x3FE85C, - ["gText_BattleTowerBan_Newline2"] = 0x3FE85E, - ["gText_BattleTowerBan_Space"] = 0x3FE859, - ["gText_BattleWallyName"] = 0x3FE870, - ["gText_BattleWasRefused"] = 0x41E572, - ["gText_BattleYesNoChoice"] = 0x3FE791, - ["gText_BeDeletedThatOkay"] = 0x418956, - ["gText_Beauty"] = 0x417AC4, - ["gText_Beauty3"] = 0x418C3F, - ["gText_Beauty_2"] = 0x417AF9, - ["gText_BecomeLeader"] = 0x417F69, - ["gText_Berries"] = 0x417B36, - ["gText_BerriesInRowFivePlayers"] = 0x41CCD8, - ["gText_BerriesPicked"] = 0x41CCBC, - ["gText_Berry"] = 0x4162BD, - ["gText_BerryCrush"] = 0x417E5A, - ["gText_BerryCrush2"] = 0x41D118, - ["gText_BerryCrush_AreYouReady"] = 0x41CE3C, - ["gText_BerryCrush_CommunicationStandby"] = 0x41D008, - ["gText_BerryCrush_GainedXUnitsOfPowder"] = 0x41CEA8, - ["gText_BerryCrush_MemberDroppedOut"] = 0x41CFA0, - ["gText_BerryCrush_NoBerries"] = 0x41CF70, - ["gText_BerryCrush_RecordingGameResults"] = 0x41CF14, - ["gText_BerryCrush_TimeUp"] = 0x41CFD4, - ["gText_BerryCrush_WaitForOthersToChooseBerry"] = 0x41CE78, - ["gText_BerryCrush_WantToPlayAgain"] = 0x41CF50, - ["gText_BerryCrushes"] = 0x419D7D, - ["gText_BerryPickingRecords"] = 0x41CC9F, - ["gText_BerryPickingResults"] = 0x41CD03, - ["gText_BerryPouch"] = 0x41670A, - ["gText_BerryPouch_2"] = 0x417DE1, - ["gText_BestScore"] = 0x41CCCC, - ["gText_BestScore2"] = 0x41CB88, - ["gText_Bicycle_Price"] = 0x417C33, - ["gText_BigGirl"] = 0x417FC3, - ["gText_BigGuy"] = 0x417FBB, - ["gText_BillSPc"] = 0x417BAC, - ["gText_BirchInTrouble"] = 0x41610A, - ["gText_BirthIsland"] = 0x417ECC, - ["gText_Black"] = 0x417AE3, - ["gText_BlueShard"] = 0x417A19, - ["gText_BoostPp"] = 0x4172C2, - ["gText_BootedUpHM"] = 0x416556, - ["gText_BootedUpTM"] = 0x416548, - ["gText_BoulderBadge"] = 0x417FD9, - ["gText_Boulderbadge"] = 0x417B3E, - ["gText_Box"] = 0x4186CD, - ["gText_BoxFull"] = 0x416631, - ["gText_BoxIsFull2"] = 0x4182A7, - ["gText_BoxName"] = 0x418E52, - ["gText_Boy"] = 0x415D93, - ["gText_Brn"] = 0x417918, - ["gText_BronzeCard"] = 0x41DF8B, - ["gText_Burn"] = 0x3FE837, - ["gText_ButtonMode"] = 0x419DFE, - ["gText_ButtonTypeHelp"] = 0x419E46, - ["gText_ButtonTypeLEqualsA"] = 0x419E4B, - ["gText_ButtonTypeLR"] = 0x419E4F, - ["gText_ByeBye"] = 0x41B54B, - ["gText_ByeByePkmn"] = 0x4182DF, - ["gText_ByeByeVar1"] = 0x41E200, - ["gText_Calcium_1000"] = 0x41812F, - ["gText_CanIHelpWithAnythingElse"] = 0x4168BF, - ["gText_Cancel7"] = 0x4161CE, - ["gText_CancelBattle"] = 0x4176CF, - ["gText_CancelParticipation"] = 0x4176B8, - ["gText_CancelPreviousData"] = 0x415FCF, - ["gText_CancelTrade"] = 0x41E0E0, - ["gText_CantAcceptCardFromTrainer"] = 0x41EA0D, - ["gText_CantAcceptNewsFromTrainer"] = 0x41EA3F, - ["gText_CantBeUsedOnPkmn"] = 0x4169F8, - ["gText_CantDismountBike"] = 0x416451, - ["gText_CantHoldAnyMore"] = 0x41CD9F, - ["gText_CantHoldMore"] = 0x41CBE4, - ["gText_CantSendGiftToTrainer"] = 0x41EC6A, - ["gText_CantSurfHere"] = 0x417270, - ["gText_CantSwitchWithAlly"] = 0x416A98, - ["gText_CantTradeWithTrainer"] = 0x417164, - ["gText_CantUseHere"] = 0x417299, - ["gText_CantUseUntilNewBadge"] = 0x416AE2, - ["gText_CantWriteMailHere"] = 0x41630F, - ["gText_Carbos_1000"] = 0x41811D, - ["gText_CascadeBadge"] = 0x417FE6, - ["gText_Cascadebadge"] = 0x417B4B, - ["gText_ChangeJustOneWordOrPhrase"] = 0x4187B2, - ["gText_ChangedToNewItem"] = 0x418443, - ["gText_Charcoal_1000Coins"] = 0x417EF7, - ["gText_CheckCompleted"] = 0x418D99, - ["gText_ChooseBattleLineup"] = 0x41E2F5, - ["gText_ChoosePokemon"] = 0x4171DF, - ["gText_ChoosePokemon2"] = 0x417301, - ["gText_ChoosePokemonCancel"] = 0x417396, - ["gText_ChoosePokemonConfirm"] = 0x4173B0, - ["gText_ClearAllSaveData"] = 0x41B69E, - ["gText_ClearTime"] = 0x3FE98D, - ["gText_ClearingData"] = 0x41B6B9, - ["gText_Clefairy_500Coins"] = 0x417C6A, - ["gText_Clefairy_750Coins"] = 0x417CE6, - ["gText_ClimbAWaterfall"] = 0x4175AE, - ["gText_ClockHasBeenReset"] = 0x419EC3, - ["gText_Close"] = 0x4166DB, - ["gText_CloseBag"] = 0x4162F5, - ["gText_ClosePokedex"] = 0x415EC7, - ["gText_CoinCase"] = 0x416537, - ["gText_Coins"] = 0x4162C4, - ["gText_Coins_2"] = 0x417C2D, - ["gText_Colon"] = 0x41B6E1, - ["gText_Colon2"] = 0x419CF8, - ["gText_Colosseum"] = 0x417D86, - ["gText_Colosseum_2"] = 0x417E50, - ["gText_CombineFourWordsOrPhrases"] = 0x4186D1, - ["gText_CombineNineWordsOrPhrases"] = 0x418780, - ["gText_CombineTwoWordsOrPhrases"] = 0x4188B9, - ["gText_CombineTwoWordsOrPhrases2"] = 0x418915, - ["gText_CombineTwoWordsOrPhrases3"] = 0x4189CB, - ["gText_CommErrorCheckConnections"] = 0x41DE9D, - ["gText_CommErrorEllipsis"] = 0x41DEF0, - ["gText_CommStandbyAwaitingOtherPlayer"] = 0x41E538, - ["gText_Communicating"] = 0x41E794, - ["gText_CommunicationCanceled"] = 0x41E7D1, - ["gText_CommunicationCompleted"] = 0x41E7A3, - ["gText_CommunicationError"] = 0x41E7BC, - ["gText_CommunicationStandby3"] = 0x41CE24, - ["gText_CommunicationStandby4"] = 0x41CC64, - ["gText_CommunicationStandby5"] = 0x41E325, - ["gText_ConfirmStarterChoice"] = 0x416133, - ["gText_Confusion"] = 0x3FE84A, - ["gText_CongratsPkmnEvolved"] = 0x3FE688, - ["gText_Continue"] = 0x415A77, - ["gText_ContinueBoxOperations"] = 0x418379, - ["gText_ControlPad"] = 0x415D1A, - ["gText_Controls"] = 0x415D2C, - ["gText_Cool"] = 0x417ABF, - ["gText_Cool_2"] = 0x417AF4, - ["gText_Coolness"] = 0x418C39, - ["gText_CooperativeRankings"] = 0x41D0E8, - ["gText_CopperCard"] = 0x41DF92, - ["gText_Counting_1"] = 0x41B2ED, - ["gText_Counting_2And"] = 0x41B2F1, - ["gText_CrushingResults"] = 0x41D0C0, - ["gText_Cry"] = 0x415FAD, - ["gText_CurrentIsTooFast"] = 0x417361, - ["gText_CutATreeOrGrass"] = 0x417533, - ["gText_Cute"] = 0x417ACB, - ["gText_Cute_2"] = 0x417B00, - ["gText_Cuteness"] = 0x418C45, - ["gText_DPadChooseAButtonConfirm"] = 0x41E2DF, - ["gText_DataWillBeSaved"] = 0x41ED2F, - ["gText_Daughter"] = 0x417FD0, - ["gText_DecimalPoint"] = 0x417FB9, - ["gText_Decoration"] = 0x4176EB, - ["gText_DelAllCancelOk"] = 0x41EE2B, - ["gText_DepositHowManyStrVars1"] = 0x41638F, - ["gText_DepositInWhichBox"] = 0x41827F, - ["gText_DepositItem"] = 0x4162E8, - ["gText_DepositItem2"] = 0x417706, - ["gText_DepositMonDescription"] = 0x4185E2, - ["gText_DepositPokemon"] = 0x41857D, - ["gText_DepositedStrVar2StrVar1s"] = 0x4163A7, - ["gText_DexCategory_CavePkmn"] = 0x415E39, - ["gText_DexCategory_ForestPkmn"] = 0x415E09, - ["gText_DexCategory_GrasslandPkmn"] = 0x415DF7, - ["gText_DexCategory_MountainPkmn"] = 0x415E46, - ["gText_DexCategory_RarePkmn"] = 0x415E7B, - ["gText_DexCategory_RoughTerrainPkmn"] = 0x415E57, - ["gText_DexCategory_SeaPkmn"] = 0x415E2D, - ["gText_DexCategory_UrbanPkmn"] = 0x415E6D, - ["gText_DexCategory_WatersEdgePkmn"] = 0x415E18, - ["gText_DifferentGameFile"] = 0x1C566A, - ["gText_Diploma_GameFreak"] = 0x41B684, - ["gText_Diploma_Kanto"] = 0x41B698, - ["gText_Diploma_National"] = 0x41B68F, - ["gText_Diploma_Player"] = 0x41B60E, - ["gText_Diploma_ThisDocument"] = 0x41B619, - ["gText_DirectCorner"] = 0x41808E, - ["gText_DiveUnderwater"] = 0x4175C1, - ["gText_DoWhatWithItem"] = 0x4172D5, - ["gText_DoWhatWithMail"] = 0x41737F, - ["gText_DoWhatWithPokemon"] = 0x417242, - ["gText_DodrioBerryPicking"] = 0x417E7F, - ["gText_DomeFossil"] = 0x417D63, - ["gText_DontHaveCardNewOneInput"] = 0x41E6A1, - ["gText_DontHaveNewsNewOneInput"] = 0x41E6DC, - ["gText_DoubleBattle"] = 0x417952, - ["gText_Dratini_2800Coins"] = 0x417C83, - ["gText_Dratini_4600Coins"] = 0x417D18, - ["gText_Draw"] = 0x3FE883, - ["gText_Dynamic0Players"] = 0x41E2B4, - ["gText_Dynamic1Players"] = 0x41E2BF, - ["gText_Dynamic2Players"] = 0x41E2C9, - ["gText_Dynamic3Players"] = 0x41E2D4, - ["gText_EarthBadge"] = 0x41802F, - ["gText_Earthbadge"] = 0x417B94, - ["gText_EasyChatKeyboard_ABCDEFothers"] = 0x47A8D8, - ["gText_EasyChatKeyboard_GHIJKL"] = 0x47A8FA, - ["gText_EasyChatKeyboard_MNOPQRS"] = 0x47A913, - ["gText_EasyChatKeyboard_TUVWXYZ"] = 0x47A934, - ["gText_Eevee"] = 0x417BE2, - ["gText_EggCantBattle"] = 0x416ACB, - ["gText_EggCantBeTradedNow"] = 0x4170E0, - ["gText_EggNickname"] = 0x415A62, - ["gText_Eggs"] = 0x4178EB, - ["gText_Ellipsis"] = 0x45ABE8, - ["gText_EllipsisQuestionMark"] = 0x3FE6D0, - ["gText_Empty"] = 0x4169D8, - ["gText_Empty1"] = 0x41E23A, - ["gText_Empty10"] = 0x41E243, - ["gText_Empty11"] = 0x41E244, - ["gText_Empty12"] = 0x4176CE, - ["gText_Empty2"] = 0x41E23B, - ["gText_Empty3"] = 0x41E23C, - ["gText_Empty4"] = 0x41E23D, - ["gText_Empty5"] = 0x41E23E, - ["gText_Empty6"] = 0x41E23F, - ["gText_Empty7"] = 0x41E240, - ["gText_Empty8"] = 0x41E241, - ["gText_Empty9"] = 0x41E242, - ["gText_EmptyLinkService1"] = 0x417E66, - ["gText_EmptyLinkService2"] = 0x417E67, - ["gText_EmptySpace"] = 0x416707, - ["gText_EmptyString3"] = 0x3FDA4C, - ["gText_EmptyTextInput1"] = 0x47AB39, - ["gText_EmptyTextInput2"] = 0x47AB3A, - ["gText_EmptyTextInput3"] = 0x47AB3B, - ["gText_EmptyTextInput4"] = 0x47AB3C, - ["gText_EmptyTextInput5"] = 0x47AB3D, - ["gText_EmptyTextInput6"] = 0x47AB3E, - ["gText_EmptyTextInput7"] = 0x47AB3F, - ["gText_EmptyTextInput8"] = 0x47AB40, - ["gText_End"] = 0x41B79F, - ["gText_EnergyRoot_80"] = 0x4180BC, - ["gText_Energypowder_50"] = 0x4180A7, - ["gText_EnjoyCycling"] = 0x4173E4, - ["gText_Enter"] = 0x4169A3, - ["gText_EntireTextCantBeDeleted"] = 0x418982, - ["gText_Escape3"] = 0x41622F, - ["gText_EscapeFromHere"] = 0x4175CC, - ["gText_EscapeFromHereAndReturnTo"] = 0x417615, - ["gText_Excellent"] = 0x4179ED, - ["gText_ExcellentsInARow"] = 0x41CB94, - ["gText_Exit"] = 0x41B361, - ["gText_Exit3"] = 0x41EE14, - ["gText_ExitFromBox"] = 0x418208, - ["gText_ExitUnused"] = 0x418204, - ["gText_ExitingTheChat"] = 0x41B3E9, - ["gText_F700JoinedChat"] = 0x41B3AA, - ["gText_F700LeftChat"] = 0x41B3BE, - ["gText_FailedUpdateSwapBackup"] = 0x415BAA, - ["gText_FeelingsRightNow"] = 0x418763, - ["gText_FemaleSymbol"] = 0x416221, - ["gText_FemaleSymbol4"] = 0x261EC3, - ["gText_Ferry"] = 0x418E36, - ["gText_FilledStorageSpace"] = 0x41CDBA, - ["gText_FilledStorageSpace2"] = 0x41CBCA, - ["gText_FindWordsThatDescribeYour"] = 0x418745, - ["gText_FirstPlacePrize"] = 0x41CD7A, - ["gText_First_PM"] = 0x41741E, - ["gText_FiveIsland"] = 0x417E22, - ["gText_FiveMarks"] = 0x416206, - ["gText_Flareon"] = 0x417BE8, - ["gText_FlyToAKnownTown"] = 0x417548, - ["gText_FlyToWhere"] = 0x416195, - ["gText_FontNormal"] = 0x416703, - ["gText_FontSmall"] = 0x4166FF, - ["gText_FourEllipsesExclamation"] = 0x4183BA, - ["gText_FourIsland"] = 0x417E16, - ["gText_Frame"] = 0x419E0A, - ["gText_FrameType"] = 0x419E52, - ["gText_FreshWater"] = 0x417C0F, - ["gText_FreshWater_200"] = 0x417984, - ["gText_Friend2"] = 0x41EE0D, - ["gText_FriendWantsToTrade"] = 0x41E1C5, - ["gText_From"] = 0x41E234, - ["gText_Frz"] = 0x41791C, - ["gText_GameLinkCable"] = 0x417D90, - ["gText_GamePlayCannotBeContinued"] = 0x418D70, - ["gText_GenderlessSymbol"] = 0x261EC5, - ["gText_GetsAPokeBlockQuestion"] = 0x418C2A, - ["gText_Ghost"] = 0x41D148, - ["gText_GiftSentTo"] = 0x41EB8E, - ["gText_Girl"] = 0x415D97, - ["gText_GiveToAPkmn"] = 0x4183DD, - ["gText_GiveToWhichPokemon"] = 0x41722B, - ["gText_GiveUpTryingToTeachNewMove"] = 0x41E4E2, - ["gText_GoBackToThePreviousMenu"] = 0x416244, - ["gText_GoldCard"] = 0x41DFA0, - ["gText_GoodSaying"] = 0x418AAB, - ["gText_GreenShard"] = 0x417A24, - ["gText_HOFCorrupted"] = 0x416090, - ["gText_HOFDexRating"] = 0x416028, - ["gText_HOFDexSaving"] = 0x416068, - ["gText_HOFNumber"] = 0x4160B4, - ["gText_HT"] = 0x415F98, - ["gText_HallOfFame"] = 0x4178F0, - ["gText_HallOfFameDebut"] = 0x419D1A, - ["gText_HallOfFame_2"] = 0x417BBE, - ["gText_HatchedFromEgg"] = 0x41B6FD, - ["gText_HaventReceivedCardsGift"] = 0x41E823, - ["gText_HaventReceivedGiftOkayToDiscard"] = 0x41ECF9, - ["gText_HealPowder_50"] = 0x4180D0, - ["gText_HelixFossil"] = 0x417D56, - ["gText_Hello"] = 0x41B510, - ["gText_HelpSystemControls_A_Next"] = 0x41DFA5, - ["gText_HelpSystemControls_AorBtoCancel"] = 0x41DFBE, - ["gText_HelpSystemControls_PickOkCancel"] = 0x41DFC9, - ["gText_HelpSystemControls_PickOkEnd"] = 0x41DFAC, - ["gText_HereYouGoThankYou"] = 0x4167E7, - ["gText_Hideout"] = 0x418E43, - ["gText_HighlightRed"] = 0x3FE826, - ["gText_HighlightRed_Left"] = 0x3FE81C, - ["gText_HowManyWouldYouLikeToSell"] = 0x416911, - ["gText_HpUp_1000"] = 0x418152, - ["gText_Huh"] = 0x41B329, - ["gText_ICanPayThisMuch_WouldThatBeOkay"] = 0x416936, - ["gText_IDNumber"] = 0x416104, - ["gText_IDNumber2"] = 0x416233, - ["gText_Ice"] = 0x3FE846, - ["gText_IfLeaderLeavesChatWillEnd"] = 0x41B4D9, - ["gText_IfThrowAwayCardEventWontHappen"] = 0x41EC99, - ["gText_InBagVar1"] = 0x416749, - ["gText_InGameClockUsable"] = 0x419F2D, - ["gText_InUseAlready_PM"] = 0x4173F9, - ["gText_InflictingDamageIsKey"] = 0x3FDC58, - ["gText_Info"] = 0x4178E6, - ["gText_Info4"] = 0x41793F, - ["gText_Info_2"] = 0x41797F, - ["gText_InputText"] = 0x41B39E, - ["gText_Interview"] = 0x418AA1, - ["gText_Iron_1000"] = 0x41810D, - ["gText_IsAsShownOkay"] = 0x418902, - ["gText_IsThisTradeOkay"] = 0x41E0A5, - ["gText_ItGotAway"] = 0x41D17E, - ["gText_Item"] = 0x4161D4, - ["gText_ItemCantBeHeld"] = 0x41635E, - ["gText_ItemEffect_Attack"] = 0x417674, - ["gText_ItemEffect_Defense"] = 0x41767B, - ["gText_ItemEffect_HP"] = 0x4169C2, - ["gText_ItemEffect_HP_Unused"] = 0x417693, - ["gText_ItemEffect_SpAtk"] = 0x4169C5, - ["gText_ItemEffect_SpAtk_Unused"] = 0x417683, - ["gText_ItemEffect_SpDef"] = 0x4169CD, - ["gText_ItemEffect_SpDef_Unused"] = 0x417688, - ["gText_ItemEffect_Speed"] = 0x41768D, - ["gText_ItemIsNowHeld"] = 0x418433, - ["gText_ItemStorage"] = 0x4176F1, - ["gText_ItemfinderResponding"] = 0x416476, - ["gText_ItemfinderShakingWildly"] = 0x4164BE, - ["gText_Items"] = 0x417B11, - ["gText_Items2"] = 0x4162CD, - ["gText_ItemsPocket"] = 0x417DA9, - ["gText_JoinGroup"] = 0x417F77, - ["gText_Jolteon"] = 0x417BF0, - ["gText_JumpToWhichBox"] = 0x41826C, - ["gText_JumpsInARow"] = 0x41CB78, - ["gText_JustOnePkmn"] = 0x418690, - ["gText_Kanto"] = 0x415DD1, - ["gText_KeepAnEyeOnHP"] = 0x3FDCD2, - ["gText_KeyItems"] = 0x417B17, - ["gText_KeyItems2"] = 0x4162DE, - ["gText_KeyItemsPocket"] = 0x417DB6, - ["gText_Koga"] = 0x4178DA, - ["gText_LButton"] = 0x415D05, - ["gText_LButtonRButton"] = 0x415D22, - ["gText_Lbs"] = 0x415FA0, - ["gText_LeaderHasLeftEndingChat"] = 0x41B3FB, - ["gText_LeagueChamp"] = 0x4160C8, - ["gText_Learned"] = 0x41744F, - ["gText_Left"] = 0x417A3D, - ["gText_Lemonade"] = 0x417C24, - ["gText_Lemonade_350"] = 0x4179AD, - ["gText_Lets"] = 0x41B52B, - ["gText_Level"] = 0x4160F4, - ["gText_LevelRoseTo"] = 0x41B295, - ["gText_LevelUp_Attack"] = 0x41B2B7, - ["gText_LevelUp_Defense"] = 0x41B2BE, - ["gText_LevelUp_MaxHP"] = 0x41B2A9, - ["gText_LevelUp_Minus"] = 0x41B2E5, - ["gText_LevelUp_Plus"] = 0x41B2DC, - ["gText_LevelUp_SpAtk"] = 0x41B2CC, - ["gText_LevelUp_SpDef"] = 0x41B2D4, - ["gText_LevelUp_Speed"] = 0x41B2C6, - ["gText_LightUpDarkness"] = 0x417583, - ["gText_LightestMode"] = 0x415EDF, - ["gText_LinkBattles"] = 0x419D2F, - ["gText_LinkCableBattles"] = 0x419D3C, - ["gText_LinkStandby"] = 0x3FE714, - ["gText_LinkStandyBButtonCancel"] = 0x41E76B, - ["gText_LinkedGamePlay"] = 0x41807D, - ["gText_ListenToExplanation"] = 0x417E68, - ["gText_LogOff"] = 0x417BCB, - ["gText_Loss"] = 0x3FE87B, - ["gText_Love"] = 0x3FE854, - ["gText_Lower"] = 0x41B343, - ["gText_LoweringStats"] = 0x3FDC95, - ["gText_LtSurge"] = 0x4178D0, - ["gText_LureWildPokemon"] = 0x4175DE, - ["gText_Lv"] = 0x416223, - ["gText_MachineSet"] = 0x41B31B, - ["gText_Mail"] = 0x4161D9, - ["gText_MailCantBeStored"] = 0x418452, - ["gText_MailMessageWillBeLost"] = 0x416BC3, - ["gText_MailReturnedToBagMessageErased"] = 0x41786B, - ["gText_MailSentToPC"] = 0x416B86, - ["gText_MailTakenFromPkmn"] = 0x416CC7, - ["gText_MailTransferredFromMailbox"] = 0x416D4F, - ["gText_Mailbox"] = 0x4176FE, - ["gText_MainMenuTime"] = 0x415CE8, - ["gText_MakeAChallenge"] = 0x41796D, - ["gText_MakeMessageSixPhrases"] = 0x418706, - ["gText_MaleSymbol"] = 0x41621F, - ["gText_MaleSymbol4"] = 0x261EC1, - ["gText_MarkYourPkmn"] = 0x4182EC, - ["gText_MarshBadge"] = 0x418017, - ["gText_Marshbadge"] = 0x417B7C, - ["gText_MaxTwoTwelveLetterPhrases"] = 0x418725, - ["gText_MenuBag"] = 0x416285, - ["gText_MenuExit"] = 0x41629D, - ["gText_MenuOption"] = 0x416296, - ["gText_MenuPlayer"] = 0x41628E, - ["gText_MenuPokedex"] = 0x41627D, - ["gText_MenuPokemon"] = 0x415A66, - ["gText_MenuPokenav"] = 0x416289, - ["gText_MenuRetire"] = 0x4162A2, - ["gText_MenuSafariStats"] = 0x4162A9, - ["gText_MenuSave"] = 0x416291, - ["gText_MessageWillBeLost"] = 0x417830, - ["gText_MiracleSeed_1000Coins"] = 0x417ED9, - ["gText_MonForgotMove"] = 0x41B306, - ["gText_MonForgotOldMoveAndMonLearnedNewMove"] = 0x41E4C0, - ["gText_MonIsTryingToLearnMove"] = 0x41E414, - ["gText_MonLearnedMove"] = 0x41E405, - ["gText_MonLearnedTMHM"] = 0x41B32E, - ["gText_MoveCloserToLinkPartner"] = 0x41DF05, - ["gText_MoveHeavyBoulders"] = 0x41756E, - ["gText_MoveInterfaceDynamicColors"] = 0x3FE770, - ["gText_MoveInterfacePP"] = 0x3FE766, - ["gText_MoveInterfaceType"] = 0x3FE76A, - ["gText_MoveItems"] = 0x41859A, - ["gText_MoveItemsDescription"] = 0x418642, - ["gText_MoveMonDescription"] = 0x418611, - ["gText_MoveNotLearned"] = 0x416E84, - ["gText_MoveOkBack"] = 0x418E77, - ["gText_MovePokemon"] = 0x41858D, - ["gText_MoveToWhere"] = 0x4171F1, - ["gText_Moves"] = 0x41699C, - ["gText_MovesPPIncreased"] = 0x417002, - ["gText_MultiBattle"] = 0x417960, - ["gText_Multichoice_Empty1"] = 0x417F68, - ["gText_Multichoice_Empty2"] = 0x417F67, - ["gText_MysteryGift"] = 0x415A97, - ["gText_MysteryGift2"] = 0x41EDBD, - ["gText_MysteryGiftBerry"] = 0x488C70, - ["gText_MysteryGiftBerryObtained"] = 0x488CCE, - ["gText_MysteryGiftBerryTransform"] = 0x488CA2, - ["gText_MysteryGiftCantBeUsed"] = 0x488DFD, - ["gText_MysteryGiftCantUse"] = 0x415ACB, - ["gText_MysteryGiftFullParty"] = 0x488D8E, - ["gText_MysteryGiftNationalDex"] = 0x488D2A, - ["gText_MysteryGiftNewAdversaryInBattleTower"] = 0x488DE1, - ["gText_MysteryGiftNewTrainer"] = 0x488DBD, - ["gText_MysteryGiftRareWord"] = 0x488D60, - ["gText_MysteryGiftSentOver"] = 0x488D7C, - ["gText_MysteryGiftSpecialRibbon"] = 0x488CF6, - ["gText_MysticWater_1000Coins"] = 0x417F11, - ["gText_Name"] = 0x4160FC, - ["gText_NamingScreenKeyboard_01234"] = 0x47AA5D, - ["gText_NamingScreenKeyboard_56789"] = 0x47AA76, - ["gText_NamingScreenKeyboard_ABCDEF"] = 0x47A9D9, - ["gText_NamingScreenKeyboard_GHIJKL"] = 0x47A9FA, - ["gText_NamingScreenKeyboard_MNOPQRS"] = 0x47AA1B, - ["gText_NamingScreenKeyboard_Symbols1"] = 0x47AA8F, - ["gText_NamingScreenKeyboard_Symbols2"] = 0x47AAA8, - ["gText_NamingScreenKeyboard_TUVWXYZ"] = 0x47AA3C, - ["gText_NamingScreenKeyboard_abcdef"] = 0x47A955, - ["gText_NamingScreenKeyboard_ghijkl"] = 0x47A976, - ["gText_NamingScreenKeyboard_mnopqrs"] = 0x47A997, - ["gText_NamingScreenKeyboard_tuvwxyz"] = 0x47A9B8, - ["gText_National"] = 0x415DD7, - ["gText_NavelRock"] = 0x417EC1, - ["gText_NeatnessRankings"] = 0x41D0D4, - ["gText_NewGame"] = 0x415A6E, - ["gText_NewStampReceived"] = 0x41E90C, - ["gText_NewTrainerReceived"] = 0x41E92B, - ["gText_Next"] = 0x415FC8, - ["gText_NextDataCancel"] = 0x415FB3, - ["gText_NickHatchPrompt"] = 0x41B716, - ["gText_No"] = 0x41793C, - ["gText_NoEntry"] = 0x4169A9, - ["gText_NoIdenticalHoldItems"] = 0x41734D, - ["gText_NoMoreRoomForStamps"] = 0x41E9AB, - ["gText_NoMoreRoomForThis"] = 0x416861, - ["gText_NoMoreRoomInBag"] = 0x41778A, - ["gText_NoMoreThanThreeMayEnter"] = 0x416B16, - ["gText_NoMoreThanTwoMayEnter"] = 0x416B3E, - ["gText_NoPokemonForBattle"] = 0x4172EB, - ["gText_NoRoomToStoreItems"] = 0x4163BB, - ["gText_NoSaveFileCantSetTime"] = 0x419F10, - ["gText_NoThanks"] = 0x417C4B, - ["gText_NoThanks_2"] = 0x417D4C, - ["gText_NoUse"] = 0x417411, - ["gText_NopeTheresNoResponse"] = 0x416513, - ["gText_Normal"] = 0x417ADC, - ["gText_NotAble"] = 0x417436, - ["gText_NotAble2"] = 0x417445, - ["gText_NotEnoughHp"] = 0x417313, - ["gText_NotEvenANibble"] = 0x41D169, - ["gText_NotPkmnOtherTrainerWants"] = 0x41718C, - ["gText_NotSoBad"] = 0x4179F7, - ["gText_NotYet"] = 0x417933, - ["gText_NothingChanged"] = 0x418C62, - ["gText_NothingSentOver"] = 0x41EA6F, - ["gText_NothingToCut"] = 0x417258, - ["gText_NowOn"] = 0x418075, - ["gText_NumPlayerLink"] = 0x41DF82, - ["gText_Number"] = 0x4160EC, - ["gText_NumberClear01"] = 0x416226, - ["gText_NumericalMode"] = 0x415E95, - ["gText_NumericalModeKanto"] = 0x415F0E, - ["gText_NumericalModeNational"] = 0x415F24, - ["gText_OK"] = 0x417926, - ["gText_OakForbidsUseOfItemHere"] = 0x416425, - ["gText_OakImportantToGetToKnowPokemonThroughly"] = 0x417457, - ["gText_OakNoRunningFromATrainer"] = 0x3FDD23, - ["gText_OakThisIsListOfPokemon"] = 0x417494, - ["gText_OhNoICantBuyThat"] = 0x4168F1, - ["gText_Ok"] = 0x41B531, - ["gText_OkayToDiscardNews"] = 0x41ECD3, - ["gText_OldAmber"] = 0x417D6F, - ["gText_OneIsland"] = 0x417DF3, - ["gText_OnlyOnePhrase"] = 0x418998, - ["gText_OnlyPkmnForBattle"] = 0x417094, - ["gText_Option"] = 0x419DCC, - ["gText_OptionMenuCancel"] = 0x419E10, - ["gText_OriginalSongWillBeUsed"] = 0x4189AC, - ["gText_Other"] = 0x417DED, - ["gText_OtherTrainerCanceled"] = 0x41EC40, - ["gText_OtherTrainerCantAcceptPkmn"] = 0x417130, - ["gText_OtherTrainerHasCard"] = 0x41EBAA, - ["gText_OtherTrainerHasNews"] = 0x41EBDE, - ["gText_OtherTrainerHasStamp"] = 0x41EC12, - ["gText_OtherTrainersPkmnCantBeTraded"] = 0x4170FC, - ["gText_Others"] = 0x41B349, - ["gText_Owned"] = 0x415DCA, - ["gText_PCMailboxFull"] = 0x416BA6, - ["gText_PC_WhatWouldYouLikeToDo"] = 0x418562, - ["gText_PPWasRestored"] = 0x416FC7, - ["gText_Page"] = 0x416002, - ["gText_Par"] = 0x417910, - ["gText_Paralysis"] = 0x3FE83C, - ["gText_Participate"] = 0x4178DF, - ["gText_PartyFull"] = 0x4186B0, - ["gText_PartyMenu_OK"] = 0x4169D5, - ["gText_PauseUntilPress"] = 0x4169D9, - ["gText_PeopleBattling"] = 0x41E273, - ["gText_PeopleCommunicating"] = 0x41E29E, - ["gText_PeopleInUnionRoom"] = 0x41E284, - ["gText_PeopleTrading"] = 0x41E263, - ["gText_Period"] = 0x41B293, - ["gText_Period2"] = 0x41B2A7, - ["gText_PickFlipPageCheckCancel"] = 0x415F6C, - ["gText_PickOK"] = 0x415DB8, - ["gText_PickOKCancel"] = 0x41EDCA, - ["gText_PickOKExit"] = 0x415F51, - ["gText_PickOk"] = 0x415D38, - ["gText_PickSwitchCancel"] = 0x419E57, - ["gText_PickTheWallpaper"] = 0x418248, - ["gText_Pink"] = 0x417AE9, - ["gText_Pinsir_2500Coins"] = 0x417CFF, - ["gText_PkmnAlreadyHoldingItemSwitch"] = 0x416C49, - ["gText_PkmnAlreadyInBattle"] = 0x416A3A, - ["gText_PkmnAlreadyKnows"] = 0x416F10, - ["gText_PkmnAlreadySelected"] = 0x416A55, - ["gText_PkmnBaseVar2StatIncreased"] = 0x417032, - ["gText_PkmnBecameHealthy"] = 0x416FED, - ["gText_PkmnBurnHealed"] = 0x416F9A, - ["gText_PkmnCameBack"] = 0x418392, - ["gText_PkmnCantBeTradedNow"] = 0x4170BC, - ["gText_PkmnCantLearnMove"] = 0x416DC2, - ["gText_PkmnCantParticipate"] = 0x417696, - ["gText_PkmnCantSwitchOut"] = 0x416A1E, - ["gText_PkmnCuredOfParalysis"] = 0x416F6F, - ["gText_PkmnCuredOfPoison"] = 0x416F4E, - ["gText_PkmnElevatedToLvVar2"] = 0x417017, - ["gText_PkmnFainted3"] = 0x1A5476, - ["gText_PkmnGotOverInfatuation"] = 0x417075, - ["gText_PkmnHPRestoredByVar2"] = 0x416F27, - ["gText_PkmnHasNoEnergy"] = 0x416A75, - ["gText_PkmnHoldingItemCantHoldMail"] = 0x416D17, - ["gText_PkmnIsEvolving"] = 0x3FE672, - ["gText_PkmnIsSelected"] = 0x41825C, - ["gText_PkmnJumpRecords"] = 0x41CB63, - ["gText_PkmnLearnedMove3"] = 0x416DB3, - ["gText_PkmnNeedsToReplaceMove"] = 0x416DF7, - ["gText_PkmnNotHolding"] = 0x416C8F, - ["gText_PkmnRegainhedHealth"] = 0x416FDA, - ["gText_PkmnSnappedOutOfConfusion"] = 0x417052, - ["gText_PkmnStoppedEvolving"] = 0x3FE6B5, - ["gText_PkmnThawedOut"] = 0x416FB2, - ["gText_PkmnWasDeposited"] = 0x418295, - ["gText_PkmnWasGivenItem"] = 0x416C2A, - ["gText_PkmnWasReleased"] = 0x4182CE, - ["gText_PkmnWokeUp2"] = 0x416F8C, - ["gText_PkmnsNickname"] = 0x418E5C, - ["gText_PlacedItemInBag"] = 0x4183F0, - ["gText_PlayedPokeFlute"] = 0x416690, - ["gText_PlayedPokeFluteCatchy"] = 0x41665C, - ["gText_Player"] = 0x41B6D5, - ["gText_PlayerSavedTheGame"] = 0x1C5647, - ["gText_PlayerScurriedBackHome"] = 0x41B5B6, - ["gText_PlayerScurriedToCenter"] = 0x41B554, - ["gText_PlayerUnused"] = 0x415CD9, - ["gText_PlayerUsedVar2"] = 0x41658C, - ["gText_PlayersXPokemon"] = 0x41B3D0, - ["gText_PlayersXPokmonDoesNotExist"] = 0x41B3DA, - ["gText_PleaseEnterPhraseOrWord"] = 0x418970, - ["gText_PleasePickATheme"] = 0x418233, - ["gText_PleaseRemoveTheMail"] = 0x4183C5, - ["gText_PleaseResetTime"] = 0x419EB0, - ["gText_Plus"] = 0x41622C, - ["gText_Points"] = 0x419CFA, - ["gText_Poison"] = 0x3FE830, - ["gText_PokeBalls"] = 0x417B21, - ["gText_PokeBalls2"] = 0x4162D3, - ["gText_PokeBallsPocket"] = 0x417DC7, - ["gText_PokeFluteAwakenedMon"] = 0x4166A7, - ["gText_PokeSum_ATrade"] = 0x419C0B, - ["gText_PokeSum_ApparentlyFatefulEncounterHatched"] = 0x4199AB, - ["gText_PokeSum_ApparentlyFatefulEncounterHatched_BoldGentleGrammar"] = 0x4199F4, - ["gText_PokeSum_ApparentlyMet"] = 0x419860, - ["gText_PokeSum_ApparentlyMet_BoldGentleGrammar"] = 0x41988A, - ["gText_PokeSum_Controls_Cancel"] = 0x419C72, - ["gText_PokeSum_Controls_Page"] = 0x419C7B, - ["gText_PokeSum_Controls_PageCancel"] = 0x419C62, - ["gText_PokeSum_Controls_PageDetail"] = 0x419C82, - ["gText_PokeSum_Controls_Pick"] = 0x419CA2, - ["gText_PokeSum_Controls_PickDelete"] = 0x419CA9, - ["gText_PokeSum_Controls_PickSwitch"] = 0x419C92, - ["gText_PokeSum_DexNoUnknown"] = 0x416202, - ["gText_PokeSum_EggHatch_AlmostReady"] = 0x419BDB, - ["gText_PokeSum_EggHatch_LongTime"] = 0x419B44, - ["gText_PokeSum_EggHatch_SomeTime"] = 0x419B7B, - ["gText_PokeSum_EggHatch_Soon"] = 0x419BAE, - ["gText_PokeSum_EggOrigin_DayCare"] = 0x419A3D, - ["gText_PokeSum_EggOrigin_NicePlace"] = 0x419AA2, - ["gText_PokeSum_EggOrigin_Spa"] = 0x419ADE, - ["gText_PokeSum_EggOrigin_Trade"] = 0x419B18, - ["gText_PokeSum_EggOrigin_TravelingMan"] = 0x419A6E, - ["gText_PokeSum_ExpPoints"] = 0x419C4D, - ["gText_PokeSum_FatefulEncounterHatched"] = 0x41992F, - ["gText_PokeSum_FatefulEncounterHatched_BoldGentleGrammar"] = 0x41996D, - ["gText_PokeSum_FatefulEncounterMet"] = 0x4197B8, - ["gText_PokeSum_FatefulEncounterMet_BoldGentleGrammar"] = 0x4197ED, - ["gText_PokeSum_Hatched"] = 0x4198B4, - ["gText_PokeSum_Hatched_BoldGentleGrammar"] = 0x4198D5, - ["gText_PokeSum_HmMovesCantBeForgotten"] = 0x419CB9, - ["gText_PokeSum_Item_None"] = 0x4161EF, - ["gText_PokeSum_JP_ApparentlyHatched"] = 0x4198F6, - ["gText_PokeSum_JP_ApparentlyHatched_BoldGentleGrammar"] = 0x419913, - ["gText_PokeSum_Met"] = 0x419822, - ["gText_PokeSum_MetInATrade"] = 0x419782, - ["gText_PokeSum_MetInATrade_BoldGentleGrammar"] = 0x41979D, - ["gText_PokeSum_Met_BoldGentleGrammar"] = 0x419841, - ["gText_PokeSum_NextLv"] = 0x419C59, - ["gText_PokeSum_NoData"] = 0x419C45, - ["gText_PokeSum_OneHyphen"] = 0x41620E, - ["gText_PokeSum_PP"] = 0x416238, - ["gText_PokeSum_PageName_KnownMoves"] = 0x419C39, - ["gText_PokeSum_PageName_PokemonInfo"] = 0x419C1D, - ["gText_PokeSum_PageName_PokemonSkills"] = 0x419C2A, - ["gText_PokeSum_TwoHyphens"] = 0x416210, - ["gText_Pokedex"] = 0x41B6E3, - ["gText_PokedexNo"] = 0x415FFF, - ["gText_PokedexOther"] = 0x415EB0, - ["gText_PokedexPokemon"] = 0x415F8F, - ["gText_PokedexQuotationMark"] = 0x415F9E, - ["gText_PokedexTableOfContents"] = 0x415D9C, - ["gText_PokedexUnused"] = 0x415CE0, - ["gText_PokedollarVar1"] = 0x41697A, - ["gText_Pokemon2"] = 0x41B516, - ["gText_Pokemon4"] = 0x416146, - ["gText_PokemonCantBeSame"] = 0x417337, - ["gText_PokemonCenter"] = 0x418C1B, - ["gText_PokemonHabitats"] = 0x415DE0, - ["gText_PokemonJump"] = 0x417E72, - ["gText_PokemonLeague"] = 0x418C12, - ["gText_PokemonList"] = 0x415EFB, - ["gText_PokemonListNoColor"] = 0x415F3D, - ["gText_PokemonOnHook"] = 0x41D14E, - ["gText_PokemonTrades"] = 0x419D57, - ["gText_Poof"] = 0x41B2FF, - ["gText_Porygon_6500Coins"] = 0x417D32, - ["gText_Porygon_9999Coins"] = 0x417CB7, - ["gText_PowderQty"] = 0x416644, - ["gText_PpUp_3000"] = 0x418163, - ["gText_PresentTime"] = 0x419E90, - ["gText_PressesRankings"] = 0x41D0A8, - ["gText_PressingPowerRankings"] = 0x41D100, - ["gText_PressingSpeed"] = 0x41D058, - ["gText_PressingSpeedRankings"] = 0x41D124, - ["gText_PreviousTime"] = 0x419EA0, - ["gText_ProfOakSPc"] = 0x417BD3, - ["gText_Profile"] = 0x418A2B, - ["gText_Protein_1000"] = 0x4180FA, - ["gText_Psn"] = 0x41790C, - ["gText_PutItemInBag"] = 0x418419, - ["gText_QuestLog_ADifferentSpot"] = 0x41A694, - ["gText_QuestLog_ArrivedInLocation"] = 0x41B064, - ["gText_QuestLog_Barely"] = 0x41AF9F, - ["gText_QuestLog_BattledTrainerEndedInOutcome"] = 0x41A7B0, - ["gText_QuestLog_BikeShop"] = 0x41B0FF, - ["gText_QuestLog_BillsHouse"] = 0x41B109, - ["gText_QuestLog_BoughtItem"] = 0x41A7DD, - ["gText_QuestLog_BoughtItemsIncludingItem"] = 0x41A810, - ["gText_QuestLog_CaughtWildMon"] = 0x41AA01, - ["gText_QuestLog_CaughtWildMons"] = 0x41AA2B, - ["gText_QuestLog_CeladonDeptStore"] = 0x41B1A3, - ["gText_QuestLog_CeladonMansion"] = 0x41B1B7, - ["gText_QuestLog_CeruleanCave"] = 0x41B277, - ["gText_QuestLog_ChattedWithManyTrainers"] = 0x41A732, - ["gText_QuestLog_Coolly"] = 0x41AF98, - ["gText_QuestLog_DayCare"] = 0x41B116, - ["gText_QuestLog_DefeatedWildMon"] = 0x41A9A9, - ["gText_QuestLog_DefeatedWildMonAndCaughtWildMon"] = 0x41AA76, - ["gText_QuestLog_DefeatedWildMonAndCaughtWildMons"] = 0x41AAAA, - ["gText_QuestLog_DefeatedWildMons"] = 0x41A9D4, - ["gText_QuestLog_DefeatedWildMonsAndCaughtWildMon"] = 0x41AAEC, - ["gText_QuestLog_DefeatedWildMonsAndCaughtWildMons"] = 0x41AB29, - ["gText_QuestLog_DepartedFromLocationToNextDestination"] = 0x41B005, - ["gText_QuestLog_DepartedGym"] = 0x41AE8F, - ["gText_QuestLog_DepartedPlaceInTownForNextDestination"] = 0x41A53A, - ["gText_QuestLog_DepartedTheLocationForNextDestination"] = 0x41AFD6, - ["gText_QuestLog_DepositedMonInPC"] = 0x41A64F, - ["gText_QuestLog_DiglettsCave"] = 0x41B14B, - ["gText_QuestLog_DoubleBattleWithPersonResultedInOutcome"] = 0x41A477, - ["gText_QuestLog_Draw"] = 0x41AFD1, - ["gText_QuestLog_FightingDojo"] = 0x41B20E, - ["gText_QuestLog_GaveMonHeldItem"] = 0x41AB74, - ["gText_QuestLog_GaveMonHeldItem2"] = 0x41AB8E, - ["gText_QuestLog_GaveMonHeldItemFromPC"] = 0x41A6A5, - ["gText_QuestLog_Gym"] = 0x41B0B5, - ["gText_QuestLog_GymWasFullOfToughTrainers"] = 0x41AE48, - ["gText_QuestLog_HadGreatTimeInSafariZone"] = 0x41AEA7, - ["gText_QuestLog_Handily"] = 0x41A74E, - ["gText_QuestLog_Home"] = 0x41B09F, - ["gText_QuestLog_JustOne"] = 0x41A8D4, - ["gText_QuestLog_LeftOaksLab"] = 0x41AE1E, - ["gText_QuestLog_LeftTownsLocationForNextDestination"] = 0x41AD9E, - ["gText_QuestLog_Loss"] = 0x41A506, - ["gText_QuestLog_ManagedToGetOutOfLocation"] = 0x41AEDC, - ["gText_QuestLog_MingledInUnionRoom"] = 0x41A50B, - ["gText_QuestLog_MonLearnedMoveFromHM"] = 0x41A938, - ["gText_QuestLog_MonLearnedMoveFromTM"] = 0x41A255, - ["gText_QuestLog_MonReplacedMoveWithHM"] = 0x41A965, - ["gText_QuestLog_MonReplacedMoveWithTM"] = 0x41A277, - ["gText_QuestLog_MonsWereFullyRestoredAtCenter"] = 0x41A2B0, - ["gText_QuestLog_MovedMonToNewBox"] = 0x41A59C, - ["gText_QuestLog_MovedMonWithinBox"] = 0x41A5FA, - ["gText_QuestLog_MtMoon"] = 0x41B0F6, - ["gText_QuestLog_MultiBattleWithPeopleResultedInOutcome"] = 0x41A4C6, - ["gText_QuestLog_NameRatersHouse"] = 0x41B190, - ["gText_QuestLog_Num"] = 0x41A8DD, - ["gText_QuestLog_OakResearchLab"] = 0x41B0A4, - ["gText_QuestLog_ObtainedItemInLocation"] = 0x41B03F, - ["gText_QuestLog_PewterMuseumOfScience"] = 0x41B0DD, - ["gText_QuestLog_PlayedGamesAtGameCorner"] = 0x41ADC8, - ["gText_QuestLog_PlayerBattledChampionRival"] = 0x41A2E1, - ["gText_QuestLog_PlayerSentOutMon1RivalSentOutMon2"] = 0x41A312, - ["gText_QuestLog_PokemonFanClub"] = 0x41B130, - ["gText_QuestLog_PokemonLeague"] = 0x41B268, - ["gText_QuestLog_PokemonLeagueGate"] = 0x41B0B9, - ["gText_QuestLog_PokemonMansion"] = 0x41B236, - ["gText_QuestLog_PokemonResearchLab"] = 0x41B246, - ["gText_QuestLog_PokemonTower"] = 0x41B172, - ["gText_QuestLog_PowerPlant"] = 0x41B166, - ["gText_QuestLog_PreviouslyOnYourQuest"] = 0x41A155, - ["gText_QuestLog_Restaurant"] = 0x41B1DA, - ["gText_QuestLog_RestedAtHome"] = 0x41ADFF, - ["gText_QuestLog_RockTunnel"] = 0x41B15A, - ["gText_QuestLog_RocketGameCorner"] = 0x41B1C7, - ["gText_QuestLog_RocketHideout"] = 0x41B1E5, - ["gText_QuestLog_SSAnne"] = 0x41B141, - ["gText_QuestLog_SafariZone"] = 0x41B1F4, - ["gText_QuestLog_SavedGameAtLocation"] = 0x41B073, - ["gText_QuestLog_SeafoamIslands"] = 0x41B226, - ["gText_QuestLog_SilphCo"] = 0x41B21C, - ["gText_QuestLog_SingleBattleWithPersonResultedInOutcome"] = 0x41A422, - ["gText_QuestLog_SoldItemsIncludingItem"] = 0x41A896, - ["gText_QuestLog_SoldNumOfItem"] = 0x41A858, - ["gText_QuestLog_Somehow"] = 0x41A762, - ["gText_QuestLog_StoredItemInPC"] = 0x41A391, - ["gText_QuestLog_SwappedHeldItemFromPC"] = 0x41A6E1, - ["gText_QuestLog_SwappedHeldItemsOnMon"] = 0x41A193, - ["gText_QuestLog_SwitchMon1WithMon2"] = 0x41A16F, - ["gText_QuestLog_SwitchedMonsBetweenBoxes"] = 0x41A566, - ["gText_QuestLog_SwitchedMonsWithinBox"] = 0x41A5D9, - ["gText_QuestLog_SwitchedMultipleMons"] = 0x41A66E, - ["gText_QuestLog_SwitchedPartyMonForPCMon"] = 0x41A60A, - ["gText_QuestLog_Tenaciously"] = 0x41A756, - ["gText_QuestLog_TookHeldItemFromMon"] = 0x41A1CD, - ["gText_QuestLog_TookOnEliteFoursMonWithMonAndWon"] = 0x41AF3E, - ["gText_QuestLog_TookOnGymLeadersMonWithMonAndWon"] = 0x41AF0C, - ["gText_QuestLog_TookOnTrainersMonWithMonAndWon"] = 0x41AF6D, - ["gText_QuestLog_TradedMon1ForPersonsMon2"] = 0x41A3FF, - ["gText_QuestLog_TradedMon1ForTrainersMon2"] = 0x41A76A, - ["gText_QuestLog_UndergroundPath"] = 0x41B11F, - ["gText_QuestLog_UsedCut"] = 0x41ABAB, - ["gText_QuestLog_UsedDigInLocation"] = 0x41AD1D, - ["gText_QuestLog_UsedDive"] = 0x41ACF9, - ["gText_QuestLog_UsedEscapeRope"] = 0x41AFA6, - ["gText_QuestLog_UsedFlash"] = 0x41AC51, - ["gText_QuestLog_UsedFly"] = 0x41ABCD, - ["gText_QuestLog_UsedItemOnMonAtThisLocation"] = 0x41A1E7, - ["gText_QuestLog_UsedMilkDrink"] = 0x41A90C, - ["gText_QuestLog_UsedRockSmash"] = 0x41AC93, - ["gText_QuestLog_UsedSoftboiled"] = 0x41A8E0, - ["gText_QuestLog_UsedStrength"] = 0x41AC2A, - ["gText_QuestLog_UsedSurf"] = 0x41ABF9, - ["gText_QuestLog_UsedSweetScent"] = 0x41AD3C, - ["gText_QuestLog_UsedTeleportToLocation"] = 0x41AD69, - ["gText_QuestLog_UsedTheItem"] = 0x41A210, - ["gText_QuestLog_UsedTheKeyItem"] = 0x41A220, - ["gText_QuestLog_UsedWaterfall"] = 0x41ACBC, - ["gText_QuestLog_VictoryRoad"] = 0x41B25B, - ["gText_QuestLog_ViridianForest"] = 0x41B0CD, - ["gText_QuestLog_VolunteerHouse"] = 0x41B180, - ["gText_QuestLog_WardensHome"] = 0x41B200, - ["gText_QuestLog_Win"] = 0x41A502, - ["gText_QuestLog_WithdrewItemFromPC"] = 0x41A3DA, - ["gText_QuestLog_WithdrewMonFromPC"] = 0x41A632, - ["gText_QuestLog_WonTheMatchAsAResult"] = 0x41A349, - ["gText_Questionnaire"] = 0x418AB2, - ["gText_Quit"] = 0x417907, - ["gText_QuitChatting"] = 0x41B366, - ["gText_QuitEditing"] = 0x4189E0, - ["gText_QuitLooking"] = 0x417C01, - ["gText_QuitShopping"] = 0x416757, - ["gText_RButton"] = 0x415D00, - ["gText_RainbowBadge"] = 0x418000, - ["gText_Rainbowbadge"] = 0x417B65, - ["gText_Read2"] = 0x4169B7, - ["gText_ReadNewsThatArrived"] = 0x41E669, - ["gText_Receive"] = 0x41EE19, - ["gText_ReceivedItemFromPkmn"] = 0x416CAC, - ["gText_RecordCorner"] = 0x417E71, - ["gText_RecordUploadedViaWireless"] = 0x41E9D3, - ["gText_RedShard"] = 0x417A02, - ["gText_RefusedBattle"] = 0x41E58D, - ["gText_RegionMap_AButtonCancel"] = 0x418E95, - ["gText_RegionMap_AButtonCancel2"] = 0x418E9E, - ["gText_RegionMap_AButtonGuide"] = 0x418E8D, - ["gText_RegionMap_AButtonOK"] = 0x418EB0, - ["gText_RegionMap_AButtonSwitch"] = 0x418EA7, - ["gText_RegionMap_AreaDesc_AlteringCave"] = 0x419629, - ["gText_RegionMap_AreaDesc_BerryForest"] = 0x419444, - ["gText_RegionMap_AreaDesc_CeruleanCave"] = 0x4192E2, - ["gText_RegionMap_AreaDesc_DiglettsCave"] = 0x418FB8, - ["gText_RegionMap_AreaDesc_DottedHole"] = 0x41971A, - ["gText_RegionMap_AreaDesc_IcefallCave"] = 0x4194C9, - ["gText_RegionMap_AreaDesc_LostCave"] = 0x41953F, - ["gText_RegionMap_AreaDesc_MtEmber"] = 0x4193D5, - ["gText_RegionMap_AreaDesc_MtMoon"] = 0x418F3A, - ["gText_RegionMap_AreaDesc_PatternBush"] = 0x41969D, - ["gText_RegionMap_AreaDesc_PokemonMansion"] = 0x41908E, - ["gText_RegionMap_AreaDesc_PokemonTower"] = 0x41926D, - ["gText_RegionMap_AreaDesc_PowerPlant"] = 0x41935B, - ["gText_RegionMap_AreaDesc_RockTunnel"] = 0x41917D, - ["gText_RegionMap_AreaDesc_SafariZone"] = 0x419103, - ["gText_RegionMap_AreaDesc_SeafoamIslands"] = 0x419201, - ["gText_RegionMap_AreaDesc_TanobyRuins"] = 0x4195AC, - ["gText_RegionMap_AreaDesc_VictoryRoad"] = 0x41901C, - ["gText_RegionMap_AreaDesc_ViridianForest"] = 0x418ECB, - ["gText_RegionMap_DPadMove"] = 0x418EB5, - ["gText_RegionMap_NoData"] = 0x418EC3, - ["gText_RegionMap_Space"] = 0x418E8B, - ["gText_RegionMap_UpDownPick"] = 0x418EBC, - ["gText_Register"] = 0x41B6F4, - ["gText_Register2"] = 0x41B358, - ["gText_RegisterTextHere"] = 0x41B38A, - ["gText_RegisterTextWhere"] = 0x41B375, - ["gText_RegisteredTextChanged_AlreadySavedFile"] = 0x41B469, - ["gText_RegisteredTextChanged_OKtoSave"] = 0x41B426, - ["gText_RegisteredTextChanged_SavedTheGame"] = 0x41B4C6, - ["gText_RegisteredTextChanged_SavingDontTurnOff"] = 0x41B4A4, - ["gText_ReleaseThisPokemon"] = 0x4182B8, - ["gText_RemoveMailBeforeItem"] = 0x416BFB, - ["gText_RepelEffectsLingered"] = 0x41659E, - ["gText_ResetRTCConfirmCancel"] = 0x419E72, - ["gText_RestoreWhichMove"] = 0x4172AE, - ["gText_ReturnToAHealingSpot"] = 0x4175FB, - ["gText_ReturnToHealingSpot"] = 0x417640, - ["gText_ReturnToPC"] = 0x4178BE, - ["gText_ReturnToTitle"] = 0x41E685, - ["gText_RevivalHerb_300"] = 0x4180E4, - ["gText_Right"] = 0x417A37, - ["gText_RivalsName"] = 0x418E69, - ["gText_Rooftop"] = 0x41806D, - ["gText_Rooftop2"] = 0x41D18D, - ["gText_SPc"] = 0x417BB6, - ["gText_SafariBalls"] = 0x3FE80C, - ["gText_SafariZoneMenu"] = 0x3FE747, - ["gText_SaveCompleteGameCannotContinue"] = 0x418DC9, - ["gText_SaveCompletePressA"] = 0x418E09, - ["gText_SaveCompleted"] = 0x419EEA, - ["gText_SaveCompletedPressA"] = 0x41ED50, - ["gText_SaveError_PleaseExchangeBackupMemory"] = 0x41614B, - ["gText_SaveFailed"] = 0x419EF9, - ["gText_SaveFailed2"] = 0x1C565A, - ["gText_SaveFailedCheckingBackup"] = 0x418C83, - ["gText_SaveFileCorrupted"] = 0x415BFF, - ["gText_SaveFileHasBeenDeleted"] = 0x415C42, - ["gText_SaveFileUpdated"] = 0x415B33, - ["gText_SaveFileUpdatedSwapBackup"] = 0x415B44, - ["gText_SavingDontTurnOffPower"] = 0x41CC42, - ["gText_SavingDontTurnOffPower2"] = 0x41CC7B, - ["gText_SavingDontTurnOffThePower"] = 0x1C5625, - ["gText_SavingDontTurnOffThePower2"] = 0x419F54, - ["gText_SawIt"] = 0x41792E, - ["gText_Scyther_5500Coins"] = 0x417C9D, - ["gText_Search"] = 0x415E88, - ["gText_SearchNoColor"] = 0x415F4A, - ["gText_Second_PM"] = 0x417424, - ["gText_SecretBase"] = 0x418E3D, - ["gText_SeeYa"] = 0x4185A5, - ["gText_SeeYaDescription"] = 0x418681, - ["gText_Seen"] = 0x415DC4, - ["gText_Select"] = 0x415D12, - ["gText_SelectorArrow2"] = 0x41623B, - ["gText_Send"] = 0x41EE21, - ["gText_SendMailToPC"] = 0x416B64, - ["gText_SendOut"] = 0x416984, - ["gText_SendingWonderCard"] = 0x41EAE7, - ["gText_SendingWonderNews"] = 0x41EB01, - ["gText_Sepia"] = 0x417AEE, - ["gText_SevenHyphens"] = 0x416217, - ["gText_SevenIsland"] = 0x417E39, - ["gText_SeviiIslands"] = 0x417EB3, - ["gText_ShareHp"] = 0x4175F1, - ["gText_ShatterACrackedRock"] = 0x417596, - ["gText_Shift"] = 0x41697E, - ["gText_ShopBuy"] = 0x416738, - ["gText_ShopQuit"] = 0x416741, - ["gText_ShopSell"] = 0x41673C, - ["gText_Silkiness"] = 0x41D068, - ["gText_SilverCard"] = 0x41DF99, - ["gText_SingleBattle"] = 0x417944, - ["gText_SixIsland"] = 0x417E2E, - ["gText_Size"] = 0x415FED, - ["gText_Slash"] = 0x41620C, - ["gText_Sleep"] = 0x3FE82A, - ["gText_Slp"] = 0x417914, - ["gText_SmallestMode"] = 0x415EED, - ["gText_Smart"] = 0x417AD0, - ["gText_Smart_2"] = 0x417B05, - ["gText_Smartness"] = 0x418C4A, - ["gText_SmokeBall_800Coins"] = 0x417F4D, - ["gText_SodaPop"] = 0x417C1B, - ["gText_SodaPop_300"] = 0x41799A, - ["gText_SomeoneDroppedOut"] = 0x41CDEB, - ["gText_SomeoneDroppedOut2"] = 0x41CC11, - ["gText_SomeoneSPc"] = 0x417B9F, - ["gText_Somewhere"] = 0x419C13, - ["gText_Son"] = 0x417FCC, - ["gText_Sorry"] = 0x41B535, - ["gText_SoulBadge"] = 0x41800D, - ["gText_Soulbadge"] = 0x417B72, - ["gText_Sound"] = 0x419DF8, - ["gText_SoundMono"] = 0x419E36, - ["gText_SoundStereo"] = 0x419E3B, - ["gText_SpaceForVar1Full"] = 0x416888, - ["gText_SpaceMin"] = 0x41D078, - ["gText_SpacePoints"] = 0x41CE1C, - ["gText_SpacePoints2"] = 0x41CB52, - ["gText_SpaceSec"] = 0x41D088, - ["gText_SpaceSeconds"] = 0x417FB2, - ["gText_SpaceTimes"] = 0x417FB6, - ["gText_SpaceTimes2"] = 0x41D034, - ["gText_SpaceTimes3"] = 0x41CB5A, - ["gText_StampSentTo"] = 0x41EB71, - ["gText_Start"] = 0x415D0A, - ["gText_StopGivingPkmnMail"] = 0x4189EE, - ["gText_StopLearningMove"] = 0x41E481, - ["gText_StopLearningMove2"] = 0x416E6B, - ["gText_Store"] = 0x4161E3, - ["gText_StoreItemsInThePC"] = 0x41772A, - ["gText_StrVar1"] = 0x41D074, - ["gText_StrVar1Berry"] = 0x41D044, - ["gText_Summary5"] = 0x416994, - ["gText_Switch2"] = 0x41698D, - ["gText_SwitchedPkmnItem"] = 0x416CEA, - ["gText_Symbols"] = 0x41B350, - ["gText_TMCase"] = 0x4166D3, - ["gText_TMCaseWillBePutAway"] = 0x4166E1, - ["gText_TMHMContainedVar1"] = 0x416565, - ["gText_TMsAndHMs"] = 0x417B2C, - ["gText_Take"] = 0x4161DE, - ["gText_Take2"] = 0x4169B2, - ["gText_TakeGoodCareOfX"] = 0x41E21E, - ["gText_TakeOutItemsFromThePC"] = 0x417741, - ["gText_TeachMoveQues"] = 0x41E3FB, - ["gText_TeachWhichMoveToMon"] = 0x41E3E3, - ["gText_TeachWhichPokemon"] = 0x417200, - ["gText_TeachyTV"] = 0x41B798, - ["gText_TenDashes"] = 0x41E52D, - ["gText_TextSpeed"] = 0x419DD3, - ["gText_TextSpeedFast"] = 0x419E20, - ["gText_TextSpeedMid"] = 0x419E1C, - ["gText_TextSpeedSlow"] = 0x419E17, - ["gText_ThankYou"] = 0x41B541, - ["gText_ThankYouIllSendItHome"] = 0x416800, - ["gText_ThanksIllSendItHome"] = 0x416822, - ["gText_ThatIsntAnEgg"] = 0x4171CC, - ["gText_ThatsTrendyAlready"] = 0x4189BB, - ["gText_ThatsYourLastPkmn"] = 0x4182FF, - ["gText_TheAnswer"] = 0x418881, - ["gText_TheBardsSong"] = 0x418A77, - ["gText_TheBerryPouchWillBePutAway"] = 0x416716, - ["gText_TheEditedWords"] = 0x418A0C, - ["gText_TheMailMessage"] = 0x41888C, - ["gText_TheMailSalutation"] = 0x41889D, - ["gText_TheNewSong"] = 0x4188AC, - ["gText_TheStrVar1CantBeHeldHere"] = 0x416374, - ["gText_TheTrainerThat"] = 0x3FDB92, - ["gText_TheTrendySaying"] = 0x4188F0, - ["gText_ThereAreNoItems"] = 0x417774, - ["gText_ThereIsNoPokemon"] = 0x41632A, - ["gText_TheresNoMailHere"] = 0x4177EE, - ["gText_Third_PM"] = 0x41742B, - ["gText_ThreeHyphens"] = 0x416213, - ["gText_ThreeIsland"] = 0x417E09, - ["gText_ThreePkmnAreNeeded"] = 0x417322, - ["gText_ThreeQuestionMarks"] = 0x41E093, - ["gText_ThrewAwayStrVar2StrVar1s"] = 0x4163F4, - ["gText_ThrowAwayItemsInPC"] = 0x41775D, - ["gText_ThrowAwayStrVar2OfThisItemQM"] = 0x416409, - ["gText_ThrowAwayWonderCard"] = 0x41E7F2, - ["gText_ThrowInPremierBall"] = 0x4168D7, - ["gText_ThunderBadge"] = 0x417FF3, - ["gText_Thunderbadge"] = 0x417B58, - ["gText_Time"] = 0x41B6DC, - ["gText_TimeBoard"] = 0x3FE982, - ["gText_TimeColon"] = 0x41D050, - ["gText_TimesPerSec"] = 0x41D098, - ["gText_TimesStrVar1"] = 0x4162B9, - ["gText_Tm13_4000Coins"] = 0x417A42, - ["gText_Tm23_3500Coins"] = 0x417A58, - ["gText_Tm24_4000Coins"] = 0x417A6E, - ["gText_Tm30_4500Coins"] = 0x417A84, - ["gText_Tm35_4000Coins"] = 0x417A9A, - ["gText_TmCase"] = 0x417DD9, - ["gText_ToTeachHerAGoodSaying"] = 0x418925, - ["gText_Toss"] = 0x41EE26, - ["gText_TossItem"] = 0x417721, - ["gText_TossOutHowManyStrVar1s"] = 0x4163DB, - ["gText_Tough"] = 0x417AD6, - ["gText_Tough_2"] = 0x417B0B, - ["gText_Toughness"] = 0x418C4F, - ["gText_Toxic"] = 0x417920, - ["gText_Trade"] = 0x41B51E, - ["gText_Trade4"] = 0x4169BC, - ["gText_TradeAction_Summary"] = 0x41E10A, - ["gText_TradeAction_Trade"] = 0x41E112, - ["gText_TradeCenter"] = 0x417D79, - ["gText_TradeHasBeenCanceled"] = 0x41E145, - ["gText_Trade_CommunicationStandby"] = 0x41E118, - ["gText_Trade_OnlyPkmnForBattle"] = 0x41E16B, - ["gText_TrainerCardIDNo"] = 0x419CE1, - ["gText_TrainerCardMoney"] = 0x419CE7, - ["gText_TrainerCardName"] = 0x419CDA, - ["gText_TrainerCardNull"] = 0x419CF7, - ["gText_TrainerCardPokedex"] = 0x419CEF, - ["gText_TrainerCardTime"] = 0x419CFD, - ["gText_TrainerCardYen"] = 0x419CED, - ["gText_TravelOnWater"] = 0x41755D, - ["gText_TryBattling"] = 0x3FDBEF, - ["gText_TurnOff"] = 0x4176E2, - ["gText_TurnedOverItemsWorthYen"] = 0x416959, - ["gText_TwoIsland"] = 0x417DFE, - ["gText_TwoPokemonAreNeeded"] = 0x4173CC, - ["gText_TypeMode"] = 0x415ED5, - ["gText_UPDOWNPick_ABUTTONBBUTTONCancel"] = 0x415D78, - ["gText_UPDOWNPick_ABUTTONNext_BBUTTONBack"] = 0x415D60, - ["gText_UR_AnOKWasSentToPlayer"] = 0x457554, - ["gText_UR_AreTheseMembersOK"] = 0x457514, - ["gText_UR_AskTrainerToMakeTrade"] = 0x458E70, - ["gText_UR_AwaitingCommunication"] = 0x457234, - ["gText_UR_AwaitingLinkPressStart"] = 0x457264, - ["gText_UR_AwaitingOtherMembers"] = 0x457700, - ["gText_UR_AwaitingPlayersResponse"] = 0x459238, - ["gText_UR_AwaitingPlayersResponseAboutTrade"] = 0x4578BC, - ["gText_UR_AwaitingResponseFromWirelessSystem"] = 0x45928C, - ["gText_UR_BButtonCancel"] = 0x45747C, - ["gText_UR_Battle"] = 0x459344, - ["gText_UR_BattleChallenge"] = 0x457CA4, - ["gText_UR_BerryCrush"] = 0x459410, - ["gText_UR_BerryPicking"] = 0x45941C, - ["gText_UR_CancelModeWithTheseMembers"] = 0x457530, - ["gText_UR_CancelRegistrationOfEgg"] = 0x458DE8, - ["gText_UR_CancelRegistrationOfMon"] = 0x458DBC, - ["gText_UR_Cards"] = 0x4593DC, - ["gText_UR_Chat"] = 0x4593D4, - ["gText_UR_Chat2"] = 0x45934C, - ["gText_UR_ChatDropped"] = 0x457E0C, - ["gText_UR_ChatEnded"] = 0x457E60, - ["gText_UR_ChatInvitation"] = 0x457CF8, - ["gText_UR_ChooseJoinCancel"] = 0x458FC8, - ["gText_UR_ChooseRequestedMonType"] = 0x458CD4, - ["gText_UR_ChooseTrainer"] = 0x458FE4, - ["gText_UR_Colon"] = 0x4571B0, - ["gText_UR_DontHaveEggTrainerWants"] = 0x458F04, - ["gText_UR_DontHaveTypeTrainerWants"] = 0x458ED0, - ["gText_UR_DoubleBattle"] = 0x4593A4, - ["gText_UR_EggTrade"] = 0x458FBC, - ["gText_UR_EmptyString"] = 0x4571AC, - ["gText_UR_Exit"] = 0x459360, - ["gText_UR_Exit2"] = 0x459368, - ["gText_UR_FinishedCheckingPlayersTrainerCard"] = 0x459588, - ["gText_UR_Greetings"] = 0x459354, - ["gText_UR_ID"] = 0x4571B4, - ["gText_UR_Info"] = 0x459370, - ["gText_UR_ItemTrade"] = 0x459440, - ["gText_UR_LinkWithFriendDropped"] = 0x4577F8, - ["gText_UR_ModeWithTheseMembersWillBeCanceled"] = 0x457610, - ["gText_UR_MultiBattle"] = 0x4593B4, - ["gText_UR_NameWantedOfferLv"] = 0x459378, - ["gText_UR_NeedTwoMonsOfLevel30OrLower1"] = 0x45847C, - ["gText_UR_NeedTwoMonsOfLevel30OrLower2"] = 0x4584C0, - ["gText_UR_OfferDeclined1"] = 0x457E28, - ["gText_UR_OfferDeclined2"] = 0x457E44, - ["gText_UR_OfferToTradeEgg"] = 0x457DB8, - ["gText_UR_OfferToTradeMon"] = 0x457D44, - ["gText_UR_PlayerContactedYouAddToMembers"] = 0x4574EC, - ["gText_UR_PlayerContactedYouForXAccept"] = 0x4574A0, - ["gText_UR_PlayerContactedYouShareX"] = 0x4574C4, - ["gText_UR_PlayerHasBeenAskedToRegisterYouPleaseWait"] = 0x459250, - ["gText_UR_PlayerOKdRegistration"] = 0x4576C4, - ["gText_UR_PlayerSentBackOK"] = 0x4576AC, - ["gText_UR_PleaseStartOver"] = 0x4571B8, - ["gText_UR_PokemonJump"] = 0x459400, - ["gText_UR_PokemonTrades"] = 0x4593C4, - ["gText_UR_QuitBeingMember"] = 0x45771C, - ["gText_UR_RegisterMonAtTradingBoard"] = 0x458AB8, - ["gText_UR_RegistraionCompleted"] = 0x458D78, - ["gText_UR_RegistrationCanceled"] = 0x458D54, - ["gText_UR_RegistrationCanceled2"] = 0x458E10, - ["gText_UR_Search"] = 0x45942C, - ["gText_UR_SearchingForWirelessSystemWait"] = 0x4591DC, - ["gText_UR_ShowTrainerCard"] = 0x457C48, - ["gText_UR_SingleBattle"] = 0x459394, - ["gText_UR_SpinTrade"] = 0x459434, - ["gText_UR_TradeCanceled"] = 0x458D9C, - ["gText_UR_TradeOfferRejected"] = 0x458F9C, - ["gText_UR_TradingBoardInfo"] = 0x458B44, - ["gText_UR_TrainerAppearsBusy"] = 0x457F90, - ["gText_UR_TrainerBattleBusy"] = 0x458434, - ["gText_UR_TrainerCardInfoPage1"] = 0x4594C4, - ["gText_UR_TrainerCardInfoPage2"] = 0x459504, - ["gText_UR_WhichMonWillYouOffer"] = 0x458D1C, - ["gText_UR_WirelessLinkDropped"] = 0x4577BC, - ["gText_UR_WirelessLinkEstablished"] = 0x45777C, - ["gText_UR_WirelessSearchCanceled"] = 0x4571E0, - ["gText_UR_WonderCards"] = 0x4593E4, - ["gText_UR_WonderNews"] = 0x4593F4, - ["gText_UR_XCheckedTradingBoard"] = 0x458A98, - ["gText_UnionRoom"] = 0x41809C, - ["gText_UnionRoomChatKeyboard_01234Lower"] = 0x47AB21, - ["gText_UnionRoomChatKeyboard_01234Upper"] = 0x47AAE5, - ["gText_UnionRoomChatKeyboard_56789Lower"] = 0x47AB27, - ["gText_UnionRoomChatKeyboard_56789Upper"] = 0x47AAEB, - ["gText_UnionRoomChatKeyboard_ABCDE"] = 0x47AAC1, - ["gText_UnionRoomChatKeyboard_Emoji1"] = 0x47AB41, - ["gText_UnionRoomChatKeyboard_Emoji10"] = 0x47ABA2, - ["gText_UnionRoomChatKeyboard_Emoji2"] = 0x47AB4C, - ["gText_UnionRoomChatKeyboard_Emoji3"] = 0x47AB57, - ["gText_UnionRoomChatKeyboard_Emoji4"] = 0x47AB62, - ["gText_UnionRoomChatKeyboard_Emoji5"] = 0x47AB6B, - ["gText_UnionRoomChatKeyboard_Emoji6"] = 0x47AB76, - ["gText_UnionRoomChatKeyboard_Emoji7"] = 0x47AB81, - ["gText_UnionRoomChatKeyboard_Emoji8"] = 0x47AB8C, - ["gText_UnionRoomChatKeyboard_Emoji9"] = 0x47AB97, - ["gText_UnionRoomChatKeyboard_FGHIJ"] = 0x47AAC7, - ["gText_UnionRoomChatKeyboard_KLMNO"] = 0x47AACD, - ["gText_UnionRoomChatKeyboard_PQRST"] = 0x47AAD3, - ["gText_UnionRoomChatKeyboard_PunctuationLower"] = 0x47AB2D, - ["gText_UnionRoomChatKeyboard_PunctuationUpper"] = 0x47AAF1, - ["gText_UnionRoomChatKeyboard_SymbolsLower"] = 0x47AB33, - ["gText_UnionRoomChatKeyboard_SymbolsUpper"] = 0x47AAF7, - ["gText_UnionRoomChatKeyboard_UVWXY"] = 0x47AAD9, - ["gText_UnionRoomChatKeyboard_Z"] = 0x47AADF, - ["gText_UnionRoomChatKeyboard_abcde"] = 0x47AAFD, - ["gText_UnionRoomChatKeyboard_fghij"] = 0x47AB03, - ["gText_UnionRoomChatKeyboard_klmno"] = 0x47AB09, - ["gText_UnionRoomChatKeyboard_pqrst"] = 0x47AB0F, - ["gText_UnionRoomChatKeyboard_uvwxy"] = 0x47AB15, - ["gText_UnionRoomChatKeyboard_z"] = 0x47AB1B, - ["gText_UnionRoomTradesBattles"] = 0x419D66, - ["gText_UnusedDashes"] = 0x41E09B, - ["gText_UnusedEmpty"] = 0x41E097, - ["gText_UnusedEmpty2"] = 0x41E0A4, - ["gText_UnusedId"] = 0x41E322, - ["gText_UnusedLv"] = 0x41E098, - ["gText_Unused_1F"] = 0x3FE9A9, - ["gText_Unused_2F"] = 0x3FE9AC, - ["gText_Unused_3F"] = 0x3FE9AF, - ["gText_Unused_4F"] = 0x3FE9B2, - ["gText_Unused_5F"] = 0x3FE9B5, - ["gText_Unused_6F"] = 0x3FE9B8, - ["gText_Unused_7F"] = 0x3FE9BB, - ["gText_Unused_8F"] = 0x3FE9BE, - ["gText_UpdatingSaveExternalData"] = 0x415B0E, - ["gText_UponLosingABattle"] = 0x418A61, - ["gText_UponWinningABattle"] = 0x418A4A, - ["gText_Upper"] = 0x41B33D, - ["gText_UseOnWhichPokemon"] = 0x417215, - ["gText_UsedVar2WildLured"] = 0x4165D2, - ["gText_UsedVar2WildRepelled"] = 0x416600, - ["gText_Vaporeon"] = 0x417BF8, - ["gText_Var1AndYouWantedVar2"] = 0x41678E, - ["gText_Var1CertainlyHowMany"] = 0x416766, - ["gText_Var1IsItThatllBeVar2"] = 0x4167BB, - ["gText_Var1IsSelected"] = 0x4162FF, - ["gText_Var1Percent"] = 0x41D0A4, - ["gText_Var1Players"] = 0x41D13C, - ["gText_Var1sTrainerCard"] = 0x419D0A, - ["gText_VarietyOfEventsImportedWireless"] = 0x41E5FD, - ["gText_Vermilion"] = 0x417E46, - ["gText_Victories"] = 0x4178FD, - ["gText_VolcanoBadge"] = 0x418022, - ["gText_Volcanobadge"] = 0x417B87, - ["gText_WT"] = 0x415F9B, - ["gText_WaitingForFriendToFinish"] = 0x41E199, - ["gText_WaitingTrainerFinishReading"] = 0x419D89, - ["gText_WantToPlayAgain"] = 0x41CDD7, - ["gText_WantToPlayAgain2"] = 0x41CBFD, - ["gText_WasEnhanced"] = 0x418C55, - ["gText_WasItWorriedAboutYou"] = 0x4183A0, - ["gText_WasUsedOn"] = 0x41B285, - ["gText_WelcomeToHOF"] = 0x416008, - ["gText_WhatDoYouWantToDo"] = 0x41821B, - ["gText_WhatToDoWithCards"] = 0x41EA86, - ["gText_WhatToDoWithNews"] = 0x41EAB7, - ["gText_WhatWillOldManDo"] = 0x3FE6FA, - ["gText_WhatWouldYouLikeToDo"] = 0x416262, - ["gText_WhatWouldYouLikeToDoWithPlayersMail"] = 0x417806, - ["gText_WhatYouSayIfYouLose"] = 0x41885F, - ["gText_WhatYouSayIfYouWin"] = 0x41883E, - ["gText_WhatsHipAndHappening"] = 0x418A87, - ["gText_WhereShouldCardBeAccessed"] = 0x41E717, - ["gText_WhereShouldNewsBeAccessed"] = 0x41E741, - ["gText_WhichMoveShouldBeForgotten"] = 0x41E50C, - ["gText_WhichMoveToForget"] = 0x416EA4, - ["gText_WhichMoveToForget_Unused"] = 0x3FE779, - ["gText_WhichOneWillYouTake"] = 0x418346, - ["gText_WillNotBeSaved"] = 0x418A1D, - ["gText_Win"] = 0x3FE874, - ["gText_WinEarnsPrizeMoney"] = 0x3FDD64, - ["gText_WinLossRatio"] = 0x419D4F, - ["gText_Wireless"] = 0x417DA0, - ["gText_WirelessCommunication"] = 0x41EDF6, - ["gText_WirelessCommunicationStatus"] = 0x41E245, - ["gText_WirelessNotConnected"] = 0x415AA4, - ["gText_WithFourPhrases"] = 0x418777, - ["gText_Withdraw"] = 0x4178B5, - ["gText_WithdrawHowMany"] = 0x4177AC, - ["gText_WithdrawItem"] = 0x4178A7, - ["gText_WithdrawItem2"] = 0x417713, - ["gText_WithdrawMonDescription"] = 0x4185AD, - ["gText_WithdrawPokemon"] = 0x41856C, - ["gText_WithdrewQuantItem"] = 0x4177C5, - ["gText_WonderCardReceived"] = 0x41E8BD, - ["gText_WonderCardReceivedFrom"] = 0x41E866, - ["gText_WonderCardSentTo"] = 0x41EB20, - ["gText_WonderCardThrownAway"] = 0x41ED7B, - ["gText_WonderCards"] = 0x41EDDD, - ["gText_WonderCardsInPossession"] = 0x41E63F, - ["gText_WonderNews"] = 0x41EDEA, - ["gText_WonderNewsReceived"] = 0x41E8E2, - ["gText_WonderNewsReceivedFrom"] = 0x41E88F, - ["gText_WonderNewsSentTo"] = 0x41EB46, - ["gText_WonderNewsThrownAway"] = 0x41ED9C, - ["gText_WontEatAnymore"] = 0x418C6F, - ["gText_WontHaveEffect"] = 0x4169DC, - ["gText_WouldYouLikeToSaveTheGame"] = 0x1C55C9, - ["gText_XDotY"] = 0x41D03C, - ["gText_XDotY2"] = 0x41D080, - ["gText_XDotY3"] = 0x41D090, - ["gText_XMinYZSec"] = 0x3FE998, - ["gText_XSentOverY"] = 0x41E20D, - ["gText_XWillBeSentToY"] = 0x41E1E9, - ["gText_YaySmileEmoji"] = 0x41B53B, - ["gText_YellowFlute_1600Coins"] = 0x417F2F, - ["gText_YellowShard"] = 0x417A0C, - ["gText_Yes"] = 0x417938, - ["gText_YesNo"] = 0x41623D, - ["gText_YouCantReleaseAnEgg"] = 0x41835F, - ["gText_YouDontHaveMoney"] = 0x416842, - ["gText_YouWantedVar1ThatllBeVar2"] = 0x4167D0, - ["gText_YourFeelingAtTheBattlesStart"] = 0x41881B, - ["gText_YourName"] = 0x418E47, - ["gText_YourPartysFull"] = 0x418319, - ["gText_YourProfile"] = 0x41880E, - ["gText_YoureHoldingAPkmn"] = 0x41832C, - ["gText_Zinc_1000"] = 0x418142, - ["gTradeText_Cancel"] = 0x41E0B9, - ["gTradeText_ChooseAPokemon"] = 0x41E0C0, - ["gTradeText_PressBButtonToExit"] = 0x41E0EE, - ["gTradeText_Summary"] = 0x41E0D2, - ["gTradeText_Trade"] = 0x41E0DA, - ["gUnusedText_MysteryGift"] = 0x415A8A, - ["sDmaOverErrorMsg"] = 0x231C48, - ["sDotSeparatedValues"] = 0x4570D1, - ["sJPText_Confirm"] = 0x3EE828, - ["sJPText_Iryoku"] = 0x3FF994, - ["sJPText_Meichuu"] = 0x3FF99A, - ["sJPText_PP"] = 0x3FF990, - ["sJPText_Taipu"] = 0x3FF98B, - ["sJPText_TatakauWaza"] = 0x3FF984, - ["sJPText_Turn"] = 0x250898, - ["sJPText_Vowels"] = 0x475630, - ["sJapaneseEggNickname"] = 0x25F83E, - ["sListItemTextColor_RegularItem"] = 0x452F60, - ["sListItemTextColor_TmCase_BerryPouch"] = 0x452F66, - ["sNewLineText"] = 0x25F83C, - ["sPluralTxt"] = 0x46D94C, - ["sString_Newline"] = 0x234615, - ["sText_100"] = 0x3FFABF, - ["sText_123Poof"] = 0x3FCC07, - ["sText_1PlayerNeeded"] = 0x4573B4, - ["sText_2PlayerMode"] = 0x4573F4, - ["sText_2PlayersNeeded"] = 0x4573C5, - ["sText_3PlayerMode"] = 0x457402, - ["sText_3PlayersNeeded"] = 0x4573D7, - ["sText_4PlayerMode"] = 0x457410, - ["sText_4PlayersNeeded"] = 0x4573E9, - ["sText_5PlayerMode"] = 0x45741E, - ["sText_ABoosted"] = 0x3FB283, - ["sText_AarghAlmostHadIt"] = 0x3FD757, - ["sText_Accuracy"] = 0x3FD5B8, - ["sText_Akito"] = 0x43EDF4, - ["sText_AllyPkmnPrefix"] = 0x3FD564, - ["sText_AllyPkmnPrefix2"] = 0x3FD56D, - ["sText_AllyPkmnPrefix3"] = 0x3FD576, - ["sText_AndEllipsis"] = 0x3FCC33, - ["sText_ApostropheS"] = 0x3FE88F, - ["sText_Attack"] = 0x3FE7D4, - ["sText_Attack2"] = 0x3FD593, - ["sText_AuroraTicket1"] = 0x489526, - ["sText_AuroraTicket2"] = 0x489583, - ["sText_AuroraTicketGot"] = 0x4895E8, - ["sText_AuroraTicketNoPlace"] = 0x489615, - ["sText_AwaitingCommunucation2"] = 0x45721C, - ["sText_AwaitingResponseCancelBButton"] = 0x457C28, - ["sText_AwaitingResponseFromPlayer"] = 0x457C00, - ["sText_AwaitingResponseFromTrainer"] = 0x457BD4, - ["sText_AwaitingResponseFromTrainer2"] = 0x458E9C, - ["sText_BattleDeclinedFemale"] = 0x4582C0, - ["sText_BattleDeclinedMale"] = 0x458260, - ["sText_BattleSurpriseFemale"] = 0x4586F8, - ["sText_BattleSurpriseMale"] = 0x458644, - ["sText_BattleWillBeStarted"] = 0x4581F8, - ["sText_BellChimed"] = 0x3FD068, - ["sText_BerrySuffix"] = 0x3FD8AF, - ["sText_Bills"] = 0x3FD824, - ["sText_BoxIsFull"] = 0x3FD876, - ["sText_ButItFailed"] = 0x3FCE08, - ["sText_ButNoEffect"] = 0x3FC65A, - ["sText_ButNoPPLeft"] = 0x3FD0CA, - ["sText_ButNothingHappened"] = 0x3FCDF2, - ["sText_CanceledReadingCard"] = 0x4595B0, - ["sText_CantEscape"] = 0x3FCDE2, - ["sText_CantEscape2"] = 0x3FB525, - ["sText_CantTradeForPartnersMon"] = 0x458F60, - ["sText_CantTransmitTrainerTooFar"] = 0x4575A4, - ["sText_CertifiesPokedexComplete"] = 0x4159B3, - ["sText_Chat"] = 0x4572E0, - ["sText_ChatDeclinedFemale"] = 0x458598, - ["sText_ChatDeclinedMale"] = 0x458550, - ["sText_ChooseLeaderBerryCrush"] = 0x459160, - ["sText_ChooseLeaderBerryPicking"] = 0x459188, - ["sText_ChooseLeaderMultiBattle"] = 0x459060, - ["sText_ChooseLeaderPokemonJump"] = 0x45912C, - ["sText_ChooseTrainerDoubleBattle"] = 0x459030, - ["sText_ChooseTrainerSingleBattle"] = 0x459000, - ["sText_ChooseTrainerToShareWonderCards"] = 0x4590BC, - ["sText_ChooseTrainerToShareWonderNews"] = 0x4590F4, - ["sText_ChooseTrainerToTradeWith"] = 0x459090, - ["sText_ChooseTrainerToTradeWith2"] = 0x458E54, - ["sText_Clear17"] = 0x43F8DB, - ["sText_ClearTo18"] = 0x463178, - ["sText_ClrWhtHltTranspShdwDrkGry"] = 0x261EB7, - ["sText_CoinsScattered"] = 0x3FD006, - ["sText_CollectedAllStamps"] = 0x4687A0, - ["sText_Communicating"] = 0x4578E4, - ["sText_CommunicatingPleaseWait"] = 0x4578A0, - ["sText_CommunicatingWithPlayer"] = 0x457950, - ["sText_CriticalHit"] = 0x3FCBE4, - ["sText_Dash"] = 0x3FE7C5, - ["sText_DeclineChatMale"] = 0x4584FC, - ["sText_Defense"] = 0x3FE7DC, - ["sText_Defense2"] = 0x3FD59A, - ["sText_Digits"] = 0x475640, - ["sText_DistMysteryEvent"] = 0x45730C, - ["sText_DistWonderCard"] = 0x4572F0, - ["sText_DistWonderNews"] = 0x4572FC, - ["sText_DoSomethingAgainFemale"] = 0x457B70, - ["sText_DoSomethingAgainMale"] = 0x457B4C, - ["sText_DoSomethingFemale"] = 0x457B2C, - ["sText_DoSomethingMale"] = 0x457B14, - ["sText_DoYouWantXMode"] = 0x457860, - ["sText_DoYouWantXMode2"] = 0x45787C, - ["sText_DoneWaitingBattleFemale"] = 0x458180, - ["sText_DoneWaitingBattleMale"] = 0x45812C, - ["sText_DoneWaitingChatFemale"] = 0x4581B0, - ["sText_DoneWaitingChatMale"] = 0x458164, - ["sText_DontBeAThief"] = 0x3FD6B5, - ["sText_DontLeaveBirch"] = 0x3FCDF1, - ["sText_DoubleBattle"] = 0x4572AC, - ["sText_DownpourContinues"] = 0x3FCE90, - ["sText_DownpourStarted"] = 0x3FCE64, - ["sText_Dummy"] = 0x261EB6, - ["sText_Dummy2"] = 0x261EC6, - ["sText_ElectricityWeakened"] = 0x3FC554, - ["sText_Empty"] = 0x4159FB, - ["sText_Empty1"] = 0x3FB218, - ["sText_EmptyString4"] = 0x3FB282, - ["sText_Enigma"] = 0x3FD8B6, - ["sText_EnigmaBerry"] = 0x3FD8A2, - ["sText_EnteringChat"] = 0x458218, - ["sText_EonTicket"] = 0x466EF7, - ["sText_Evasiveness"] = 0x3FD5C1, - ["sText_ExclamationMark"] = 0x3FD586, - ["sText_ExclamationMark2"] = 0x3FD588, - ["sText_ExclamationMark3"] = 0x3FD58A, - ["sText_ExclamationMark4"] = 0x3FD58C, - ["sText_ExclamationMark5"] = 0x3FD58E, - ["sText_FailedToSpitUp"] = 0x3FCF7E, - ["sText_FailedToSwallow"] = 0x3FCFA0, - ["sText_FaintInThree"] = 0x3FD077, - ["sText_FemaleTraded1"] = 0x458A24, - ["sText_FemaleTraded2"] = 0x458A44, - ["sText_FireWeakened"] = 0x3FC576, - ["sText_FoePkmnPrefix"] = 0x3FD55B, - ["sText_FoePkmnPrefix2"] = 0x3FD560, - ["sText_FoePkmnPrefix3"] = 0x3FD569, - ["sText_FoePkmnPrefix4"] = 0x3FD572, - ["sText_FullParty"] = 0x488FE3, - ["sText_GetOutGetOut"] = 0x3FCB26, - ["sText_GhostAppearedCantId"] = 0x3FD2D9, - ["sText_GladToMeetYouFemale"] = 0x459564, - ["sText_GladToMeetYouMale"] = 0x459544, - ["sText_GotAwaySafely"] = 0x3FCC8A, - ["sText_HMMovesCantBeForgotten"] = 0x3FCC39, - ["sText_HP2"] = 0x3FD590, - ["sText_HailContinues"] = 0x3FCF54, - ["sText_HailStopped"] = 0x3FCF6C, - ["sText_HealthboxNickname"] = 0x260556, - ["sText_HiDoSomethingAgainFemale"] = 0x457AD0, - ["sText_HiDoSomethingAgainMale"] = 0x457A98, - ["sText_HiDoSomethingFemale"] = 0x457A70, - ["sText_HiDoSomethingMale"] = 0x457A40, - ["sText_Hoenn"] = 0x4159A3, - ["sText_HoldBerryCrush"] = 0x457330, - ["sText_HoldBerryPicking"] = 0x457344, - ["sText_HoldPokemonJump"] = 0x457320, - ["sText_HoldSpinShop"] = 0x457368, - ["sText_HoldSpinTrade"] = 0x457354, - ["sText_IES"] = 0x3A72A2, - ["sText_IfYouWantToDoSomethingFemale"] = 0x4583F8, - ["sText_IfYouWantToDoSomethingMale"] = 0x4583BC, - ["sText_ItAppearedCaught"] = 0x3FD738, - ["sText_ItDodgedBall"] = 0x3FD6C7, - ["sText_ItDodgedBall2"] = 0x3FD78A, - ["sText_ItHurtConfusion"] = 0x3FCE17, - ["sText_ItIsRaining"] = 0x3FD850, - ["sText_ItSuckedLiquidOoze"] = 0x3FC9E5, - ["sText_ItemsCantBeUsedNow"] = 0x3FC5CC, - ["sText_ItsBronzeCard"] = 0x459460, - ["sText_ItsCopperCard"] = 0x459474, - ["sText_ItsGoldCard"] = 0x45949C, - ["sText_ItsNormalCard"] = 0x45944C, - ["sText_ItsSilverCard"] = 0x459488, - ["sText_JoinChatFemale"] = 0x457F00, - ["sText_JoinChatMale"] = 0x457E88, - ["sText_LearnedSomethingFemale"] = 0x458828, - ["sText_LearnedSomethingMale"] = 0x458778, - ["sText_Letters"] = 0x475638, - ["sText_MaleTraded1"] = 0x4589BC, - ["sText_MaleTraded2"] = 0x4589E4, - ["sText_MaxHP"] = 0x3FE7C7, - ["sText_MemberNoLongerAvailable"] = 0x457644, - ["sText_MirrorMoveFailed"] = 0x3FCE38, - ["sText_MultiBattle"] = 0x4572BC, - ["sText_MustHaveTwoMonsForDoubleBattle"] = 0x459214, - ["sText_MysteryGiftAlteringCave"] = 0x489887, - ["sText_MysteryGiftBattleCountCard"] = 0x489301, - ["sText_MysteryGiftBattleCountCard_2"] = 0x489419, - ["sText_MysteryGiftEgg"] = 0x488F56, - ["sText_MysteryGiftStampCard"] = 0x488E55, - ["sText_MysteryGiftVisitingTrainer"] = 0x489063, - ["sText_MysteryGiftVisitingTrainer_2"] = 0x4891B0, - ["sText_MysticTicket1"] = 0x48975C, - ["sText_MysticTicket2"] = 0x4896FF, - ["sText_MysticTicketGot"] = 0x4897C1, - ["sText_MysticTicketNoPlace"] = 0x4897EE, - ["sText_National"] = 0x4159AB, - ["sText_Newline"] = 0x261EC7, - ["sText_NoCardsSharedRightNow"] = 0x4592E8, - ["sText_NoNewsSharedRightNow"] = 0x459314, - ["sText_NoPPLeft"] = 0x3FD0A7, - ["sText_NoRunningFromTrainers"] = 0x3FCDB3, - ["sText_NobodyHasRegistered"] = 0x458CB4, - ["sText_NotRegisteredAMonForTrade"] = 0x458EB0, - ["sText_NotVeryEffective"] = 0x3FCC5B, - ["sText_OneHitKO"] = 0x3FCBF4, - ["sText_OtherTrainerUnavailableNow"] = 0x45756C, - ["sText_OutOfSafariBalls"] = 0x3FD24D, - ["sText_PkmnBrokeFree"] = 0x3FD718, - ["sText_PkmnHearingFluteAwoke"] = 0x3FDA8C, - ["sText_Player"] = 0x415994, - ["sText_PlayerCantTradeForYourMon"] = 0x458F2C, - ["sText_PlayerHasContactedYou"] = 0x457BB0, - ["sText_PlayerJoinChatFemale"] = 0x457F3C, - ["sText_PlayerJoinChatMale"] = 0x457EC0, - ["sText_PlayerRepliedNo"] = 0x4576EC, - ["sText_PlayerRepliedNo2"] = 0x457824, - ["sText_PleaseWaitAWhile"] = 0x4579BC, - ["sText_PleaseWaitForOtherTrainersToGather"] = 0x4592C8, - ["sText_Plus"] = 0x3FE7C2, - ["sText_PokeblockWasTooBitter"] = 0x3FD61B, - ["sText_PokeblockWasTooDry"] = 0x3FD5FF, - ["sText_PokeblockWasTooSour"] = 0x3FD62B, - ["sText_PokeblockWasTooSpicy"] = 0x3FD5F0, - ["sText_PokeblockWasTooSweet"] = 0x3FD60C, - ["sText_RainContinues"] = 0x3FCE78, - ["sText_RainStopped"] = 0x3FCEA8, - ["sText_RandomChatFemale1"] = 0x458868, - ["sText_RandomChatFemale2"] = 0x458898, - ["sText_RandomChatMale1"] = 0x4587D8, - ["sText_RandomChatMale2"] = 0x4587FC, - ["sText_RightArrow2"] = 0x3FE7BF, - ["sText_S"] = 0x3A72A0, - ["sText_Sama"] = 0x41599D, - ["sText_SandstormBrewed"] = 0x3FCEBA, - ["sText_SandstormIsRaging"] = 0x3FD85F, - ["sText_SandstormRages"] = 0x3FCECE, - ["sText_SandstormSubsided"] = 0x3FCEE3, - ["sText_SearchingForParticipants"] = 0x457488, - ["sText_SharedPain"] = 0x3FD048, - ["sText_ShedinjaJP"] = 0x262034, - ["sText_ShedinjaJapaneseName"] = 0x3F8440, - ["sText_ShedinjaJpnName"] = 0x24F008, - ["sText_ShootSoClose"] = 0x3FD76D, - ["sText_ShowTrainerCardDeclinedFemale"] = 0x45836C, - ["sText_ShowTrainerCardDeclinedMale"] = 0x45831C, - ["sText_ShowTrainerCardFemale"] = 0x4580C0, - ["sText_ShowTrainerCardMale"] = 0x45801C, - ["sText_ShowedTrainerCardFemale1"] = 0x45893C, - ["sText_ShowedTrainerCardFemale2"] = 0x45897C, - ["sText_ShowedTrainerCardMale1"] = 0x4588DC, - ["sText_ShowedTrainerCardMale2"] = 0x458918, - ["sText_SilphScopeUnveil"] = 0x3FD322, - ["sText_SingleBattle"] = 0x45729C, - ["sText_SingleSpace"] = 0x46317C, - ["sText_SomebodyHasContactedYou"] = 0x457B90, - ["sText_Someones"] = 0x3FD81A, - ["sText_SoothingAroma"] = 0x3FC5A2, - ["sText_SpAtk"] = 0x3FE7E4, - ["sText_SpAtk2"] = 0x3FD5A8, - ["sText_SpDef"] = 0x3FE7EC, - ["sText_SpDef2"] = 0x3FD5B0, - ["sText_Space"] = 0x4643B4, - ["sText_SpaceIs"] = 0x3FE88B, - ["sText_Speed"] = 0x3FD5A2, - ["sText_SpikesScattered"] = 0x3FC090, - ["sText_StartedHail"] = 0x3FCF40, - ["sText_StartedToRain"] = 0x3FCE50, - ["sText_StatChangesGone"] = 0x3FCFE4, - ["sText_StatFell"] = 0x3FCB59, - ["sText_StatHarshly"] = 0x3FCB50, - ["sText_StatSharply"] = 0x3FCB41, - ["sText_StoppedBeingMember"] = 0x457734, - ["sText_SunlightFaded"] = 0x3FCF2C, - ["sText_SunlightGotBright"] = 0x3FCEFB, - ["sText_SunlightStrong"] = 0x3FCF14, - ["sText_SuperEffective"] = 0x3FCC74, - ["sText_SwitchedMonsFemale"] = 0x458724, - ["sText_SwitchedMonsMale"] = 0x458668, - ["sText_TaskOver"] = 0x3BF52C, - ["sText_Test"] = 0x466EF3, - ["sText_ThankYouForRegistering"] = 0x458C80, - ["sText_ThatsFunnyFemale"] = 0x45884C, - ["sText_ThatsFunnyMale"] = 0x45879C, - ["sText_TheGhostAppeared"] = 0x3FD30D, - ["sText_TheGhostWas"] = 0x3FD34D, - ["sText_TheWallShattered"] = 0x3FC646, - ["sText_TooWeakForSubstitute"] = 0x3FD022, - ["sText_TradePokemon"] = 0x4572CC, - ["sText_TradeTrainersWillBeListed"] = 0x458E38, - ["sText_TradeWillBeStarted"] = 0x4581D8, - ["sText_TrainerAppearsUnavailable"] = 0x457684, - ["sText_TrainerBlockedBall"] = 0x3FD697, - ["sText_TrainersNotReadyYet"] = 0x4575DC, - ["sText_Underscore"] = 0x43F8D8, - ["sText_UseNextPkmn"] = 0x3FB359, - ["sText_UsedGoodMoveFemale"] = 0x4586D0, - ["sText_UsedGoodMoveMale"] = 0x458618, - ["sText_WaitForBattleFemale"] = 0x458054, - ["sText_WaitForBattleMale"] = 0x457FB4, - ["sText_WaitForChatFemale"] = 0x458088, - ["sText_WaitForChatMale"] = 0x457FE8, - ["sText_WaitForChatMale2"] = 0x458114, - ["sText_WildPkmnPrefix"] = 0x3FD555, - ["sText_WindBecameHeatWave"] = 0x3FCFC2, - ["sText_YouMissedPkmn"] = 0x3FD700, - ["sText_YouThrowABallNowRight"] = 0x3FDAB1, - ["sText_YoureToughFemale"] = 0x4586A0, - ["sText_YoureToughMale"] = 0x4585F0, - ["stext_DeclineChatFemale"] = 0x458528, - }, - battle = { - ["gBattleText_GetPumped"] = 0x3FBBE9, - ["gBattleText_MistShroud"] = 0x3FBBB4, - ["gText_DefendersStatRose"] = 0x3FCB6A, - ["gText_ForPetesSake"] = 0x3FDAE2, - ["gText_HowDissapointing"] = 0x3FDDEB, - ["gText_PkmnsXPreventsSwitching"] = 0x3FCA2C, - ["gText_WhatWillPkmnDo"] = 0x3FE6D5, - ["gText_WhatWillPlayerThrow"] = 0x3FE6E6, - ["sText_AttackMissed"] = 0x3FB36B, - ["sText_AttackerCantEscape"] = 0x3FB534, - ["sText_AttackerFainted"] = 0x3FB400, - ["sText_AttackerUsedX"] = 0x3FD57B, - ["sText_AttackersStatFell"] = 0x3FCB8F, - ["sText_AttackersStatRose"] = 0x3FCB5F, - ["sText_AvoidedDamage"] = 0x3FB394, - ["sText_DefendersStatFell"] = 0x3FCB9A, - ["sText_DidNotLearnMove"] = 0x3FB343, - ["sText_DoItPkmn"] = 0x3FD47D, - ["sText_EnemyAboutToSwitchPkmn"] = 0x3FD186, - ["sText_FoeStoppedWorking"] = 0x3FB826, - ["sText_ForXCommaYZ"] = 0x3FC5E8, - ["sText_GiveNicknameCaptured"] = 0x3FD7E0, - ["sText_GoForItPkmn"] = 0x3FD488, - ["sText_GoPkmn"] = 0x3FD45E, - ["sText_GoPkmn2"] = 0x3FD475, - ["sText_GoTwoPkmn"] = 0x3FD466, - ["sText_GotchaPkmnCaught"] = 0x3FD7A2, - ["sText_GotchaPkmnCaught2"] = 0x3FD7C0, - ["sText_HitXTimes"] = 0x3FB545, - ["sText_ItDoesntAffect"] = 0x3FB3EA, - ["sText_ItemAllowsOnlyYMove"] = 0x3FDA0B, - ["sText_LinkPartnerSentOutPkmnGoPkmn"] = 0x3FD4B5, - ["sText_LinkTrainer1WithdrewPkmn"] = 0x3FD535, - ["sText_LinkTrainer2WithdrewPkmn"] = 0x3FD545, - ["sText_LinkTrainerMultiSentOutPkmn"] = 0x3FD44E, - ["sText_LinkTrainerSentOutPkmn"] = 0x3FD3F7, - ["sText_LinkTrainerSentOutPkmn2"] = 0x3FD43E, - ["sText_LinkTrainerSentOutTwoPkmn"] = 0x3FD407, - ["sText_LinkTrainerWantsToBattle"] = 0x3FD383, - ["sText_MagnitudeStrength"] = 0x3FC132, - ["sText_NaturePowerTurnedInto"] = 0x3FC26D, - ["sText_OldManUsedItem"] = 0x3FD65C, - ["sText_PkmnAbsorbedNutrients"] = 0x3FC3C5, - ["sText_PkmnAfflictedByCurse"] = 0x3FC072, - ["sText_PkmnAlreadyAsleep"] = 0x3FB57C, - ["sText_PkmnAlreadyAsleep2"] = 0x3FB592, - ["sText_PkmnAlreadyConfused"] = 0x3FB885, - ["sText_PkmnAlreadyHasBurn"] = 0x3FB67B, - ["sText_PkmnAlreadyPoisoned"] = 0x3FB5F8, - ["sText_PkmnAnchoredItself"] = 0x3FC3EB, - ["sText_PkmnAnchorsItselfWith"] = 0x3FC860, - ["sText_PkmnAttack"] = 0x3FC224, - ["sText_PkmnAvoidedAttack"] = 0x3FB3D3, - ["sText_PkmnBadlyPoisoned"] = 0x3FB610, - ["sText_PkmnBeganToNap"] = 0x3FD124, - ["sText_PkmnBlewAwaySpikes"] = 0x3FC1B1, - ["sText_PkmnBlownAway"] = 0x3FB8E0, - ["sText_PkmnBracedItself"] = 0x3FC10C, - ["sText_PkmnBuffetedBySandstorm"] = 0x3FBC2B, - ["sText_PkmnBurnedBy"] = 0x3FB650, - ["sText_PkmnCalmedDown"] = 0x3FBD43, - ["sText_PkmnCantSleepInUproar"] = 0x3FBD53, - ["sText_PkmnCantSleepInUproar2"] = 0x3FBDA3, - ["sText_PkmnCantStockpile"] = 0x3FBD86, - ["sText_PkmnCantUseMoveSealed"] = 0x3FC6F8, - ["sText_PkmnCantUseMoveTaunt"] = 0x3FC6D6, - ["sText_PkmnCantUseMoveTorment"] = 0x3FC69D, - ["sText_PkmnCausedUproar"] = 0x3FBD16, - ["sText_PkmnCenterAttention"] = 0x3FC231, - ["sText_PkmnChangedType"] = 0x3FB8F3, - ["sText_PkmnChangedTypeWith"] = 0x3FC7B6, - ["sText_PkmnChargingPower"] = 0x3FC254, - ["sText_PkmnChoseXAsDestiny"] = 0x3FC208, - ["sText_PkmnClamped"] = 0x3FBB62, - ["sText_PkmnComeBack"] = 0x3FD4EB, - ["sText_PkmnCopiedFoe"] = 0x3FC377, - ["sText_PkmnCopiedStatChanges"] = 0x3FC168, - ["sText_PkmnCoveredByVeil"] = 0x3FB9B8, - ["sText_PkmnCrashed"] = 0x3FBB99, - ["sText_PkmnCutHPMaxedAttack"] = 0x3FC140, - ["sText_PkmnCutsAttackWith"] = 0x3FC87B, - ["sText_PkmnDataAddedToDex"] = 0x3FD82B, - ["sText_PkmnDreamEaten"] = 0x3FB7BF, - ["sText_PkmnDugHole"] = 0x3FBAC3, - ["sText_PkmnEncoreEnded"] = 0x3FBF63, - ["sText_PkmnEnduredHit"] = 0x3FC11E, - ["sText_PkmnEnergyDrained"] = 0x3FB626, - ["sText_PkmnEvadedAttack"] = 0x3FBC83, - ["sText_PkmnFastAsleep"] = 0x3FBCBE, - ["sText_PkmnFatigueConfusion"] = 0x3FBE2B, - ["sText_PkmnFellAsleep"] = 0x3FB555, - ["sText_PkmnFellForTaunt"] = 0x3FC2E3, - ["sText_PkmnFellInLove"] = 0x3FB89D, - ["sText_PkmnFellIntoNightmare"] = 0x3FC011, - ["sText_PkmnFledFromBattle"] = 0x3FC1C6, - ["sText_PkmnFledUsing"] = 0x3FCCBB, - ["sText_PkmnFledUsingIts"] = 0x3FCCA0, - ["sText_PkmnFlewHigh"] = 0x3FBAB2, - ["sText_PkmnFlinched"] = 0x3FB914, - ["sText_PkmnForesawAttack"] = 0x3FC1DB, - ["sText_PkmnForgotMove"] = 0x3FB31F, - ["sText_PkmnFreedFrom"] = 0x3FBB83, - ["sText_PkmnFrozenBy"] = 0x3FB6A7, - ["sText_PkmnGainedEXP"] = 0x3FB265, - ["sText_PkmnGoodComeBack"] = 0x3FD50D, - ["sText_PkmnGotEncore"] = 0x3FBF51, - ["sText_PkmnGotFree"] = 0x3FC185, - ["sText_PkmnGrewToLv"] = 0x3FB28E, - ["sText_PkmnHPFull"] = 0x3FB935, - ["sText_PkmnHasNoMovesLeft"] = 0x3FC670, - ["sText_PkmnHasSubstitute"] = 0x3FBEA1, - ["sText_PkmnHealedConfusion"] = 0x3FB854, - ["sText_PkmnHealedParalysis"] = 0x3FB7A3, - ["sText_PkmnHidUnderwater"] = 0x3FBAD2, - ["sText_PkmnHitWithRecoil"] = 0x3FBBFF, - ["sText_PkmnHungOnWithX"] = 0x3FDA33, - ["sText_PkmnHurtBy"] = 0x3FBB71, - ["sText_PkmnHurtByBurn"] = 0x3FB663, - ["sText_PkmnHurtByPoison"] = 0x3FB5E2, - ["sText_PkmnHurtBySpikes"] = 0x3FC0C6, - ["sText_PkmnHurtsWith"] = 0x3FC8B1, - ["sText_PkmnIdentified"] = 0x3FC0DC, - ["sText_PkmnIgnoredOrders"] = 0x3FD111, - ["sText_PkmnIgnoresAsleep"] = 0x3FD0F1, - ["sText_PkmnImmobilizedByLove"] = 0x3FB8C5, - ["sText_PkmnInLove"] = 0x3FB8AE, - ["sText_PkmnIsAlreadyParalyzed"] = 0x3FB78A, - ["sText_PkmnIsAngry"] = 0x3FD232, - ["sText_PkmnIsConfused"] = 0x3FB844, - ["sText_PkmnIsEating"] = 0x3FD23F, - ["sText_PkmnIsFrozen"] = 0x3FB6BF, - ["sText_PkmnIsGlowing"] = 0x3FBAA3, - ["sText_PkmnIsParalyzed"] = 0x3FB76A, - ["sText_PkmnKnockedOff"] = 0x3FC421, - ["sText_PkmnLaidCurse"] = 0x3FC048, - ["sText_PkmnLearnedMove"] = 0x3FB2A4, - ["sText_PkmnLearnedMove2"] = 0x3FD1B8, - ["sText_PkmnLoafing"] = 0x3FD135, - ["sText_PkmnLockedInNightmare"] = 0x3FC02B, - ["sText_PkmnLostFocus"] = 0x3FC60C, - ["sText_PkmnLostPPGrudge"] = 0x3FC4AB, - ["sText_PkmnLoweredHead"] = 0x3FBA8E, - ["sText_PkmnMadeItRain"] = 0x3FC715, - ["sText_PkmnMadeSleep"] = 0x3FB565, - ["sText_PkmnMadeSubstitute"] = 0x3FBE8B, - ["sText_PkmnMadeWish"] = 0x3FC38A, - ["sText_PkmnMakesGroundMiss"] = 0x3FB3AF, - ["sText_PkmnMakingUproar"] = 0x3FBD2B, - ["sText_PkmnMoveBounced"] = 0x3FC4F0, - ["sText_PkmnMoveDisabledNoMore"] = 0x3FBF39, - ["sText_PkmnMoveIsDisabled"] = 0x3FC687, - ["sText_PkmnMoveWasDisabled"] = 0x3FBF23, - ["sText_PkmnMustRecharge"] = 0x3FBEFA, - ["sText_PkmnObtainedX"] = 0x3FC337, - ["sText_PkmnObtainedX2"] = 0x3FC347, - ["sText_PkmnObtainedXYObtainedZ"] = 0x3FC357, - ["sText_PkmnOkComeBack"] = 0x3FD4FA, - ["sText_PkmnPeltedByHail"] = 0x3FBC4C, - ["sText_PkmnPerishCountFell"] = 0x3FC0EE, - ["sText_PkmnPickedUpItem"] = 0x3FBE4E, - ["sText_PkmnPlantedRoots"] = 0x3FC3AF, - ["sText_PkmnPoisonedBy"] = 0x3FB5CD, - ["sText_PkmnPretendNotNotice"] = 0x3FD16A, - ["sText_PkmnPreventsConfusionWith"] = 0x3FC825, - ["sText_PkmnPreventsParalysisWith"] = 0x3FC7D3, - ["sText_PkmnPreventsPoisoningWith"] = 0x3FC809, - ["sText_PkmnPreventsRomanceWith"] = 0x3FC7EF, - ["sText_PkmnPreventsStatLossWith"] = 0x3FC895, - ["sText_PkmnPreventsUsage"] = 0x3FC75D, - ["sText_PkmnProtectedBy"] = 0x3FC745, - ["sText_PkmnProtectedByMist"] = 0x3FBBD0, - ["sText_PkmnProtectedItself"] = 0x3FB37F, - ["sText_PkmnProtectedItself2"] = 0x3FBC16, - ["sText_PkmnRageBuilding"] = 0x3FBF0C, - ["sText_PkmnRaisedDef"] = 0x3FB97F, - ["sText_PkmnRaisedDefALittle"] = 0x3FB997, - ["sText_PkmnRaisedFirePowerWith"] = 0x3FC841, - ["sText_PkmnRaisedSpDef"] = 0x3FB946, - ["sText_PkmnRaisedSpDefALittle"] = 0x3FB95E, - ["sText_PkmnRaisedSpeed"] = 0x3FC72B, - ["sText_PkmnReadyToHelp"] = 0x3FC2FA, - ["sText_PkmnReducedPP"] = 0x3FBFD3, - ["sText_PkmnRegainedHealth"] = 0x3FB921, - ["sText_PkmnRestoredHPUsing"] = 0x3FC780, - ["sText_PkmnSafeguardExpired"] = 0x3FB9FF, - ["sText_PkmnSappedByLeechSeed"] = 0x3FBC99, - ["sText_PkmnSealedOpponentMove"] = 0x3FC461, - ["sText_PkmnSeeded"] = 0x3FBC74, - ["sText_PkmnSentToPC"] = 0x3FD804, - ["sText_PkmnShedLeechSeed"] = 0x3FC19D, - ["sText_PkmnShroudedItself"] = 0x3FC4D6, - ["sText_PkmnSketchedMove"] = 0x3FBF89, - ["sText_PkmnSleptHealthy"] = 0x3FBA41, - ["sText_PkmnSnatchedMove"] = 0x3FC53D, - ["sText_PkmnSprangUp"] = 0x3FBAE5, - ["sText_PkmnSqueezedByBind"] = 0x3FBAF3, - ["sText_PkmnStatusNormal"] = 0x3FC28A, - ["sText_PkmnStayedAwakeUsing"] = 0x3FBDE2, - ["sText_PkmnStockpiled"] = 0x3FBD74, - ["sText_PkmnStoleItem"] = 0x3FBFEA, - ["sText_PkmnStoringEnergy"] = 0x3FBE00, - ["sText_PkmnSubjectedToTorment"] = 0x3FC2AA, - ["sText_PkmnSubstituteFaded"] = 0x3FBEE2, - ["sText_PkmnSwappedAbilities"] = 0x3FC439, - ["sText_PkmnSwitchedItems"] = 0x3FC312, - ["sText_PkmnThatsEnough"] = 0x3FD4CD, - ["sText_PkmnTighteningFocus"] = 0x3FC2C7, - ["sText_PkmnTookAim"] = 0x3FBF76, - ["sText_PkmnTookAttack"] = 0x3FC1F1, - ["sText_PkmnTookFoe"] = 0x3FBFBF, - ["sText_PkmnTookSunlight"] = 0x3FBA79, - ["sText_PkmnTraced"] = 0x3FC8C2, - ["sText_PkmnTransformed"] = 0x3FCA03, - ["sText_PkmnTransformedInto"] = 0x3FBE73, - ["sText_PkmnTrappedBySandTomb"] = 0x3FBB2F, - ["sText_PkmnTrappedInVortex"] = 0x3FBB11, - ["sText_PkmnTryingToTakeFoe"] = 0x3FBF99, - ["sText_PkmnTurnedAway"] = 0x3FD15A, - ["sText_PkmnUnaffected"] = 0x3FBE61, - ["sText_PkmnUnleashedEnergy"] = 0x3FBE16, - ["sText_PkmnUproarKeptAwake"] = 0x3FBCDD, - ["sText_PkmnUsedSafeguard"] = 0x3FB9D9, - ["sText_PkmnUsedXToGetPumped"] = 0x3FC5F6, - ["sText_PkmnWaitsForTarget"] = 0x3FC518, - ["sText_PkmnWantsGrudge"] = 0x3FC483, - ["sText_PkmnWasBurned"] = 0x3FB641, - ["sText_PkmnWasConfused"] = 0x3FB871, - ["sText_PkmnWasDefrosted"] = 0x3FB6D3, - ["sText_PkmnWasDefrosted2"] = 0x3FB6E5, - ["sText_PkmnWasDefrostedBy"] = 0x3FB6F7, - ["sText_PkmnWasDraggedOut"] = 0x3FC631, - ["sText_PkmnWasFrozen"] = 0x3FB692, - ["sText_PkmnWasMadeDrowsy"] = 0x3FC40E, - ["sText_PkmnWasParalyzed"] = 0x3FB70F, - ["sText_PkmnWasParalyzedBy"] = 0x3FB73A, - ["sText_PkmnWasPoisoned"] = 0x3FB5BC, - ["sText_PkmnWasntAffected"] = 0x3FB5A8, - ["sText_PkmnWatchingCarefully"] = 0x3FD218, - ["sText_PkmnWentToSleep"] = 0x3FBA2F, - ["sText_PkmnWhippedWhirlwind"] = 0x3FBA5E, - ["sText_PkmnWishCameTrue"] = 0x3FC39A, - ["sText_PkmnWokeUp"] = 0x3FBCD1, - ["sText_PkmnWokeUpInUproar"] = 0x3FBCFC, - ["sText_PkmnWontObey"] = 0x3FD14B, - ["sText_PkmnWrappedBy"] = 0x3FBB4C, - ["sText_PkmnsItemCuredParalysis"] = 0x3FD8B9, - ["sText_PkmnsItemCuredPoison"] = 0x3FD8D2, - ["sText_PkmnsItemCuredProblem"] = 0x3FD95C, - ["sText_PkmnsItemDefrostedIt"] = 0x3FD901, - ["sText_PkmnsItemHealedBurn"] = 0x3FD8E8, - ["sText_PkmnsItemNormalizedStatus"] = 0x3FD97A, - ["sText_PkmnsItemRestoredHPALittle"] = 0x3FD9E9, - ["sText_PkmnsItemRestoredHealth"] = 0x3FD999, - ["sText_PkmnsItemRestoredPP"] = 0x3FD9B2, - ["sText_PkmnsItemRestoredStatus"] = 0x3FD9CC, - ["sText_PkmnsItemSnappedOut"] = 0x3FD937, - ["sText_PkmnsItemWokeIt"] = 0x3FD917, - ["sText_PkmnsXBlocksY"] = 0x3FC8ED, - ["sText_PkmnsXBlocksY2"] = 0x3FC900, - ["sText_PkmnsXCuredItsYProblem"] = 0x3FCAD1, - ["sText_PkmnsXCuredYProblem"] = 0x3FC9C7, - ["sText_PkmnsXHadNoEffectOnY"] = 0x3FCAEF, - ["sText_PkmnsXInfatuatedY"] = 0x3FC993, - ["sText_PkmnsXIntensifiedSun"] = 0x3FC955, - ["sText_PkmnsXMadeItIneffective"] = 0x3FCA71, - ["sText_PkmnsXMadeYIneffective"] = 0x3FC9AA, - ["sText_PkmnsXMadeYUseless"] = 0x3FC79D, - ["sText_PkmnsXPreventsBurns"] = 0x3FC8D5, - ["sText_PkmnsXPreventsFlinching"] = 0x3FCA8E, - ["sText_PkmnsXPreventsYLoss"] = 0x3FC979, - ["sText_PkmnsXPreventsYsZ"] = 0x3FCAAA, - ["sText_PkmnsXRestoredHPALittle2"] = 0x3FC913, - ["sText_PkmnsXTookAttack"] = 0x3FCA13, - ["sText_PkmnsXWhippedUpSandstorm"] = 0x3FC935, - ["sText_PkmnsXWoreOff"] = 0x3FBC62, - ["sText_PlayedFluteCatchyTune"] = 0x3FDA4D, - ["sText_PlayedThe"] = 0x3FDA7A, - ["sText_PlayerBattledToDrawLinkTrainer"] = 0x3FCD41, - ["sText_PlayerBattledToDrawTrainer1"] = 0x3FE9FF, - ["sText_PlayerBattledToDrawVsTwo"] = 0x3FCD66, - ["sText_PlayerDefeatedLinkTrainer"] = 0x3FCCE4, - ["sText_PlayerDefeatedLinkTrainerTrainer1"] = 0x3FD1C7, - ["sText_PlayerGotMoney"] = 0x3FB41A, - ["sText_PlayerLostAgainstLinkTrainer"] = 0x3FCD0F, - ["sText_PlayerLostAgainstTrainer1"] = 0x3FE9E4, - ["sText_PlayerLostToTwo"] = 0x3FCD27, - ["sText_PlayerPaidAsPrizeMoney"] = 0x3FB4BE, - ["sText_PlayerPanicked"] = 0x3FB451, - ["sText_PlayerUsedItem"] = 0x3FD650, - ["sText_PlayerWhiteout"] = 0x3FB433, - ["sText_PlayerWhiteout2"] = 0x3FB4F7, - ["sText_PlayerWhiteoutAgainstTrainer"] = 0x3FB484, - ["sText_PokedudeUsedItem"] = 0x3FD671, - ["sText_PreventedFromWorking"] = 0x3FCA49, - ["sText_PreventsEscape"] = 0x3FB508, - ["sText_StatsWontDecrease"] = 0x3FB7EE, - ["sText_StatsWontDecrease2"] = 0x3FCBC5, - ["sText_StatsWontIncrease"] = 0x3FB7D5, - ["sText_StatsWontIncrease2"] = 0x3FCBA5, - ["sText_StopLearningMove"] = 0x3FB32E, - ["sText_SubstituteDamaged"] = 0x3FBEBE, - ["sText_TargetCantEscapeNow"] = 0x3FBFFC, - ["sText_TargetFainted"] = 0x3FB40D, - ["sText_TeamStoppedWorking"] = 0x3FB806, - ["sText_ThrewARock"] = 0x3FD1DF, - ["sText_ThrewSomeBait"] = 0x3FD1FA, - ["sText_TooScaredToMove"] = 0x3FCB0C, - ["sText_Trainer1Fled"] = 0x3FE9D4, - ["sText_Trainer1LoseText"] = 0x3FB219, - ["sText_Trainer1RecallBoth"] = 0x3FB248, - ["sText_Trainer1RecallPkmn1"] = 0x3FB21F, - ["sText_Trainer1RecallPkmn2"] = 0x3FB235, - ["sText_Trainer1SentOutPkmn"] = 0x3FD3B1, - ["sText_Trainer1SentOutPkmn2"] = 0x3FD3E4, - ["sText_Trainer1SentOutTwoPkmn"] = 0x3FD3C7, - ["sText_Trainer1UsedItem"] = 0x3FD688, - ["sText_Trainer1WantsToBattle"] = 0x3FD366, - ["sText_Trainer1WinText"] = 0x3FB232, - ["sText_Trainer1WithdrewPkmn"] = 0x3FD522, - ["sText_Trainer2LoseText"] = 0x3FB21C, - ["sText_Trainer2WinText"] = 0x3FB262, - ["sText_TryToLearnMove1"] = 0x3FB2B6, - ["sText_TryToLearnMove2"] = 0x3FB2D1, - ["sText_TryToLearnMove3"] = 0x3FB2FC, - ["sText_TwoLinkTrainersDefeated"] = 0x3FCCF8, - ["sText_TwoLinkTrainersSentOutPkmn"] = 0x3FD41E, - ["sText_TwoLinkTrainersWantToBattle"] = 0x3FD397, - ["sText_TwoWildFled"] = 0x3FCD9F, - ["sText_TwoWildPkmnAppeared"] = 0x3FD2BF, - ["sText_UproarKeptPkmnAwake"] = 0x3FBDC4, - ["sText_UsingItemTheStatOfPkmnRose"] = 0x3FCB75, - ["sText_WildFled"] = 0x3FCD92, - ["sText_WildPkmnAppeared"] = 0x3FD284, - ["sText_WildPkmnAppeared2"] = 0x3FD297, - ["sText_WildPkmnAppearedPause"] = 0x3FD2AA, - ["sText_WildPkmnFled"] = 0x3FCCD2, - ["sText_XFoundOneY"] = 0x3FC591, - ["sText_YourFoesWeakGetEmPkmn"] = 0x3FD497, - }, -} diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 7e4e2f7ac..21bd5617e 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -231,6 +231,11 @@ function TextBox.registerInto(registry, _, owner) end function TextBox.substitute(game, text) + if type(text) == "string" and game.data and game.data._romTextGeneration == 2 then + -- Prose end markers are control metadata, not visible glyphs. The + -- native Gen-2 battle/UI paths strip them before their textbox pass. + text = text:gsub("{DONE}", ""):gsub("{PROMPT}", "") + end local Tokens = require("src.script.Tokens") local handlers = game.data and game.data.tokens or TextBox.TOKENS return Tokens.expand(game, text, handlers) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 4e84e00b8..19e8a0ebb 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -236,9 +236,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts) -- id shown lets those prompts reproduce it when the writetext that printed -- it lived inside a `scall` and so could not be folded at compile time. if textId ~= nil then ctx.g2LastText = textId end - local text = textId ~= nil and ctx.game.data.text[textId] or nil + local text, private = require("src.script.ScriptText").get(ctx.game.data, textId) + if private and (text == nil or text == "") then text = "..." end local literal=false - if not text and ctx.overworld then + if not text and not private and ctx.overworld then text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) end if not text and type(textId) == "string" and textId:match("^TEXT_B%d+_%d+$") then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index e5e73eac7..99b4da696 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -31,8 +31,8 @@ end -- BattleState:say takes text that is already expanded, not a label. local function resolvedText(ctx, textId) local data = ctx.game.data - local text = data.text[textId] - if not text and ctx.overworld then + local text, private = require("src.script.ScriptText").get(data, textId) + if not text and not private and ctx.overworld then text = data:resolveText(ctx.overworld.map.def.label, textId) end text = text or textId diff --git a/src/script/ScriptText.lua b/src/script/ScriptText.lua new file mode 100644 index 000000000..7b8d6398a --- /dev/null +++ b/src/script/ScriptText.lua @@ -0,0 +1,12 @@ +-- API-3 script labels can have the same spelling as engine prose without +-- sharing a value. A present row with nil value masks a removed script text. +local M = {} +function M.get(data, id) + local private = data and data._portableScriptText + local row = private and private[id] + if row then + return row.value, true + end + return data and data.text and data.text[id] or nil, false +end +return M diff --git a/src/ui/Gen3StorageMenu.lua b/src/ui/Gen3StorageMenu.lua index 24a88f5c2..366328cca 100644 --- a/src/ui/Gen3StorageMenu.lua +++ b/src/ui/Gen3StorageMenu.lua @@ -45,9 +45,9 @@ function Gen3StorageMenu.record(game) local pc = c and c.gen3PCMenu local box = pc and pc.storage if type(box) == "table" and type(box.rows) == "table" and #box.rows > 0 then - return box + return require("src.mods.RomTextContent").storageView(game, box) end - return FALLBACK + return require("src.mods.RomTextContent").storageView(game, FALLBACK) end -- The three that open the grid, in the cartridge's own order. diff --git a/tests/drivers/rom_text.lua b/tests/drivers/rom_text.lua new file mode 100644 index 000000000..da4b2460a --- /dev/null +++ b/tests/drivers/rom_text.lua @@ -0,0 +1,85 @@ +-- Bounded imported-data proof: native prose reader, storage UI and VM isolation. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported version") + local e = assert(game.mods.exports.portable_rom_text, "install public prose example") + assert(e.available, "native prose registry unavailable") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(4) + end + assert(title, "native title missing") + title:onNewGame() + local world = assert(game.overworld, "native New Game missing") + game.save.player.name = "PROBE" + game.stack.states = { world } + if V.generation() == 2 then + local b = setmetatable({ data = game.data }, { __index = require("src.battle.BattleState") }) + local value = b:romText("_WokeUpText", "%s fallback", "PROBE") + assert(value == "PROBE PROSE{DONE}", "native battle prose helper ignored override") + assert(game.data.text._WokeUpText == e.after, "legacy native label reader missed projection") + local box = require("src.render.TextBox").new(game, value) + game.stack:push(box) + assert(table.concat(box.pages[1], "|") == "PROBE PROSE", "native box printed prose end marker") + U.wait(10) + for _ = 1, 50 do + if game.stack:top() == world then + break + end + U.tap(game, "a") + U.wait(3) + end + assert(game.stack:top() == world, "native prose box did not close") + assert( + e.scriptBefore == nil and e.scriptAfter == "PRIVATE SCRIPT", + "same-label registry isolation failed" + ) + local runner = require("src.script.ScriptRunner").new(game, world) + runner:run({ { "show_text", "_WokeUpText" } }) + local scriptBox = assert(game.stack:top()) + assert(table.concat(scriptBox.pages[1], "|") == "PRIVATE SCRIPT", "native VM used engine prose") + U.wait(10) + for _ = 1, 50 do + if game.stack:top() == world then + break + end + U.tap(game, "a") + U.wait(3) + end + assert(game.stack:top() == world and not runner:isRunning(), "native private script did not resume") + else + assert(V.generation() == 3, "driver requires imported Gen 2/3") + local menu = require("src.ui.Gen3StorageMenu").new(game, {}) + game.stack:push(menu) + for i = 1, 5 do + assert(menu.items[i].label == "MOD_ROW_" .. i, "native storage label binding missing") + assert(menu.items[i].describe == "MOD_DESC_" .. i, "native storage description binding missing") + end + U.wait(10) + U.tap(game, "b") + U.wait(6) + assert(game.stack:top() == world, "native storage menu did not close") + local again = require("src.ui.Gen3StorageMenu").new(game, {}) + assert(again.items[1].label == "MOD_ROW_1", "native reopened storage lost prose overlay") + game.stack:push(again) + U.wait(6) + U.tap(game, "b") + U.wait(6) + assert(game.stack:top() == world, "reopened native storage menu did not close") + end + local ok, err = pcall(function() + e.api.content.rom_text:override("_WokeUpText", "LATE") + end) + assert(not ok and tostring(err):find("frozen", 1, true), "late public prose write was not frozen") + print("PORTABILITY_ROM_TEXT_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index f15031ed1..b765fcc8d 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -70,7 +70,8 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': shared simulation/world call sites available') local registries = true for name in pairs(Schemas.REGISTRIES) do - local expected = (name ~= 'held_items' and name ~= 'marts') or GameVersion.generation(version) == 2 + local expected = name == 'rom_text' and (GameVersion.generation(version) == 2 or GameVersion.generation(version) == 3) + or (name ~= 'rom_text' and ((name ~= 'held_items' and name ~= 'marts') or GameVersion.generation(version) == 2)) registries = registries and (expected and yes(api, 'content', name) or not expected and no(api, 'content', name)) end diff --git a/tests/engine/rom_text_authority.lua b/tests/engine/rom_text_authority.lua new file mode 100644 index 000000000..9a355786d --- /dev/null +++ b/tests/engine/rom_text_authority.lua @@ -0,0 +1,204 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local F = dofile("tests/rom_text/fixture.lua") +local original = V.get() +local function reload(data) + -- Keep the registry under test, excluding the known duplicate builtin + -- fixture registration issue from the unrelated existing baseline suites. + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "text" and root ~= "constants" then + data[root] = nil + end + end + return T.sdk.loadNone({ data = data }) +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + for _, config in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local api, format = unpack(config) + local f = F.new(".", "gen2", version, "replace", api, format) + T.eq(#f.result.errors, 0, "prose entry loads in each host mode") + T.eq(f.e.api:supports("content", "rom_text"), true, "public capability has a native prose target") + T.eq(f.e.api.content.rom_text.available, true, "prose facade advertises availability") + T.eq(f.e.api.content.rom_text:has(f.label), true, "effective prose is queryable") + T.eq(f.e.api.content.rom_text:get("MISSING"), nil, "missing prose remains absent") + for id, value in f.e.api.content.rom_text:each() do + T.eq(type(id), "string", "prose enumeration uses labels") + T.eq(type(value), "string", "prose enumeration returns source/native strings") + T.check(not id:match("^TEXT_S") and id ~= "55:4067", "VM addresses never appear as prose") + end + T.eq( + f.data.text._WokeUpText, + f.gen == 2 and "{USER} is awake!" or "{USER} woke up!", + "native Gen-2 label readers consume prose while GBA script storage stays intact" + ) + if f.gen == 2 then + f.data.text.UNLISTED_LEGACY = "UNCHANGED" + local b = setmetatable({ data = f.data }, { __index = require("src.battle.BattleState") }) + T.eq( + b:romText("UNLISTED_LEGACY", "fallback"), + "UNCHANGED", + "untouched legacy prose keeps native fallback" + ) + else + local native = f.data.constants.gen3PCMenu.storage + T.eq(native.rows[1], "BASE1", "prose view does not edit imported constants") + local game = { data = f.data, save = { player = { name = "PROBE" }, options = {} } } + local view = require("src.ui.Gen3StorageMenu").record(game) + T.eq(view.rows[1], "TAKE FRIEND", "native record view reads prose override") + view.rows[1] = "EXTERNAL VIEW" + T.eq( + require("src.ui.Gen3StorageMenu").record(game).rows[1], + "TAKE FRIEND", + "overridden view is detached" + ) + T.eq(native.rows[1], "BASE1", "view mutation cannot edit the imported cartridge record") + f.data.constants.gen3PCMenu.storage = nil + T.eq( + require("src.ui.Gen3StorageMenu").record(game).rows[1], + "TAKE FRIEND", + "legacy fallback screen consumes label override" + ) + end + f.close() + local r = reload(f.data) + T.eq(f.data.romText, nil, "reload retires untouched installed prose map") + T.eq(f.data._romTextGeneration, nil, "reload retires generation routing") + T.eq(f.data._romTextTouched, nil, "reload retires tombstone routing") + r.release() + local edited = F.new(".", "gen2", version, "replace", api, format) + edited.data.romText[edited.label] = "EXTERNAL" + edited.close() + local keep = reload(edited.data) + T.eq(edited.data.romText[edited.label], "EXTERNAL", "reload preserves intervening native prose edit") + keep.release() + local unrelated = F.new(".", "gen2", version, "replace", api, format) + unrelated.data.romText.UNRELATED = "EXTERNAL" + unrelated.close() + local keepOther = reload(unrelated.data) + T.eq(unrelated.data.romText.UNRELATED, "EXTERNAL", "retirement preserves unrelated native prose") + T.eq(unrelated.data.romText[unrelated.label], nil, "retirement removes owned value beside external edit") + if unrelated.gen == 2 then + local b = setmetatable({ data = unrelated.data }, { __index = require("src.battle.BattleState") }) + T.eq( + b:romText(unrelated.label, "%s fallback", "GOLD"), + "GOLD woke up!", + "retirement also removes owned fallback mask" + ) + end + keepOther.release() + end +end +for _, api in ipairs({ 2, 3 }) do + for _, mode in ipairs({ "same-label", "namespace-prefix", "empty-script" }) do + local f = F.new(".", "gen2", "gold", mode, api, "unified") + T.eq(#f.result.errors, 0, "isolated script namespace loads") + T.eq(f.prose(), "GOLD is awake!", "private script source does not replace engine prose") + for id in pairs(f.data.text) do + T.check( + not id:match("^__portable_script_text__:"), + "synthetic script keys do not leak into legacy storage" + ) + end + T.eq( + f.data.text._WokeUpText, + "{USER} is awake!", + "raw Gen-2 engine label reads prose, not private script source" + ) + T.eq( + f.e.api.content.text:get(f.label), + mode == "empty-script" and "" or "SCRIPT ONLY", + "public script view retains its value" + ) + local proseSeen, scriptSeen = 0, 0 + for id in f.e.api.content.rom_text:each() do + if id == f.label then + proseSeen = proseSeen + 1 + end + end + for id in f.e.api.content.text:each() do + if id == f.label then + scriptSeen = scriptSeen + 1 + end + end + T.eq(proseSeen, 1, "prose enumerates its label once") + T.eq(scriptSeen, 1, "script enumerates separate same-spelling label once") + f.close() + local r = reload(f.data) + T.eq(f.data._portableScriptText[f.label], nil, "reload retires private script record") + T.eq(f.data.romText, nil, "reload retires corresponding prose independently") + r.release() + end + local masked = F.new( + ".", + "gen2", + "gold", + "none", + api, + "unified", + [[ + mod.content.rom_text:override("_WokeUpText", "{USER} ENGINE") + mod.content.text:override("_WokeUpText", "SCRIPT") + mod.content.text:remove("_WokeUpText") + ]] + ) + T.eq(masked.e.api.content.text:get("_WokeUpText"), nil, "private tombstone remains absent to public reader") + T.eq(masked.script(), "...", "private tombstone cannot reveal engine text through native VM") + T.eq(masked.prose(), "GOLD ENGINE", "private tombstone does not remove engine prose") + masked.close() + local collision = F.new(".", "gen2", "gold", "same-label", api, "unified", nil, function(data) + data.text["__portable_script_text__:_WokeUpText"] = "PREEXISTING" + end) + T.eq(collision.e.scriptAfter, "SCRIPT ONLY", "namespace reservation avoids preexisting native record") + T.eq( + collision.data.text["__portable_script_text__:_WokeUpText"], + "PREEXISTING", + "existing authored prefix bytes survive" + ) + T.eq(collision.script(), "SCRIPT ONLY", "reserved private record reaches actual VM") + collision.close() + for _, body in ipairs({ + 'mod.content.rom_text:override("_WokeUpText","EARLY"); mod.content.rom_text:override("BAD",{})', + 'mod.content.rom_text:override("_WokeUpText","EARLY"); error("ROLL BACK")', + 'mod.content.text:register("_WokeUpText","EARLY SCRIPT"); error("ROLL BACK")', + }) do + local failed = F.new(".", "gen2", "gold", "none", api, "unified", body) + T.check(#failed.result.errors > 0, "invalid/failed prose or script entry is rejected") + T.eq(failed.data.romText, nil, "failed prose entry adds no native map") + T.eq(failed.data._portableScriptText, nil, "failed entry adds no private native script records") + T.eq(next(failed.result.loader.content.rom_text.ops), nil, "failed owner leaves no prose operations") + T.eq(failed.prose(), "GOLD woke up!", "failed entry leaves native prose intact") + failed.close() + end +end +for _, version in ipairs({ "red", "blue", "yellow", "platinum", "heartgold", "black", "white" }) do + local f = F.new(".", "gen2", version, "replace", 3, "unified") + local handle = f.e.api.content.rom_text + T.eq(handle.available, false, "unavailable generation has honest metadata") + local value, reason = handle:override("UNUSED", "unused") + T.eq(value, nil, "unavailable write is inert") + T.check(type(reason) == "string", "unavailable write explains missing native target") + T.eq(handle:each()(), nil, "unavailable prose iterator is empty") + T.eq(next(f.result.loader.content.rom_text.ops), nil, "unavailable target receives no operations") + T.eq(f.data.romText, nil, "unavailable target receives no prose map") + f.close() +end +local endMarker = F.new( + ".", + "gen2", + "gold", + "none", + 3, + "unified", + [[ + mod.content.rom_text:override("_WokeUpText", "{USER} awake{DONE}{PROMPT}") +]] +) +T.eq(endMarker.prose(), "GOLD awake{DONE}{PROMPT}", "native formatter retains ending metadata") +T.eq( + require("src.render.TextBox").substitute({ data = endMarker.data }, endMarker.prose()), + "GOLD awake", + "native prose textbox does not print ending metadata" +) +endMarker.close() +V.set(original) +T.finish("ROM prose ownership, namespace isolation and native storage views") diff --git a/tests/engine/rom_text_content.lua b/tests/engine/rom_text_content.lua new file mode 100644 index 000000000..f15f375b8 --- /dev/null +++ b/tests/engine/rom_text_content.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "red", "gold", "silver", "crystal", "firered", "emerald" }) do + dofile("tests/rom_text/cases.lua").run(".", "gen2", version, 3, "unified") +end +T.finish("portable engine prose and native storage readers") diff --git a/tests/engine/rom_text_labels.lua b/tests/engine/rom_text_labels.lua index b028120dd..28d8bb1e2 100644 --- a/tests/engine/rom_text_labels.lua +++ b/tests/engine/rom_text_labels.lua @@ -8,15 +8,16 @@ T.eq(L.forVersion("ruby"), nil, "unregistered host GBA profile has no guessed of T.eq(L.forVersion("gold")._WokeUpText, true, "Gen-2 prose label is known") T.eq(L.forVersion("gold")["55:4067"], nil, "VM bank/address is not engine prose") T.eq(L.forVersion("gold").TEXT_S55_4067, nil, "host physical VM key is not engine prose") -T.eq(L.address("gold", "_WokeUpText"), nil, "Gen-2 labels are not mistaken for GBA addresses") -T.eq(L.address("firered", "gText_BigGuy"), 0x417FBB, "FireRed exact storage-label address") -T.eq(L.address("emerald", "gText_BigGuy"), 0x5EB5F9, "Emerald exact storage-label address") -T.eq(L.address("firered", "sText_PkmnWokeUp"), 0x3FBCD1, "FireRed native battle-label address") -T.eq(L.address("emerald", "sText_PkmnWokeUp"), 0x5CA541, "Emerald native battle-label address") -local _, battle = L.address("emerald", "sText_PkmnWokeUp") -T.eq(battle, true, "battle code dialect remains explicit") -local _, ordinary = L.address("firered", "gText_BigGuy") -T.eq(ordinary, false, "field/menu code dialect remains explicit") -T.eq(L.address("emerald", "UNKNOWN_MOD_LABEL"), nil, "unknown label does not acquire an offset") -T.eq(L.forVersion("emerald"), L.forVersion("emerald"), "native label dictionary is cached") +for _, version in ipairs({ "firered", "emerald" }) do + local facts = dofile("tests/rom_text/labels_" .. version .. ".lua") + local count = 0 + for label, offset in pairs(facts.text) do + T.eq(type(label), "string", "semantic storage label is named") + T.check(type(offset) == "number" and offset > 0, "semantic storage binding has exact native address") + count = count + 1 + end + T.eq(count, 10, "all five storage actions and descriptions have provenance") + T.eq(next(facts.battle), nil, "storage actions are ordinary text, not battle-code strings") +end +T.eq(L.forVersion("gold"), L.forVersion("gold"), "native Gen-2 label dictionary is cached") T.finish("native prose-label provenance facts") diff --git a/tests/rom_text/cases.lua b/tests/rom_text/cases.lua new file mode 100644 index 000000000..b8ea6cc31 --- /dev/null +++ b/tests/rom_text/cases.lua @@ -0,0 +1,86 @@ +local C = {} +function C.run(root, backend, version, api, format) + local F = dofile(root .. "/tests/rom_text/fixture.lua") + local rows = {} + local gen = require("src.core.GameVersion").generation(version) + if gen ~= 2 and gen ~= 3 then + local f = F.new(root, backend, version, "replace", api, format) + f.T.eq(f.e.available, false, "unsupported generation does not advertise engine prose") + f.close() + return { { available = false } } + end + for _, mode in ipairs({ + "replace", + "patch", + "register", + "remove", + "delete", + "rollback", + "no-mod", + "same-label", + "namespace-prefix", + "empty-script", + }) do + if (mode ~= "same-label" and mode ~= "namespace-prefix" and mode ~= "empty-script") or gen == 2 then + local f = F.new(root, backend, version, mode, api, format) + local failed = mode == "rollback" or gen == 2 and mode == "register" + f.T.eq(#f.result.errors > 0, failed, "prose registry load outcome " .. mode) + local a, b = f.prose() + if + mode == "replace" + or mode == "patch" + or mode == "same-label" + or mode == "namespace-prefix" + or mode == "empty-script" + or gen == 3 and mode == "register" + then + f.T.eq(a, gen == 2 and "GOLD is awake!" or "TAKE FRIEND", "actual native reader sees effective prose") + elseif gen == 2 and (mode == "remove" or mode == "delete") then + f.T.eq(a, "GOLD fallback", "removed Gen-2 engine prose uses native fallback") + else + f.T.eq(a, gen == 2 and "GOLD woke up!" or "BASE1", "rollback/default uses original native prose") + end + local row = { mode = mode, failed = failed, native = a, description = b } + if f.e then + row.before, row.after = f.e.before, f.e.after + f.T.raises(function() + f.e.api.content.rom_text:override(f.label, "LATE") + end, "frozen", "late prose write rejected") + end + if mode == "same-label" or mode == "namespace-prefix" or mode == "empty-script" then + f.T.eq(f.e.scriptBefore, nil, "engine-prose label is absent in VM registry") + f.T.eq( + f.e.scriptAfter, + mode == "empty-script" and "" or "SCRIPT ONLY", + "same-spelling script gets separate record" + ) + f.T.eq( + f.script(), + mode == "empty-script" and "..." or "SCRIPT ONLY", + "actual script reader sees its own namespace" + ) + row.script = f.script() + end + if mode == "namespace-prefix" then + f.T.eq(f.e.prefixValue, "AUTHORED PREFIX", "authored prefix spelling remains its own ID") + row.prefix = f.script("__portable_script_text__:" .. f.label) + f.T.eq(row.prefix, "AUTHORED PREFIX", "actual VM distinguishes authored prefix label") + end + rows[#rows + 1] = row + f.close() + end + end + if gen == 3 then + local all = F.new(root, backend, version, "storage-all", api, format) + all.T.eq(#all.result.errors, 0, "all storage bindings load") + local native = all.storageRows() + for i, row in ipairs(native) do + all.T.eq(row.label, "MOD_ROW_" .. i, "native storage action binding " .. i) + all.T.eq(row.description, "MOD_DESC_" .. i, "native storage description binding " .. i) + end + rows[#rows + 1] = { mode = "storage-all", native = native } + all.close() + end + return rows +end +return C diff --git a/tests/rom_text/fixture.lua b/tests/rom_text/fixture.lua new file mode 100644 index 000000000..759247289 --- /dev/null +++ b/tests/rom_text/fixture.lua @@ -0,0 +1,145 @@ +local F = {} +function F.new(root, backend, version, mode, api, format, body, prepare) + local T, V = require("tests.modkit"), require("src.core.GameVersion") + V.set(version) + local gen, J = V.generation(), require("src.link.Json") + local names = + { "gText_WithdrawPokemon", "gText_DepositPokemon", "gText_MovePokemon", "gText_MoveItems", "gText_SeeYa" } + local desc = { + "gText_WithdrawMonDescription", + "gText_DepositMonDescription", + "gText_MoveMonDescription", + "gText_MoveItemsDescription", + "gText_SeeYaDescription", + } + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or { constants = { generation = gen } } + local label = gen == 2 and "_WokeUpText" or names[1] + local value = gen == 2 and "{USER} is awake!" or "TAKE FRIEND" + data.text = { _WokeUpText = "{USER} woke up!" } + if backend == "gen1" then + data.gen2Text = { ["55:4067"] = "SCRIPT BASE" } + else + data.text.TEXT_S55_4067 = "SCRIPT BASE" + end + if gen == 3 then + if backend == "gen1" then + data.gen3RomText, data.gen3Text = {}, {} + end + data.constants = data.constants or {} + local storage = { rows = {}, describe = {} } + for i in ipairs(names) do + storage.rows[i], storage.describe[i] = "BASE" .. i, "DETAIL" .. i + end + data.constants.gen3PCMenu = { storage = storage } + end + if prepare then + prepare(data, gen) + end + local name, files = "portable_rom_text", {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/" .. name .. "/" .. path, "rb")) + files["mods/" .. name .. "/" .. path] = f:read("*a") + f:close() + end + local m = J.decode(files["mods/" .. name .. "/manifest.json"]) + m.api, m.content_format, m.games, m.gen2compat = api, format, { version }, api < 3 and true or nil + files["mods/" .. name .. "/manifest.json"] = J.encode(m) + files["mods/" .. name .. "/case.txt"] = mode + files["mods/" .. name .. "/label.txt"] = label + files["mods/" .. name .. "/source.txt"] = value + if mode == "same-label" or mode == "namespace-prefix" or mode == "empty-script" then + files["mods/" .. name .. "/script.txt"] = mode == "empty-script" and "" or "SCRIPT ONLY" + end + if body then + files["mods/" .. name .. "/main.lua"] = "local mod=...; mod.exports.api=mod; " .. body + end + if mode == "no-mod" then + files = {} + end + local r = T.sdk.loadMods( + mode == "no-mod" and {} or { "mods/" .. name }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + local f = { + T = T, + result = r, + data = data, + gen = gen, + label = label, + source = value, + e = r.loader.exports[name], + } + local restore = function() end + if backend == "gen1" and gen == 3 then + local IR, Rom = require("src.core.game3.scripting.text_ir"), require("src.core.game3.rom_text") + local Space = require("src.core.game3.scripting.space") + local oldBundle, oldOverrides = Space.bundle, Rom.overrides + local text = {} + for i, key in ipairs(names) do + text[key] = IR.fromAscii("BASE" .. i) + end + for i, key in ipairs(desc) do + text[key] = IR.fromAscii("DETAIL" .. i) + end + Space.bundle, Rom.overrides = { text = text }, data.gen3RomText + restore = function() + Space.bundle, Rom.overrides = oldBundle, oldOverrides + end + end + function f.prose() + if gen == 2 then + if backend == "gen1" then + return require("src.core.RomText")(data, label, "%s fallback", "GOLD") + end + local b = setmetatable({ data = data }, { __index = require("src.battle.BattleState") }) + return b:romText(label, "%s fallback", "GOLD") + elseif gen == 3 then + if backend == "gen1" then + local row = require("src.ui.game3.pc_menu")._storageOptions()[1] + return row.label, row.desc + end + local game = { data = data, save = { player = { name = "PROBE" }, options = {} } } + local menu = require("src.ui.Gen3StorageMenu").new(game, {}) + return menu.items[1].label, menu.items[1].describe + end + end + function f.storageRows() + local out, rows = {}, nil + if backend == "gen1" then + rows = require("src.ui.game3.pc_menu")._storageOptions() + else + local game = { data = data, save = { player = { name = "PROBE" }, options = {} } } + rows = require("src.ui.Gen3StorageMenu").new(game, {}).items + end + for i, row in ipairs(rows) do + out[i] = { label = row.label, description = row.describe or row.desc } + end + return out + end + function f.script(id) + id = id or label + if backend == "gen1" then + local vm = require("src.script.gen2.Vm").new({}, data.gen2Text, nil, { showText = function() end }) + local co = coroutine.create(function() + vm:showText(id) + end) + local ok, row = coroutine.resume(co) + assert(ok, row) + return row.text + end + local game = { data = data, save = { player = { name = "PROBE" }, options = {} } } + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + require("src.render.Font").load(data) + local runner = require("src.script.ScriptRunner").new(game) + runner:run({ { "show_text", id } }) + local box = assert(game.stack:top()) + return table.concat(box.pages[1], "|") + end + function f.close() + restore() + r.release() + end + return f +end +return F diff --git a/tests/rom_text/format_cases.lua b/tests/rom_text/format_cases.lua index d7ac3203e..bed12088f 100644 --- a/tests/rom_text/format_cases.lua +++ b/tests/rom_text/format_cases.lua @@ -5,6 +5,7 @@ function C.run() local cases = { { "plain", "Native", "fallback", {} }, { "no-fallback", "Native", nil, {} }, + { "false-prose", false, "fallback", {} }, { "missing", nil, "fallback %s", { "VALUE" } }, { "one-slot", "{USER} wakes!", "%s fallback", { "GOLD" } }, { "two-slots", "{USER} used {MOVE}!", "%s/%s fallback", { "GOLD", "TACKLE" } }, diff --git a/tests/rom_text/labels_emerald.lua b/tests/rom_text/labels_emerald.lua new file mode 100644 index 000000000..82c341afd --- /dev/null +++ b/tests/rom_text/labels_emerald.lua @@ -0,0 +1,18 @@ +-- Generated label/address facts for emerald; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + text = { + ["gText_DepositMonDescription"] = 0x5EBBDE, + ["gText_DepositPokemon"] = 0x5EBB82, + ["gText_MoveItems"] = 0x5EBB9F, + ["gText_MoveItemsDescription"] = 0x5EBC35, + ["gText_MoveMonDescription"] = 0x5EBC04, + ["gText_MovePokemon"] = 0x5EBB92, + ["gText_SeeYa"] = 0x5EBBAA, + ["gText_SeeYaDescription"] = 0x5EBC6C, + ["gText_WithdrawMonDescription"] = 0x5EBBB2, + ["gText_WithdrawPokemon"] = 0x5EBB71, + }, + battle = {}, +} diff --git a/tests/rom_text/labels_firered.lua b/tests/rom_text/labels_firered.lua new file mode 100644 index 000000000..68eb6bf11 --- /dev/null +++ b/tests/rom_text/labels_firered.lua @@ -0,0 +1,18 @@ +-- Generated label/address facts for firered; no cartridge prose. +-- Gen1Recomp public import metadata at 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Regenerate with tools/rom-text-contract-check/generate-labels.py. +return { + text = { + ["gText_DepositMonDescription"] = 0x4185E2, + ["gText_DepositPokemon"] = 0x41857D, + ["gText_MoveItems"] = 0x41859A, + ["gText_MoveItemsDescription"] = 0x418642, + ["gText_MoveMonDescription"] = 0x418611, + ["gText_MovePokemon"] = 0x41858D, + ["gText_SeeYa"] = 0x4185A5, + ["gText_SeeYaDescription"] = 0x418681, + ["gText_WithdrawMonDescription"] = 0x4185AD, + ["gText_WithdrawPokemon"] = 0x41856C, + }, + battle = {}, +} diff --git a/tests/rom_text/worker.lua b/tests/rom_text/worker.lua new file mode 100644 index 000000000..5a20cbcd4 --- /dev/null +++ b/tests/rom_text/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = dofile(root .. "/tests/rom_text/cases.lua").run(root, backend, version, tonumber(api), format) +print("ROM_TEXT_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("portable native ROM prose") diff --git a/tools/rom-text-contract-check/README.md b/tools/rom-text-contract-check/README.md index d608aad9c..070cb8472 100644 --- a/tools/rom-text-contract-check/README.md +++ b/tools/rom-text-contract-check/README.md @@ -1,8 +1,9 @@ # ROM-text portability work This branch is preparing the separate `mod.content.rom_text` registry. The -formatter and provenance facts below are prerequisites; they do not advertise -the registry or complete native UI/battle label coverage yet. +registry now covers Gen-2 native battle prose and GBA Pokemon-storage actions. +Other GBA engine/UI readers still need explicit bindings; this is not blanket +coverage of every cartridge label. `check-format.py --gen1 /clean/gen1 --lua luajit` compares actual native GB/Gen-2 prose formatting against the clean reference at @@ -15,10 +16,10 @@ Printf-shaped strings do not invent dynamic slots in this native contract. facts from the reference's public import metadata. `--check` verifies all committed bytes without rewriting either checkout. No cartridge prose, ROM, importer implementation or source scripts are copied. Gen-2 manifests supply -prose-label membership; GBA profiles resolve their exact ordinary/battle text -addresses through their native symbol tables. Text and battle-code dialects -remain distinct. A lazy host accessor avoids loading unneeded profile tables -and refuses to guess addresses for absent profiles. +runtime prose-label membership. The GBA fixtures retain exact provenance for +the ten storage action/description labels that this change binds. The full +GBA catalog is not shipped as unused runtime data. A lazy host accessor avoids +loading unneeded Gen-2 profile tables. These facts establish provenance. They do not, by themselves, prove that a host menu reads a label dynamically: pre-extracted UI constants need explicit diff --git a/tools/rom-text-contract-check/check.py b/tools/rom-text-contract-check/check.py new file mode 100644 index 000000000..203ff3888 --- /dev/null +++ b/tools/rom-text-contract-check/check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Compare engine prose through both real SDKs and native battle/storage readers.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--host-api", type=int, choices=[2, 3], default=3) + p.add_argument("--report", type=Path) + a = p.parse_args() + source, host = a.gen1.resolve(), a.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + p.error("Gen1 must be clean at " + REFERENCE) + results = [] + for version in ["red", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine, api in [("gen1", source, 3), ("gen2", host, a.host_api)]: + run = subprocess.run([a.lua, str(host / "tests/rom_text/worker.lua"), str(host), + backend, version, str(api), "unified"], cwd=engine, + text=True, capture_output=True, timeout=45) + rows = [line.removeprefix("ROM_TEXT_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("ROM_TEXT_CONTRACT ")] + if run.returncode or len(rows) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(rows[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, "api": api, "observations": rows}) + if observations[0] != observations[1]: + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + raise AssertionError(f"{version}/{index}: reference={left!r}, host={right!r}") + raise AssertionError(f"{version}: different observation counts") + print(f"PASS {version}: {len(observations[0])} matching native engine-prose observations") + assert not git(source, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"reference_ref": REFERENCE, + "host_ref": git(host, "rev-parse", "HEAD"), "host_api": a.host_api, + "host_worktree_dirty": bool(git(host, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/rom-text-contract-check/generate-labels.py b/tools/rom-text-contract-check/generate-labels.py index 4619e0ff7..3318ab2e1 100644 --- a/tools/rom-text-contract-check/generate-labels.py +++ b/tools/rom-text-contract-check/generate-labels.py @@ -7,6 +7,12 @@ import re import subprocess +STORAGE_LABELS = [ + "gText_WithdrawPokemon", "gText_DepositPokemon", "gText_MovePokemon", "gText_MoveItems", "gText_SeeYa", + "gText_WithdrawMonDescription", "gText_DepositMonDescription", "gText_MoveMonDescription", + "gText_MoveItemsDescription", "gText_SeeYaDescription", +] + REFERENCE = '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91' ROOT = Path(__file__).resolve().parents[2] @@ -52,8 +58,12 @@ def main(): if run.returncode or len(lines) != 1: raise RuntimeError(run.stdout + run.stderr) facts = json.loads(lines[0]) + facts = {'text': {key: facts['text'][key] for key in STORAGE_LABELS}, 'battle': {}} content = header(version) + 'return {\n' for kind in ['text', 'battle']: + if not facts[kind]: + content += f' {kind} = {{}},\n' + continue content += f' {kind} = {{\n' for label, offset in sorted(facts[kind].items()): assert isinstance(offset, int) and 0 <= offset < 0x1000000, (version, label, offset) @@ -65,7 +75,8 @@ def main(): if not a.check: output.mkdir(parents=True, exist_ok=True) for version, content in generated.items(): - path = output / (version + '.lua') + path = (ROOT / 'tests/rom_text' / ('labels_' + version + '.lua') + if version in ['firered', 'emerald'] else output / (version + '.lua')) if a.check: if path.read_text() != content: raise AssertionError(f'stale label facts: {path}') From 901ea77f7f0bfb15b5f125ee0b3e6e3797da3b21 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:23:22 +0200 Subject: [PATCH 71/86] Document validated prose-registry contracts and remaining native readers --- docs/api3-portability.md | 3 +++ docs/api3-rom-text.md | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 81c50b687..bba8e5db0 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -184,3 +184,6 @@ See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for For shared script-text source strings, GBA placeholder names and native Gen-2 bank/address IDs, see [portable text sources](api3-text-sources.md). Cartridge prose and named GBA label bindings remain separate follow-up work. + +For separate label-keyed engine prose, native consumer coverage and remaining +GBA bindings, see [portable ROM prose](api3-rom-text.md). diff --git a/docs/api3-rom-text.md b/docs/api3-rom-text.md index bb04e7e9d..99b2b79d8 100644 --- a/docs/api3-rom-text.md +++ b/docs/api3-rom-text.md @@ -81,5 +81,40 @@ reference API-2/unified. The public consumer has no engine imports. Source formatting, namespace isolation and native reader observations are separate from imported-data/UI smoke tests. No ROM, imported cache or save is included. The LÖVE driver is `tests/drivers/rom_text.lua`; use the isolated testfork -launcher. Validation of the complete combined runtime remains required before -publishing this working branch as a PR. +launcher. Native smoke and full-suite results are recorded below. + +## Validated runtime + +Feature `52541d1b0160de2361a2bffe15fd4fad4eb9a20c`; combined +`62f8902617255195b4a12b176f171925c49209ec`. Later documentation changes do not +change the tested runtime. + +- **175/175** public/native registry checks, **510/510** ownership/isolation/ + retirement checks, **57/57** native formatting checks, **52/52** provenance + checks. Existing script-text contracts **3,700/3,700** and **234/234** pass. +- **24 isolated SDK workers / 94 paired native observations** at both runtime + snapshots, using the same public consumer. Red gates availability; Gold, + Silver, Crystal, FireRed and Emerald compare supported behavior. Host API + 2/unified compares with reference API 3; reference API 2 remains native. +- Separate real-formatter comparison: **8 native engine workers / 96 paired + observations**, Red/Gold/Silver/Crystal, against the clean pinned reference. +- The source-derived prose membership and ten GBA storage bindings regenerate + exactly from pinned public import metadata. No cartridge prose is copied. +- Feature **135/145 engine suites**, SDK **3/3**, B30 pass: exactly the same + **26 FAIL lines / ten baseline suites** as #170. Independent baseline + repairs remain #128–134. Combined **190/190**, SDK **3/3**, B30 pass. +- Imported-data **LÖVE 11.5 / Gold, FireRed, Emerald** at the combined runtime: + unchanged consumer, real title/New Game; Gold's native battle-prose helper + and textbox/end-marker handling, separate same-label native script display, + A-close/script resume; GBA real storage menu with all five action labels and + descriptions, B-close/reopen; frozen late writes. This does not execute a + complete battle/wakeup turn. Isolated identity, in-memory New Game, no + normal SAVE, full playthrough, fresh full-ROM reimport or pixel-equivalence + claim. Owned temporary example removed with hash guards. +- Unchanged #170 rejects the example because it has no registry; its actual + native Gold prose and GBA storage reader retain their original values. +- Imported Red regressions: battle **468/468**, save conversion **95/95**, + parity J **73/73**, K **22/22**. No external binary save fixture supplied. +- New Lua formatting, workflow actionlint, Python compile, deterministic + generation and diff whitespace checks pass. Hosted CI status is reported + separately in the PR and does not inherit these local results. From 37176cc15e26d0804f8825e233ca185ee5c9599e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:51:27 +0200 Subject: [PATCH 72/86] Bind portable ROM prose to native GBA party and item-PC menus --- .github/workflows/gba-menu-prose-contract.yml | 38 +++ docs/portable-gba-menu-prose.md | 47 ++++ examples/portable_gba_menu_prose/main.lua | 62 +++++ .../portable_gba_menu_prose/manifest.json | 8 + src/ui/GbaProse.lua | 73 ++++++ src/ui/Gen3ItemPcFRLG.lua | 10 +- src/ui/Gen3ItemPcSubmenu.lua | 12 +- src/ui/Gen3PartyMenu.lua | 16 +- src/ui/Gen3PlayerPC.lua | 6 +- tests/drivers/gba_menu_prose.lua | 127 ++++++++++ tests/engine/gba_menu_prose.lua | 6 + tests/engine/gba_menu_prose_native.lua | 93 +++++++ tests/gba_menu_prose/cases.lua | 66 +++++ tests/gba_menu_prose/fixture.lua | 230 ++++++++++++++++++ tests/gba_menu_prose/label-facts.json | 39 +++ tests/gba_menu_prose/worker.lua | 6 + tools/gba-menu-prose-contract-check/README.md | 27 ++ tools/gba-menu-prose-contract-check/check.py | 54 ++++ .../export-facts.lua | 40 +++ .../generate-facts.py | 42 ++++ 20 files changed, 989 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/gba-menu-prose-contract.yml create mode 100644 docs/portable-gba-menu-prose.md create mode 100644 examples/portable_gba_menu_prose/main.lua create mode 100644 examples/portable_gba_menu_prose/manifest.json create mode 100644 src/ui/GbaProse.lua create mode 100644 tests/drivers/gba_menu_prose.lua create mode 100644 tests/engine/gba_menu_prose.lua create mode 100644 tests/engine/gba_menu_prose_native.lua create mode 100644 tests/gba_menu_prose/cases.lua create mode 100644 tests/gba_menu_prose/fixture.lua create mode 100644 tests/gba_menu_prose/label-facts.json create mode 100644 tests/gba_menu_prose/worker.lua create mode 100644 tools/gba-menu-prose-contract-check/README.md create mode 100644 tools/gba-menu-prose-contract-check/check.py create mode 100644 tools/gba-menu-prose-contract-check/export-facts.lua create mode 100644 tools/gba-menu-prose-contract-check/generate-facts.py diff --git a/.github/workflows/gba-menu-prose-contract.yml b/.github/workflows/gba-menu-prose-contract.yml new file mode 100644 index 000000000..8ca93b968 --- /dev/null +++ b/.github/workflows/gba-menu-prose-contract.yml @@ -0,0 +1,38 @@ +name: portable GBA menu prose +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gba-menu-prose-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native menu integration + run: | + luajit tests/engine/gba_menu_prose.lua + luajit tests/engine/gba_menu_prose_native.lua + - name: Compare native readers through both SDKs + run: | + mkdir -p /tmp/gba-menu-prose + python3 tools/gba-menu-prose-contract-check/generate-facts.py --gen1 .contract-reference/gen1 --check + python3 tools/gba-menu-prose-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-menu-prose/api3.json + python3 tools/gba-menu-prose-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/gba-menu-prose/host2.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gba-menu-prose-contract + path: /tmp/gba-menu-prose + if-no-files-found: ignore diff --git a/docs/portable-gba-menu-prose.md b/docs/portable-gba-menu-prose.md new file mode 100644 index 000000000..fc686b23f --- /dev/null +++ b/docs/portable-gba-menu-prose.md @@ -0,0 +1,47 @@ +# Portable native GBA menu prose + +`mod.content.rom_text` now reaches the registered FireRed/Emerald party action +readers and their separate cancel buttons. FireRed's actual Item-PC withdrawal +flow also reads overrides for its title, cancel list row and three submenu labels. +The same public consumer runs on both engines; no renderer replacement or engine +module import is needed. See `examples/portable_gba_menu_prose`. + +| Action | FireRed label | Emerald label | +| --- | --- | --- | +| SUMMARY | `sCursorOptions[0]` | `gText_Summary5` | +| SWITCH | `sCursorOptions[1]` | `gText_Switch2` | +| CANCEL action | `sCursorOptions[2]` | `gText_Cancel2` | +| ITEM | `sCursorOptions[3]` | `gText_Item` | +| GIVE | `sCursorOptions[4]` | `gMenuText_Give` | +| TAKE | `sCursorOptions[5]` | `gText_Take` | +| SHIFT | `sCursorOptions[10]` | `gText_Shift` | +| SEND OUT | `sCursorOptions[11]` | `gText_SendOut` | +| ENTER | `sCursorOptions[12]` | `gText_Enter` | +| NO ENTRY | `sCursorOptions[13]` | `gText_NoEntry` | +| STORE | `sCursorOptions[14]` | `gText_Store` | +| Party cancel button | `gFameCheckerText_Cancel` | `gText_Cancel` | + +FireRed's Item-PC title is `gText_WithdrawItem`; its submenu reads +`gText_Withdraw`, `gOtherText_Give` and `gFameCheckerText_Cancel`. The last label +is intentionally shared with the party button, matching the reference reader. +Emerald's `gText_Take2` is a separate label; overriding it does not replace the +primary TAKE action. Unmapped roles do not borrow a label just because their +English fallback happens to match. A custom/TOSS title retains its own text. + +Unmodified and removed overrides keep native imported labels or the existing +older-cache/`strings` fallback. Empty strings remain empty. Reads leave imported +constants untouched. Menu action identities and input behavior are independent +of translated label text. Source controls are printed through the existing +shared source-string conversion and native textbox token expansion. + +The contracts compare real reference action producers and captured native font +output for the party cancel button and FireRed Item PC. Emerald's generated +manifest fixture exercises the actual profile-specific branch. Separate host +tests exercise battle/order/item action construction, native submenu rendering, +selection identities, custom-title isolation and older-cache fallback. +Run `tools/gba-menu-prose-contract-check/check.py` as described in its README. + +This does not bind every party prompt, field-move name, mail/trade message, +Item-PC quantity/result dialog or battle message. Emerald's different Item-PC +screen and DS screens remain separate work. Text binding is not pixel/layout, +complete-playthrough or universal printer equivalence. diff --git a/examples/portable_gba_menu_prose/main.lua b/examples/portable_gba_menu_prose/main.lua new file mode 100644 index 000000000..76ef61e94 --- /dev/null +++ b/examples/portable_gba_menu_prose/main.lua @@ -0,0 +1,62 @@ +-- One public consumer on both engines. These are native profile labels, +-- not renderer replacements or English-word substitutions. +local mod = ... +mod.exports.api = mod +if not mod:supports("content", "rom_text") then + return +end +local mode = mod:read("case.txt") or "all" +local profile = mod:read("profile.txt") or "both" +local labels = { + { 0, "gText_Summary5", "SUMMARY" }, + { 1, "gText_Switch2", "SWITCH" }, + { 2, "gText_Cancel2", "CANCEL" }, + { 3, "gText_Item", "ITEM" }, + { 4, "gMenuText_Give", "GIVE" }, + { 5, "gText_Take", "TAKE" }, + { 10, "gText_Shift", "SHIFT" }, + { 11, "gText_SendOut", "SEND_OUT" }, + { 12, "gText_Enter", "ENTER" }, + { 13, "gText_NoEntry", "NO_ENTRY" }, + { 14, "gText_Store", "STORE" }, +} +local prose = mod.content.rom_text +for _, row in ipairs(labels) do + if + mode ~= "cancel-only" and mode ~= "take-only" + or mode == "cancel-only" and row[3] == "CANCEL" + or mode == "take-only" and row[3] == "TAKE" + then + for _, entry in ipairs({ { "firered", "sCursorOptions[" .. row[1] .. "]" }, { "emerald", row[2] } }) do + if profile == "both" or profile == entry[1] then + if mode == "remove" then + prose:remove(entry[2]) + else + local value = mode == "empty" and "" or "MOD_" .. row[3] + if mode == "controls" and row[3] == "SUMMARY" then + value = "A\nB\fC\vD" + end + prose:override(entry[2], value) + end + end + end + end +end +if mode ~= "cancel-only" and mode ~= "take-only" then + if profile ~= "emerald" then + prose:override("gFameCheckerText_Cancel", "MOD_FOOTER") + end + if profile ~= "firered" then + prose:override("gText_Cancel", "MOD_FOOTER") + end + if mode ~= "foreign" then + prose:override("gText_WithdrawItem", "MOD_PC_TITLE") + prose:override("gText_Withdraw", "MOD_PC_WITHDRAW") + prose:override("gOtherText_Give", "MOD_PC_GIVE") + -- Distinct Emerald TAKE row: it must not replace the primary TAKE. + prose:override("gText_Take2", "OTHER_TAKE") + end +end +if mode == "rollback" then + error("intentional menu-prose rollback") +end diff --git a/examples/portable_gba_menu_prose/manifest.json b/examples/portable_gba_menu_prose/manifest.json new file mode 100644 index 000000000..dd57874f1 --- /dev/null +++ b/examples/portable_gba_menu_prose/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_gba_menu_prose", + "name": "Portable GBA menu prose", + "version": "1.0.0", + "api": 3, + "games": ["firered", "emerald"], + "entry": "main.lua" +} diff --git a/src/ui/GbaProse.lua b/src/ui/GbaProse.lua new file mode 100644 index 000000000..e2cbb073a --- /dev/null +++ b/src/ui/GbaProse.lua @@ -0,0 +1,73 @@ +-- Native GBA label bindings. The identifiers follow each reference reader; +-- equal English words do not imply that two dialogs use the same label. +local V = require("src.core.GameVersion") +local P = {} +local FR_ACTION = { + summary = 0, + switch = 1, + cancel = 2, + item = 3, + give = 4, + takeItem = 5, + takeMail = 5, + shift = 10, + sendOut = 11, + enter = 12, + noEntry = 13, + store = 14, +} +local EM_ACTION = { + summary = "gText_Summary5", + switch = "gText_Switch2", + cancel = "gText_Cancel2", + item = "gText_Item", + give = "gMenuText_Give", + takeItem = "gText_Take", + takeMail = "gText_Take", + shift = "gText_Shift", + sendOut = "gText_SendOut", + enter = "gText_Enter", + noEntry = "gText_NoEntry", + store = "gText_Store", +} +local WORD_ROLE = { "shift", "sendOut", "switch", "summary", false, "enter", "noEntry", "takeItem" } +local ITEM_PC = { + title = "gText_WithdrawItem", + withdraw = "gText_Withdraw", + give = "gOtherText_Give", + cancel = "gFameCheckerText_Cancel", +} +function P.read(game, label, fallback) + local data = game and game.data + local overrides = data and data._romTextGeneration == 3 and data.romText + local value = overrides and label and overrides[label] or nil + if value == nil then + return fallback + end + return (require("src.render.TextBox").substitute(game, value):gsub("[\f\v]", "\n\n")) +end +function P.partyAction(game, role, fallback) + local version, label = V.get(), nil + if version == "firered" then + local index = FR_ACTION[role] + if index ~= nil then + label = "sCursorOptions[" .. index .. "]" + end + elseif version == "emerald" then + label = EM_ACTION[role] + end + return P.read(game, label, fallback) +end +function P.partyWord(game, index, fallback) + return P.partyAction(game, WORD_ROLE[index], fallback) +end +function P.partyCancel(game, fallback) + local label = V.get() == "firered" and "gFameCheckerText_Cancel" + or V.get() == "emerald" and "gText_Cancel" + or nil + return P.read(game, label, fallback) +end +function P.itemPc(game, role, fallback) + return P.read(game, V.get() == "firered" and ITEM_PC[role] or nil, fallback) +end +return P diff --git a/src/ui/Gen3ItemPcFRLG.lua b/src/ui/Gen3ItemPcFRLG.lua index 0c830b712..9a6612410 100644 --- a/src/ui/Gen3ItemPcFRLG.lua +++ b/src/ui/Gen3ItemPcFRLG.lua @@ -50,7 +50,11 @@ function Gen3ItemPcFRLG.new(game, opts) local self = setmetatable({}, Gen3ItemPcFRLG) self.game = game self.rows = opts.rows or {} + self.nativeProse = opts.nativeProse == true or opts.title == nil self.title = opts.title or Strings("WITHDRAW ITEM") + if self.nativeProse then + self.title = require("src.ui.GbaProse").itemPc(game, "title", self.title) + end self.onPick = opts.onPick self.onCancel = opts.onCancel self.onReorder = opts.onReorder @@ -261,7 +265,11 @@ function Gen3ItemPcFRLG:drawRows() local row = self.rows[self.top + i] if not row then break end local y = y0 + i * pitch - Font.draw(row.label or "", itemX, y) + local label = row.label or "" + if self.nativeProse and row.close then + label = require("src.ui.GbaProse").itemPc(self.game, "cancel", label) + end + Font.draw(label, itemX, y) if row.qty and not row.close then local faced = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") Font.draw(Strings("×%3d", row.qty), quantityX, y) diff --git a/src/ui/Gen3ItemPcSubmenu.lua b/src/ui/Gen3ItemPcSubmenu.lua index 0e16d55a6..397821903 100644 --- a/src/ui/Gen3ItemPcSubmenu.lua +++ b/src/ui/Gen3ItemPcSubmenu.lua @@ -35,6 +35,14 @@ function Gen3ItemPcSubmenu.new(game, itemPc, itemName, onPick) return self end +function Gen3ItemPcSubmenu:entries() + local out, prose = {}, require("src.ui.GbaProse") + for i, entry in ipairs(ENTRIES) do + out[i] = { label = prose.itemPc(self.game, entry.kind, Strings(entry.label)), kind = entry.kind } + end + return out +end + function Gen3ItemPcSubmenu:move(delta) self.index = math.max(1, math.min(#ENTRIES, self.index + delta)) Sound.play(self.game.data, "Press_AB") @@ -82,9 +90,9 @@ function Gen3ItemPcSubmenu:draw() love.graphics.setColor(0, 0, 0, 1) local styles = self.game.data.font and self.game.data.font.uiTextStyles or {} if styles.dark then Font.pushStyle(styles.dark) end - for i, entry in ipairs(ENTRIES) do + for i, entry in ipairs(self:entries()) do local y = b.y + 2 + (i - 1) * 16 - Font.draw(Strings(entry.label), b.x + 8, y) + Font.draw(entry.label, b.x + 8, y) if i == self.index then Font.drawCode(Theme.cursor, b.x, y) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 710527d08..29232acea 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -55,6 +55,7 @@ local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Prose = require("src.ui.GbaProse") local Theme = require("src.ui.Theme") -- Sprites reaches back into the party screen, so it is resolved once on @@ -191,7 +192,7 @@ end function Gen3PartyMenu:word(n, fallback) local words = self.words - return (words and words[n]) or Strings(fallback) + return Prose.partyWord(self.game, n, (words and words[n]) or Strings(fallback)) end -- DOES THIS POKEMON KNOW A FIELD MOVE THAT WOULD DO SOMETHING HERE? @@ -462,7 +463,7 @@ function Gen3PartyMenu:actionsFor() self.forceSwitch and "SEND OUT" or "SHIFT"), action = "switch" }, { label = self:word(4, "SUMMARY"), action = "summary" }, - { label = Strings("CANCEL"), action = "cancel" }, + { label = self:actionWord("cancel", nil, "CANCEL"), action = "cancel" }, } end @@ -503,7 +504,8 @@ end function Gen3PartyMenu:actionWord(key, index, fallback) local words = self:actionWords() - return (words and words[key]) or self:word(index, fallback) + local native = (words and words[key]) or (self.words and self.words[index]) or Strings(fallback) + return Prose.partyAction(self.game, key, native) end -- The cartridge's field-move rows, in the order this Pokemon's move slots @@ -827,7 +829,7 @@ function Gen3PartyMenu:orderActionsFor(slot) rows[#rows + 1] = { label = self:word(6, "ENTER"), action = "enter" } end rows[#rows + 1] = { label = self:word(4, "SUMMARY"), action = "summary" } - rows[#rows + 1] = { label = Strings("CANCEL"), action = "cancel" } + rows[#rows + 1] = { label = self:actionWord("cancel", nil, "CANCEL"), action = "cancel" } return rows end @@ -1195,7 +1197,7 @@ function Gen3PartyMenu:drawFireRedCancel(bg, images, party, ball) end g.setColor(0, 0, 0, 1) - local label = Strings("CANCEL") + local label = Prose.partyCancel(self.game, Strings("CANCEL")) local lx = FIRERED_CANCEL.x + 3 + math.floor((FIRERED_CANCEL.width - Font.width(label)) / 2) local ly = FIRERED_CANCEL.y @@ -1633,7 +1635,7 @@ function Gen3PartyMenu:draw() self:drawFireRedCancel(bg, images, party, ball) elseif bg and cancel then love.graphics.setColor(0, 0, 0, 1) - local label = Strings("CANCEL") + local label = Prose.partyCancel(self.game, Strings("CANCEL")) local lx = cancel.x + math.floor((cancel.width - Font.width(label)) / 2) local ly = cancel.y + math.floor((cancel.height - Font.glyphHeight()) / 2) Font.draw(label, lx, ly) @@ -1643,7 +1645,7 @@ function Gen3PartyMenu:draw() else Font.drawBox(CANCEL.tx, CANCEL.ty, CANCEL.tw, CANCEL.th) love.graphics.setColor(0, 0, 0, 1) - local label = Strings("CANCEL") + local label = Prose.partyCancel(self.game, Strings("CANCEL")) local y = (CANCEL.ty + 1) * 8 + inset Font.draw(label, (CANCEL.tx + 2) * 8, y) if self.index > #party then diff --git a/src/ui/Gen3PlayerPC.lua b/src/ui/Gen3PlayerPC.lua index 1ef0cbab9..0a57adb4c 100644 --- a/src/ui/Gen3PlayerPC.lua +++ b/src/ui/Gen3PlayerPC.lua @@ -177,7 +177,7 @@ end -- FireRed's dedicated Item PC, or the shared Gen 3 bag-list fallback on the -- other Gen 3 dataset. This list is one list, not bag pockets. -local function storageList(game, title, rows, onPick, onCancel, onReorder) +local function storageList(game, title, rows, onPick, onCancel, onReorder, nativeProse) local bagScreen = (game.data.constants or {}).gen3BagScreen local fireRed = ((game.data.constants or {}).gen3FRLGItemPc ~= nil) or (type(bagScreen) == "table" and bagScreen.layout == "frlg") @@ -185,7 +185,7 @@ local function storageList(game, title, rows, onPick, onCancel, onReorder) if fireRed then menu = require("src.ui.Gen3ItemPcFRLG").new(game, { title = title, rows = rows, onPick = onPick, onCancel = onCancel, - onReorder = onReorder, + onReorder = onReorder, nativeProse = nativeProse, }) else menu = require("src.ui.Gen3BagMenu").new(game, { @@ -296,7 +296,7 @@ local function g3Withdraw(game, back) end, back, function(sourceId, beforeId) reorderPc(game, pc, sourceId, beforeId) current:setRows(storageRows(game, pc)) - end) + end, true) end open() end diff --git a/tests/drivers/gba_menu_prose.lua b/tests/drivers/gba_menu_prose.lua new file mode 100644 index 000000000..d96feff99 --- /dev/null +++ b/tests/drivers/gba_menu_prose.lua @@ -0,0 +1,127 @@ +-- Imported native UI smoke in an isolated in-memory New Game. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION") and V.generation() == 3, "wrong imported version") + local e = assert(game.mods.exports.portable_gba_menu_prose, "install public menu-prose example") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(4) + end + assert(title, "native title missing") + title:onNewGame() + local world = assert(game.overworld, "native New Game missing") + game.stack.states = { world } + local species + for id, def in pairs(game.data.pokemon) do + if def.name == "CHARMANDER" then + species = id + break + end + end + assert(species, "imported species missing") + game.save.party = + { require("src.pokemon.Pokemon").new(game.data, species, 5, function(lo) + return lo or 0 + end) } + local Font, captured = require("src.render.Font"), {} + local original = Font.draw + Font.draw = function(text, ...) + captured[text] = true + return original(text, ...) + end + local party = require("src.ui.Gen3PartyMenu").new(game, {}) + game.stack:push(party) + U.wait(15) + assert(captured.MOD_FOOTER, "native party render did not draw separate cancel label") + U.tap(game, "a") + U.wait(12) + assert(party.submenu and party.submenu[1].label == "MOD_SUMMARY", "native party actions ignored prose") + assert(captured.MOD_SUMMARY and captured.MOD_CANCEL, "native party submenu did not render overrides") + U.tap(game, "a") + U.wait(12) + assert(game.stack:top() ~= party, "translated SUMMARY did not open native summary") + U.tap(game, "b") + U.wait(10) + assert(game.stack:top() == party, "native summary did not return to party") + U.tap(game, "a") + U.wait(4) + local item + for i, row in ipairs(party.submenu) do + if row.action == "item" then + item = i + end + end + assert(item, "native ITEM action missing") + party.subIndex = item + U.tap(game, "a") + U.wait(10) + assert( + party.submenu[1].label == "MOD_GIVE" and party.submenu[2].label == "MOD_TAKE", + "native item actions ignored prose" + ) + assert(captured.MOD_GIVE and captured.MOD_TAKE, "native item submenu did not draw prose") + U.tap(game, "b") + U.wait(4) + assert(party.submenu == nil, "native submenu cancel changed") + U.tap(game, "b") + U.wait(6) + assert(game.stack:top() == world, "native party did not close") + if V.get() == "firered" then + local potion + for id, def in pairs(game.data.items) do + if def.name == "POTION" then + potion = id + break + end + end + assert(potion, "imported Potion missing") + game.save.pcItems = { [potion] = 1 } + local pc = require("src.ui.Gen3PlayerPC").new(game, {}) + game.stack:push(pc) + U.tap(game, "a") + U.wait(6) -- native ITEM STORAGE + U.tap(game, "a") + U.wait(10) -- native WITHDRAW ITEM + local list = game.stack:top() + assert(list.title == "MOD_PC_TITLE" and list.nativeProse, "Player PC did not route native title label") + assert(captured.MOD_PC_TITLE and captured.MOD_FOOTER, "native Item-PC render missed title/cancel") + U.tap(game, "a") + U.wait(10) + local submenu = game.stack:top() + assert(submenu ~= list and submenu.entries, "native Item-PC submenu missing") + local entries = submenu:entries() + assert( + entries[1].label == "MOD_PC_WITHDRAW" and entries[2].label == "MOD_PC_GIVE", + "native Item-PC rows ignored prose" + ) + assert(captured.MOD_PC_WITHDRAW and captured.MOD_PC_GIVE, "native Item-PC rows not rendered") + U.tap(game, "down") + U.tap(game, "down") + U.tap(game, "a") + U.wait(6) + assert( + game.stack:top() == list and list.transientWindow == nil, + "translated CANCEL changed native action" + ) + assert(game.save.pcItems[potion] == 1, "cancel changed inventory") + U.tap(game, "b") + U.wait(6) + assert(game.stack:top() ~= list, "native Item-PC did not close") + end + Font.draw = original + local ok, err = pcall(function() + e.api.content.rom_text:override("gText_Withdraw", "LATE") + end) + assert(not ok and tostring(err):find("frozen", 1, true), "late public prose mutation accepted") + print("PORTABILITY_GBA_MENU_PROSE_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/gba_menu_prose.lua b/tests/engine/gba_menu_prose.lua new file mode 100644 index 000000000..f32595afa --- /dev/null +++ b/tests/engine/gba_menu_prose.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gba_menu_prose/cases.lua").run(".", "gen2", version, 3, "unified") +end +T.finish("native GBA menu-prose bindings") diff --git a/tests/engine/gba_menu_prose_native.lua b/tests/engine/gba_menu_prose_native.lua new file mode 100644 index 000000000..678992cfb --- /dev/null +++ b/tests/engine/gba_menu_prose_native.lua @@ -0,0 +1,93 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = dofile("tests/gba_menu_prose/fixture.lua") +for _, version in ipairs({ "firered", "emerald" }) do + local f = F.new(".", "gen2", version, "all", 3, "unified") + local p = f.party + local base = require("src.mods.Merge").deepCopy(f.data.constants) + p.battle, p.onSwitch = {}, function() end + local actions = p:actionsFor() + T.same( + { actions[1].label, actions[2].label, actions[3].label }, + { "MOD_SHIFT", "MOD_SUMMARY", "MOD_CANCEL" }, + "battle menu reads native labels" + ) + T.same( + { actions[1].action, actions[2].action, actions[3].action }, + { "switch", "summary", "cancel" }, + "battle action identities survive replacement" + ) + p.forceSwitch = true + T.eq(p:actionsFor()[1].label, "MOD_SEND_OUT", "forced-switch label") + actions = p:itemActions() + T.same( + { actions[1].label, actions[2].label, actions[3].label }, + { "MOD_GIVE", "MOD_TAKE", "MOD_CANCEL" }, + "item submenu uses primary TAKE" + ) + T.same( + { actions[1].action, actions[2].action, actions[3].action }, + { "give", "take", "cancel" }, + "item action identities survive replacement" + ) + p.chooseOrder = { most = 3 } + T.eq(p:orderActionsFor(1)[1].label, "MOD_ENTER", "team-order entry") + p.order = { 1 } + T.eq(p:orderActionsFor(1)[1].label, "MOD_NO_ENTRY", "team-order removal") + T.eq(p:word(5, "MOVES"), "MOVES", "unbound native word preserved") + T.eq(p:actionWord("read", 9, "READ"), "READ", "unbound action preserved") + T.eq(p:actionWord("takeMail", nil, "TAKE"), "MOD_TAKE", "reference primary TAKE reused") + T.eq( + p:actionWord("cancel2", nil, "OTHER CANCEL"), + "OTHER CANCEL", + "unmapped role does not borrow first cancel" + ) + f.data.constants.gen3PartyActions = nil + p.words = nil + T.eq(p:actionWord("summary", nil, "SUMMARY"), "MOD_SUMMARY", "older cache without action tables") + T.eq(p:word(4, "SUMMARY"), "MOD_SUMMARY", "older battle-menu cache") + f.data.constants = base + local Font = require("src.render.Font") + local captured, draw = {}, Font.draw + Font.draw = function(text) + captured[#captured + 1] = text + end + local pc = require("src.ui.Gen3ItemPcFRLG").new(f.game, {}) + local sub = require("src.ui.Gen3ItemPcSubmenu").new(f.game, pc, "POTION") + local ok, err = pcall(sub.draw, sub) + Font.draw = draw + T.check(ok, "actual submenu render: " .. tostring(err)) + T.same( + { captured[1], captured[2], captured[3] }, + version == "firered" and { "MOD_PC_WITHDRAW", "MOD_PC_GIVE", "MOD_FOOTER" } + or { "WITHDRAW", "GIVE", "CANCEL" }, + "native submenu rendering respects profile boundary" + ) + for index, kind in ipairs({ "withdraw", "give", "cancel" }) do + local selected, popped = nil, 0 + f.game.stack.pop = function() + popped = popped + 1 + end + local menu = require("src.ui.Gen3ItemPcSubmenu").new(f.game, pc, "POTION", function(k) + selected = k + end) + menu.index = index + menu:keypressed("a") + T.eq(selected, kind ~= "cancel" and kind or nil, "translated action still selects " .. kind) + T.eq(popped, 1, "submenu closes exactly once") + T.eq(pc.transientWindow, nil, "submenu releases native description window") + end + local custom = require("src.ui.Gen3ItemPcFRLG").new(f.game, { title = "TOSS ITEM" }) + T.eq(custom.title, "TOSS ITEM", "withdraw override cannot rename another dialog") + local routed = + require("src.ui.Gen3ItemPcFRLG").new(f.game, { title = "LOCALIZED BASE", nativeProse = true }) + T.eq( + routed.title, + version == "firered" and "MOD_PC_TITLE" or "LOCALIZED BASE", + "explicit native withdrawal title" + ) + -- Registry reads do not mutate importer-owned action tables. + T.same(f.data.constants, base, "imported constants preserved") + f.close() +end +T.finish("native menu action and render integration") diff --git a/tests/gba_menu_prose/cases.lua b/tests/gba_menu_prose/cases.lua new file mode 100644 index 000000000..801f2fb66 --- /dev/null +++ b/tests/gba_menu_prose/cases.lua @@ -0,0 +1,66 @@ +local C = {} +function C.run(root, backend, version, api, format) + local F = dofile(root .. "/tests/gba_menu_prose/fixture.lua") + local rows = {} + for _, mode in ipairs({ + "no-mod", + "all", + "controls", + "foreign", + "cancel-only", + "take-only", + "empty", + "remove", + "rollback", + }) do + local f = F.new(root, backend, version, mode, api, format) + f.T.eq(#f.result.errors > 0, mode == "rollback", "menu-prose load outcome " .. mode) + local native = f.actions() + for i, role in ipairs(F.roles) do + local base = "BASE_" .. role[2]:gsub(" ", "_") + local expected = base + if + mode == "all" + or mode == "controls" + or mode == "cancel-only" and role[1] == "cancel" + or mode == "take-only" and role[1] == "takeItem" + then + expected = "MOD_" .. role[2]:gsub(" ", "_") + if mode == "controls" and role[1] == "summary" then + expected = "A\nB\n\nC\n\nD" + end + elseif mode == "empty" then + expected = "" + end + -- A removed cartridge override delegates to the imported native base. + f.T.eq(native[i], expected, version .. "/" .. mode .. "/" .. role[1]) + end + local footer = f.footer() + f.T.eq( + footer, + (mode == "all" or mode == "controls" or mode == "empty" or mode == "remove") and "MOD_FOOTER" + or "CANCEL", + "separate native cancel button" + ) + local row = { mode = mode, actions = native, footer = footer } + if version == "firered" then + row.itemPc = f.itemPc() + local changed = mode == "all" or mode == "controls" or mode == "empty" or mode == "remove" + f.T.same( + row.itemPc, + changed and { "MOD_PC_TITLE", "MOD_PC_WITHDRAW", "MOD_PC_GIVE", "MOD_FOOTER", "MOD_FOOTER" } + or { "WITHDRAW ITEM", "WITHDRAW", "GIVE", "CANCEL", "CANCEL" }, + "actual native item-PC title/submenu/cancel" + ) + end + if f.e then + f.T.raises(function() + f.e.api.content.rom_text:override("gText_Withdraw", "LATE") + end, "frozen", "late mutation rejected") + end + rows[#rows + 1] = row + f.close() + end + return rows +end +return C diff --git a/tests/gba_menu_prose/fixture.lua b/tests/gba_menu_prose/fixture.lua new file mode 100644 index 000000000..6cc49815b --- /dev/null +++ b/tests/gba_menu_prose/fixture.lua @@ -0,0 +1,230 @@ +local F = {} +F.roles = { + { "summary", "SUMMARY", 0, "gText_Summary5" }, + { "switch", "SWITCH", 1, "gText_Switch2" }, + { "cancel", "CANCEL", 2, "gText_Cancel2" }, + { "item", "ITEM", 3, "gText_Item" }, + { "give", "GIVE", 4, "gMenuText_Give" }, + { "takeItem", "TAKE", 5, "gText_Take" }, + { "shift", "SHIFT", 10, "gText_Shift" }, + { "sendOut", "SEND OUT", 11, "gText_SendOut" }, + { "enter", "ENTER", 12, "gText_Enter" }, + { "noEntry", "NO ENTRY", 13, "gText_NoEntry" }, + { "store", "STORE", 14, "gText_Store" }, +} +function F.new(root, backend, version, mode, api, format) + local T, V = require("tests.modkit"), require("src.core.GameVersion") + V.set(version) + local J = require("src.link.Json") + local data = backend == "gen1" and T.sdk.gen3Data() or { constants = { generation = 3 } } + data.constants = data.constants or {} + if backend == "gen1" then + data.gen3RomText, data.gen3Text = {}, {} + end + local labels, texts, manifestLabels = {}, {}, {} + for i = 1, 19 do + manifestLabels[i] = "UNUSED_" .. i + end + for _, row in ipairs(F.roles) do + local base = "BASE_" .. row[2]:gsub(" ", "_") + labels[row[1]] = base + texts["sCursorOptions[" .. row[3] .. "]"], texts[row[4]] = base, base + manifestLabels[row[3] + 1] = base + end + texts.gFameCheckerText_Cancel, texts.gText_Cancel = "CANCEL", "CANCEL" + texts.gText_Take2 = "BASE_OTHER_TAKE" + texts.gText_WithdrawItem, texts.gText_Withdraw, texts.gOtherText_Give = "WITHDRAW ITEM", "WITHDRAW", "GIVE" + texts.gText_TimesStrVar1, texts.gText_Var1IsSelected = "x{STR_VAR_1}", "{STR_VAR_1} selected." + data.constants.gen3PartyActions = { labels = labels, fieldMoves = {} } + data.constants.gen3Screens = { + partyActions = { + items = { + labels.shift, + labels.sendOut, + labels.switch, + labels.summary, + "MOVES", + labels.enter, + labels.noEntry, + labels.takeItem, + "READ", + "TRADE", + }, + }, + } + local files, name = {}, "portable_gba_menu_prose" + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local h = assert(io.open(root .. "/examples/" .. name .. "/" .. path, "rb")) + files["mods/" .. name .. "/" .. path] = h:read("*a") + h:close() + end + local m = J.decode(files["mods/" .. name .. "/manifest.json"]) + m.api, m.content_format, m.games, m.gen2compat = api, format, { version }, api < 3 and true or nil + files["mods/" .. name .. "/manifest.json"] = J.encode(m) + files["mods/" .. name .. "/case.txt"] = mode + if mode == "foreign" then + files["mods/" .. name .. "/profile.txt"] = version == "firered" and "emerald" or "firered" + end + if mode == "no-mod" then + files = {} + end + local r = T.sdk.loadMods( + mode == "no-mod" and {} or { "mods/" .. name }, + { data = data, generation = 3, fs = T.sdk.memfs(files), dev = false } + ) + local f = { T = T, data = data, result = r, e = r.loader.exports[name], backend = backend } + f.game = { + data = data, + save = { player = { name = "PROBE" }, options = {}, party = {} }, + stack = { pop = function() end }, + } + local restores = {} + local function replace(t, k, v) + local old = t[k] + t[k] = v + restores[#restores + 1] = function() + t[k] = old + end + end + f.replace = replace + if backend == "gen1" then + local IR, Rom = require("src.core.game3.scripting.text_ir"), require("src.core.game3.rom_text") + local Space = require("src.core.game3.scripting.space") + local ir = {} + for k, v in pairs(texts) do + ir[k] = IR.fromAscii(v) + end + replace(Space, "bundle", { text = ir }) + replace(Rom, "overrides", data.gen3RomText) + local Party = require("src.ui.game3.party_menu") + replace(Party, "_session", { version = version }) + replace(Party, "_actionTexts", nil) + local Items = require("src.core.game3.items_data") + for _, key in ipairs({ "_pack", "_byId", "_byName", "_logged" }) do + replace(Items, key, Items[key]) + end + Items.installPack({ items = { [13] = { name = "POTION", pocket = "ITEMS" } } }) + local Kit = require("src.ui.game3.rse.scene_kit") + Kit.resetCaches() + local info, load = love.filesystem.getInfo, love.filesystem.load + local path = "data/generated/gba/rse/menus/manifest.lua" + -- Supply generated fixture IO, so Emerald takes its actual manifest branch. + replace(love.filesystem, "getInfo", function(p) + if p == path then + return { type = "file" } + end + return info(p) + end) + replace(love.filesystem, "load", function(p) + if p == path then + return function() + return { party = { cursorOptions = manifestLabels, fieldMoves = {} } } + end + end + return load(p) + end) + restores[#restores + 1] = Kit.resetCaches + function f.actions() + local names = {} + for i, row in ipairs(F.roles) do + names[i] = row[2] + end + return Party._actionTextsFor(names) + end + function f.footer() + local Chrome, Font = require("src.ui.game3.party_chrome"), require("src.ui.game3.frlg_font") + local result + local draw = Font.draw + Font.draw = function(t) + result = t + end + local old = { Chrome._nativeDelegate, Chrome._manifest, Chrome._cancelBtn, Chrome._balls } + Chrome._nativeDelegate, Chrome._manifest, Chrome._cancelBtn, Chrome._balls = + nil, + { palBuffer = { [51] = { 0, 0, 0 }, [50] = { 0, 0, 0 }, [53] = { 0, 0, 0 } } }, + {}, + { image = {}, frameCount = 1, quads = {} } + local ok, err = pcall(Chrome.drawCancelButton, 184, 136, false) + Chrome._nativeDelegate, Chrome._manifest, Chrome._cancelBtn, Chrome._balls = unpack(old) + Font.draw = draw + assert(ok, err) + return result + end + function f.itemPc() + local Pc, Font = require("src.ui.game3.item_pc"), require("src.ui.game3.frlg_font") + local Window, BagChrome = require("src.ui.game3.window"), require("src.ui.game3.bag_chrome") + local captured, undo = {}, {} + local function patch(t, k, value) + local old = t[k] + t[k] = value + undo[#undo + 1] = function() + t[k] = old + end + end + patch(Pc, "open", true) + patch(Pc, "mode", "submenu") + patch(Pc, "scroll", 0) + patch(Pc, "row", 0) + patch(Pc, "_session", { version = version, storage = { items = { { id = 13, qty = 1 } } } }) + patch(Pc, "_images", { bg = {}, bg_submenu = {} }) + patch(Pc, "subCursor", 1) + patch(Font, "draw", function(t, x, y) + captured[x .. ":" .. y] = t + end) + patch(Font, "drawGlyph", function() end) + patch(BagChrome, "drawItemIcon", function() end) + patch(Window, "stdFrame", function() end) + patch(Window, "fixedStdFrame", function() end) + local ok, err = pcall(Pc.draw) + for i = #undo, 1, -1 do + undo[i]() + end + assert(ok, err) + return { + captured["8:9"], + captured["184:106"], + captured["184:122"], + captured["184:138"], + captured["65:26"], + } + end + else + local Party = require("src.ui.Gen3PartyMenu") + f.party = Party.new(f.game, {}) + function f.actions() + local out = {} + for i, row in ipairs(F.roles) do + out[i] = f.party:actionWord(row[1], nil, row[2]) + end + return out + end + function f.footer() + return require("src.ui.GbaProse").partyCancel(f.game, "CANCEL") + end + function f.itemPc() + local pc = require("src.ui.Gen3ItemPcFRLG").new( + f.game, + { rows = { { id = "POTION", label = "POTION" }, { close = true, label = "CANCEL" } } } + ) + local sub = require("src.ui.Gen3ItemPcSubmenu").new(f.game, pc, "POTION") + local entries, captured = sub:entries(), {} + local Font = require("src.render.Font") + local draw = Font.draw + Font.draw = function(t) + captured[#captured + 1] = t + end + local ok, err = pcall(pc.drawRows, pc) + Font.draw = draw + assert(ok, err) + return { pc.title, entries[1].label, entries[2].label, entries[3].label, captured[2] } + end + end + function f.close() + for i = #restores, 1, -1 do + restores[i]() + end + r.release() + end + return f +end +return F diff --git a/tests/gba_menu_prose/label-facts.json b/tests/gba_menu_prose/label-facts.json new file mode 100644 index 000000000..7a542c291 --- /dev/null +++ b/tests/gba_menu_prose/label-facts.json @@ -0,0 +1,39 @@ +{ + "profiles": { + "emerald": { + "tables": [], + "text": { + "gMenuText_Give": 6196436, + "gText_Cancel": 6196464, + "gText_Cancel2": 6196471, + "gText_Enter": 6198980, + "gText_Item": 6196491, + "gText_NoEntry": 6198986, + "gText_SendOut": 6198950, + "gText_Shift": 6198944, + "gText_Store": 6196506, + "gText_Summary5": 6198966, + "gText_Switch2": 6198959, + "gText_Take": 6196501, + "gText_Take2": 6198995 + } + }, + "firered": { + "tables": { + "sCursorOptions": { + "addr": 4564504, + "count": 30, + "name": "sCursorOptions", + "stride": 8 + } + }, + "text": { + "gFameCheckerText_Cancel": 4284865, + "gOtherText_Give": 4284850, + "gText_Withdraw": 4290741, + "gText_WithdrawItem": 4290727 + } + } + }, + "reference_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" +} diff --git a/tests/gba_menu_prose/worker.lua b/tests/gba_menu_prose/worker.lua new file mode 100644 index 000000000..3927ff075 --- /dev/null +++ b/tests/gba_menu_prose/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/gba_menu_prose/cases.lua").run(root, backend, version, tonumber(api), format) +print("GBA_MENU_PROSE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA menu prose") diff --git a/tools/gba-menu-prose-contract-check/README.md b/tools/gba-menu-prose-contract-check/README.md new file mode 100644 index 000000000..729d54184 --- /dev/null +++ b/tools/gba-menu-prose-contract-check/README.md @@ -0,0 +1,27 @@ +# Native GBA menu-prose contracts + +Use a clean Gen1Recomp checkout at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +python3 tools/gba-menu-prose-contract-check/generate-facts.py --gen1 /path/to/gen1 --check +python3 tools/gba-menu-prose-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/gba-menu-prose-contract-check/check.py --gen1 /path/to/gen1 --host-api 2 +``` + +The unchanged public consumer runs through each real SDK in separate workers. +The reference calls its actual party action producer. Emerald receives a generated +manifest fixture through filesystem IO, so it exercises its profile's named-label +branch instead of silently falling back to FireRed's table keys. The actual +reference party-footer and FireRed Item-PC draw functions run with captured font +output and fixture artwork/palette/item data. No text producer is replaced. + +Each configuration compares 232 native text values across both profiles: baseline, +replacement, wrong-profile isolation, primary TAKE/action CANCEL separation, +empty strings, removal and failed-owner rollback. The host also runs native +action/render integration checks. These tests do not prove pixel equivalence, +complete battle flow, every party message, Item-PC quantity/results, or DS support. +The source's native API 2 is not claimed as a portable source-string registry; +the host API-2 check explicitly uses `content_format: unified`. + +`label-facts.json` contains public import name/address/table facts, no ROM text. +Regenerate by omitting `--check`; it is test provenance, not a runtime address map. diff --git a/tools/gba-menu-prose-contract-check/check.py b/tools/gba-menu-prose-contract-check/check.py new file mode 100644 index 000000000..db9ab310d --- /dev/null +++ b/tools/gba-menu-prose-contract-check/check.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare the same installed consumer through the two real mod SDKs.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--host-api", type=int, choices=[2, 3], default=3) + p.add_argument("--report", type=Path) + a = p.parse_args() + source, host = a.gen1.resolve(), a.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + p.error("Gen1 must be clean at " + REFERENCE) + results, pairs = [], 0 + for version in ["firered", "emerald"]: + observed = [] + for backend, engine, api in [("gen1", source, 3), ("gen2", host, a.host_api)]: + run = subprocess.run([a.lua, str(host / "tests/gba_menu_prose/worker.lua"), + str(host), backend, version, str(api), "unified"], + cwd=engine, text=True, capture_output=True, timeout=45) + lines = [s.removeprefix("GBA_MENU_PROSE ") for s in run.stdout.splitlines() + if s.startswith("GBA_MENU_PROSE ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observed.append(rows) + results.append({"backend": backend, "version": version, "api": api, "observations": rows}) + if observed[0] != observed[1]: + raise AssertionError(f"{version}: native menu observations differ") + count = sum(len(r["actions"]) + 1 + len(r.get("itemPc", [])) for r in observed[0]) + pairs += count + print(f"PASS {version}: {count} paired native menu values") + assert not git(source, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"reference_ref": REFERENCE, + "host_ref": git(host, "rev-parse", "HEAD"), "host_worktree_dirty": bool(git(host, "status", "--porcelain")), + "host_api": a.host_api, "paired_values": pairs, "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/gba-menu-prose-contract-check/export-facts.lua b/tools/gba-menu-prose-contract-check/export-facts.lua new file mode 100644 index 000000000..326d2e457 --- /dev/null +++ b/tools/gba-menu-prose-contract-check/export-facts.lua @@ -0,0 +1,40 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local version = assert(arg[1]) +require("src.core.GameVersion").set(version) +local M = require("src.import.gba.versions") +M.select(version) +local wanted = version == "firered" + and { + "gFameCheckerText_Cancel", + "gText_WithdrawItem", + "gText_Withdraw", + "gOtherText_Give", + } + or { + "gText_Summary5", + "gText_Switch2", + "gText_Cancel2", + "gText_Item", + "gMenuText_Give", + "gText_Take", + "gText_Take2", + "gText_Shift", + "gText_SendOut", + "gText_Enter", + "gText_NoEntry", + "gText_Store", + "gText_Cancel", + } +local facts = { text = {}, tables = {} } +for _, name in ipairs(wanted) do + facts.text[name] = assert(M.NAMED_TEXTS[name], name) +end +if version == "firered" then + for _, row in pairs(M.TEXT_TABLES) do + if row.name == "sCursorOptions" then + facts.tables.sCursorOptions = row + end + end + assert(facts.tables.sCursorOptions) +end +print("GBA_MENU_FACTS " .. require("src.link.Json").encode(facts)) diff --git a/tools/gba-menu-prose-contract-check/generate-facts.py b/tools/gba-menu-prose-contract-check/generate-facts.py new file mode 100644 index 000000000..107461fa0 --- /dev/null +++ b/tools/gba-menu-prose-contract-check/generate-facts.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Record native public label/table metadata without copying cartridge prose.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +from check import REFERENCE, git + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--check", action="store_true") + a = p.parse_args() + source = a.gen1.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + p.error("Gen1 must be clean at " + REFERENCE) + facts = {"reference_ref": REFERENCE, "profiles": {}} + for version in ["firered", "emerald"]: + run = subprocess.run([a.lua, str(ROOT / "tools/gba-menu-prose-contract-check/export-facts.lua"), version], + cwd=source, text=True, capture_output=True, timeout=30) + lines = [s.removeprefix("GBA_MENU_FACTS ") for s in run.stdout.splitlines() if s.startswith("GBA_MENU_FACTS ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(run.stdout + run.stderr) + facts["profiles"][version] = json.loads(lines[0]) + assert not git(source, "status", "--porcelain"), "reference changed" + content = json.dumps(facts, sort_keys=True, indent=2) + "\n" + path = ROOT / "tests/gba_menu_prose/label-facts.json" + if a.check: + assert path.read_text() == content, "stale native menu label facts" + else: + path.write_text(content) + print("PASS pinned native menu label/table provenance") + + +if __name__ == "__main__": + main() From 431fe8c78d3c6815a6df118b96cad3bd4c504f30 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:56:47 +0200 Subject: [PATCH 73/86] Keep native menu title reads live across mod retirement --- docs/api3-portability.md | 3 +++ docs/api3-rom-text.md | 6 +++++- src/ui/Gen3ItemPcFRLG.lua | 12 ++++++++---- tests/drivers/gba_menu_prose.lua | 5 ++++- tests/engine/gba_menu_prose_native.lua | 18 +++++++++++++++--- tests/gba_menu_prose/fixture.lua | 2 +- tools/gba-menu-prose-contract-check/README.md | 4 ++-- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bba8e5db0..6dfa29a59 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -187,3 +187,6 @@ prose and named GBA label bindings remain separate follow-up work. For separate label-keyed engine prose, native consumer coverage and remaining GBA bindings, see [portable ROM prose](api3-rom-text.md). + +For native FireRed/Emerald party action labels, separate cancel buttons and +FireRed Item-PC withdrawal labels, see [GBA menu prose](portable-gba-menu-prose.md). diff --git a/docs/api3-rom-text.md b/docs/api3-rom-text.md index 99b2b79d8..894ef759e 100644 --- a/docs/api3-rom-text.md +++ b/docs/api3-rom-text.md @@ -39,8 +39,12 @@ a brace slot: an incompatible call retains the native fallback policy. unchanged. All five actions/descriptions are tested through both engines' actual native menu readers; this does not invent a generic word matcher. +The [GBA menu-prose extension](portable-gba-menu-prose.md) also binds the +FireRed/Emerald party actions and separate cancel buttons, plus FireRed's +Item-PC withdrawal title, cancel row and submenu. + **Registry availability does not mean every native GBA UI/battle reader has -been bound.** Party, item-PC, other field menus and GBA battle-message contexts +been bound.** Further party prompts, Item-PC quantity/results, other field menus and GBA battle-message contexts need further explicit adapters. Likewise, a Gen-2 screen that still prints a hardcoded fallback rather than reading its ROM label needs a native reader change. This PR is a shared registry and its first verified native consumers, diff --git a/src/ui/Gen3ItemPcFRLG.lua b/src/ui/Gen3ItemPcFRLG.lua index 9a6612410..04e36ddfa 100644 --- a/src/ui/Gen3ItemPcFRLG.lua +++ b/src/ui/Gen3ItemPcFRLG.lua @@ -52,9 +52,6 @@ function Gen3ItemPcFRLG.new(game, opts) self.rows = opts.rows or {} self.nativeProse = opts.nativeProse == true or opts.title == nil self.title = opts.title or Strings("WITHDRAW ITEM") - if self.nativeProse then - self.title = require("src.ui.GbaProse").itemPc(game, "title", self.title) - end self.onPick = opts.onPick self.onCancel = opts.onCancel self.onReorder = opts.onReorder @@ -63,6 +60,13 @@ function Gen3ItemPcFRLG.new(game, opts) return self end +function Gen3ItemPcFRLG:titleText() + if self.nativeProse then + return require("src.ui.GbaProse").itemPc(self.game, "title", self.title) + end + return self.title +end + -- item_pc.c keeps the base screen alive while it places transient BG0 windows -- over the lower description area. In the port those transient windows are -- separate transparent stack states, so the base screen is redrawn first on @@ -304,7 +308,7 @@ function Gen3ItemPcFRLG:draw() love.graphics.setColor(0, 0, 0, 1) local label = self:box("label") - local labelText = tostring(self.title or ""):gsub(" ", "\n", 1) + local labelText = tostring(self:titleText() or ""):gsub(" ", "\n", 1) local faced = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") local uiStyles = self.game.data.font and self.game.data.font.uiTextStyles or {} if uiStyles.white then Font.pushStyle(uiStyles.white) end diff --git a/tests/drivers/gba_menu_prose.lua b/tests/drivers/gba_menu_prose.lua index d96feff99..c3a167dac 100644 --- a/tests/drivers/gba_menu_prose.lua +++ b/tests/drivers/gba_menu_prose.lua @@ -92,7 +92,10 @@ return function(game) U.tap(game, "a") U.wait(10) -- native WITHDRAW ITEM local list = game.stack:top() - assert(list.title == "MOD_PC_TITLE" and list.nativeProse, "Player PC did not route native title label") + assert( + list.titleText and list:titleText() == "MOD_PC_TITLE" and list.nativeProse, + "Player PC did not route native title label" + ) assert(captured.MOD_PC_TITLE and captured.MOD_FOOTER, "native Item-PC render missed title/cancel") U.tap(game, "a") U.wait(10) diff --git a/tests/engine/gba_menu_prose_native.lua b/tests/engine/gba_menu_prose_native.lua index 678992cfb..d5a527cda 100644 --- a/tests/engine/gba_menu_prose_native.lua +++ b/tests/engine/gba_menu_prose_native.lua @@ -42,6 +42,7 @@ for _, version in ipairs({ "firered", "emerald" }) do "OTHER CANCEL", "unmapped role does not borrow first cancel" ) + T.same(f.data.constants, base, "native readers preserve imported constants") f.data.constants.gen3PartyActions = nil p.words = nil T.eq(p:actionWord("summary", nil, "SUMMARY"), "MOD_SUMMARY", "older cache without action tables") @@ -82,12 +83,23 @@ for _, version in ipairs({ "firered", "emerald" }) do local routed = require("src.ui.Gen3ItemPcFRLG").new(f.game, { title = "LOCALIZED BASE", nativeProse = true }) T.eq( - routed.title, + routed:titleText(), version == "firered" and "MOD_PC_TITLE" or "LOCALIZED BASE", "explicit native withdrawal title" ) - -- Registry reads do not mutate importer-owned action tables. - T.same(f.data.constants, base, "imported constants preserved") f.close() + -- Match the existing registry-retirement fixture: discard unrelated builtin + -- namespaces whose duplicate registration is a known baseline issue. + for root in pairs(require("src.mods.Builtins").namespaceRoots()) do + if root ~= "text" and root ~= "constants" then + f.data[root] = nil + end + end + local retired = T.sdk.loadMods({}, { data = f.data, generation = 3, fs = T.sdk.memfs({}), dev = false }) + T.eq(#retired.errors, 0, "empty reload succeeds") + T.eq(routed:titleText(), "LOCALIZED BASE", "existing title reader retires removed owner") + T.eq(sub:entries()[1].label, "WITHDRAW", "existing submenu reader retires removed owner") + T.eq(p:actionWord("summary", nil, "SUMMARY"), "BASE_SUMMARY", "existing party reader retires removed owner") + retired.release() end T.finish("native menu action and render integration") diff --git a/tests/gba_menu_prose/fixture.lua b/tests/gba_menu_prose/fixture.lua index 6cc49815b..f1dbf6865 100644 --- a/tests/gba_menu_prose/fixture.lua +++ b/tests/gba_menu_prose/fixture.lua @@ -216,7 +216,7 @@ function F.new(root, backend, version, mode, api, format) local ok, err = pcall(pc.drawRows, pc) Font.draw = draw assert(ok, err) - return { pc.title, entries[1].label, entries[2].label, entries[3].label, captured[2] } + return { pc:titleText(), entries[1].label, entries[2].label, entries[3].label, captured[2] } end end function f.close() diff --git a/tools/gba-menu-prose-contract-check/README.md b/tools/gba-menu-prose-contract-check/README.md index 729d54184..63d1b5f63 100644 --- a/tools/gba-menu-prose-contract-check/README.md +++ b/tools/gba-menu-prose-contract-check/README.md @@ -15,9 +15,9 @@ branch instead of silently falling back to FireRed's table keys. The actual reference party-footer and FireRed Item-PC draw functions run with captured font output and fixture artwork/palette/item data. No text producer is replaced. -Each configuration compares 232 native text values across both profiles: baseline, +Each configuration compares 261 native text values across both profiles: baseline, replacement, wrong-profile isolation, primary TAKE/action CANCEL separation, -empty strings, removal and failed-owner rollback. The host also runs native +empty strings, line/page/scroll controls, removal and failed-owner rollback. The host also runs native action/render integration checks. These tests do not prove pixel equivalence, complete battle flow, every party message, Item-PC quantity/results, or DS support. The source's native API 2 is not claimed as a portable source-string registry; From 236591a1fa50266311837f2d8569d7395c365b46 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:01:11 +0200 Subject: [PATCH 74/86] Document native GBA menu-prose validation and remaining readers --- docs/portable-gba-menu-prose.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/portable-gba-menu-prose.md b/docs/portable-gba-menu-prose.md index fc686b23f..14ba569fc 100644 --- a/docs/portable-gba-menu-prose.md +++ b/docs/portable-gba-menu-prose.md @@ -45,3 +45,33 @@ This does not bind every party prompt, field-move name, mail/trade message, Item-PC quantity/result dialog or battle message. Emerald's different Item-PC screen and DS screens remain separate work. Text binding is not pixel/layout, complete-playthrough or universal printer equivalence. + +## Validation + +Feature runtime `431fe8c78d3c6815a6df118b96cad3bd4c504f30` passes 257 shared +reader checks and 62 host action/render/retirement checks. The unchanged consumer +matches through both real SDKs: eight isolated workers and 522 paired native +text values across reference API 3, host API 3 and host API 2/unified. Import +label/table facts regenerate deterministically from the pinned reference. + +The feature's complete ROM-free run is 137/147 engine suites: exactly the same +26 failure lines across ten existing baseline suites as parent #171, with no +new failures. The combined testing runtime +`7a12243293dcb7a6ff2a00dc64490f3c6bb5aa32` includes repairs #128–134 and passes +192/192 engine suites, 3/3 SDK suites and Dramatic Shape B30. The same cross-engine +contracts also pass from that combined checkout. + +Native LÖVE 11.5 / imported FireRed and Emerald pass at the combined runtime: +actual party rendering, separate footer and action CANCEL, SUMMARY open/B-return, +ITEM submenu/B-close, party close; FireRed's real Player-PC → Item Storage → +withdrawal title/list/submenu rendering and translated CANCEL without inventory +changes. The smoke uses isolated in-memory New Game state and the installed +public example. No normal SAVE, full playthrough, fresh full-ROM import or pixel +equality is claimed. Temporary example installation/removal is hash guarded. + +Imported Red regressions also pass: battle 468, save conversion 95, parity J 73 +and parity K 22 checks; no external binary save fixture was supplied. The +unchanged #171 negative control still prints `BASE_SUMMARY` after the public mod +requests `MOD_SUMMARY`, confirming that the new native binding is necessary. +New Lua files pass StyLua; the new workflow passes actionlint and the Python +tools compile. Hosted CI results are separate from these local checks. From 0a24af4885ca74cdb21e48ab525a60ba665f3ead Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:38:07 +0200 Subject: [PATCH 75/86] Preserve native trainer attributes and raw GBA item effects during import --- .../trainer-ai-metadata-contract.yml | 34 ++++++ docs/native-trainer-ai-metadata.md | 37 +++++++ src/import/RomExtractorGen2.lua | 21 ++++ src/import/RomExtractorGen3.lua | 31 +++++- tests/engine/trainer_ai_metadata.lua | 72 +++++++++++++ tests/trainer_ai_metadata/fixtures.lua | 101 ++++++++++++++++++ tests/trainer_ai_metadata/worker.lua | 57 ++++++++++ .../README.md | 25 +++++ .../check.py | 54 ++++++++++ 9 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/trainer-ai-metadata-contract.yml create mode 100644 docs/native-trainer-ai-metadata.md create mode 100644 tests/engine/trainer_ai_metadata.lua create mode 100644 tests/trainer_ai_metadata/fixtures.lua create mode 100644 tests/trainer_ai_metadata/worker.lua create mode 100644 tools/trainer-ai-metadata-contract-check/README.md create mode 100644 tools/trainer-ai-metadata-contract-check/check.py diff --git a/.github/workflows/trainer-ai-metadata-contract.yml b/.github/workflows/trainer-ai-metadata-contract.yml new file mode 100644 index 000000000..e6d7672cd --- /dev/null +++ b/.github/workflows/trainer-ai-metadata-contract.yml @@ -0,0 +1,34 @@ +name: native trainer AI metadata +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: trainer-ai-metadata-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native metadata extraction + run: luajit tests/engine/trainer_ai_metadata.lua + - name: Compare actual native importer records + run: | + mkdir -p /tmp/trainer-ai-metadata + python3 tools/trainer-ai-metadata-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/trainer-ai-metadata/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: trainer-ai-metadata + path: /tmp/trainer-ai-metadata + if-no-files-found: ignore diff --git a/docs/native-trainer-ai-metadata.md b/docs/native-trainer-ai-metadata.md new file mode 100644 index 000000000..ef491ae2e --- /dev/null +++ b/docs/native-trainer-ai-metadata.md @@ -0,0 +1,37 @@ +# Native trainer AI import metadata + +Portable enemy switch/item hooks need the native inputs to the decision, +not just matching callback names. Import now preserves two missing inputs: + +- Gen-2 trainer classes keep `attributes`, the exact seven bytes of + `TrainerClassAttributes`, and `items`, the two nonzero item references in + cartridge slot order. Duplicate items are preserved. The existing reward + multiplier still comes from byte three; AI weights and item/switch flags + are retained without inventing an interpretation. +- Each successfully parsed GBA `constants.gen3ItemEffects[id]` record also + keeps `bytes`: the six native effect bytes followed by their actual argument + bytes. This retains X-item bits, raw full/half-heal sentinels and compound + EV/PP/friendship payloads needed by native battle readers. It accompanies + the existing parsed medicine fields; their behavior is not changed here. + +Gen-2 rows use the existing seven-byte class stride and version-selected +symbol lookup. Missing, invalid or truncated optional rows leave AI metadata +absent; they do not fabricate flags or stock. An empty valid stock becomes an +empty list. References use the host's existing `ITEM_nnn` format. GBA payloads +use native amount-field counts, including the shared PP argument, and ordinary +ROM bounds checks refuse truncated data instead of reading a neighboring item. + +These fields are generated during import. Existing caches retain their previous +format until reimported; this change does not invent missing bytes or mutate a +loaded battle/save. It does not enable `battle.enemy_switch_or_item` by itself, +change native automatic decisions, or extend the writable API-3 trainer schema. +The hook and gameplay integration follow as separate work. + +The ROM-free unit suite drives the real host trainer/group and item-effects +readers with synthetic cartridge bytes and checks cache serialization. Shared +contracts compare the actual reference importers at pinned Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: six workers, 1,168 matching metadata +records across the Gen-2 reader and FireRed/Emerald effect-byte matrices. +See `tools/trainer-ai-metadata-contract-check/README.md` for reproduction. +No copyrighted ROM assets or text are included. This is not a full-ROM reimport, +native gameplay, AI-policy, save or pixel-equivalence claim. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 79dc65a28..01c480894 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3796,6 +3796,24 @@ local GEN2_CLASS_POKEMON_PROF = 10 local GEN2_CLASS_ATTRIBUTE_BYTES = 7 local GEN2_CLASS_ATTRIBUTE_REWARD = 2 +function RomExtractorGen2:gen2TrainerAttributes(group) + if type(group) ~= "number" or group % 1 ~= 0 or group < 1 or group > 255 then return nil end + local sym = self:symbol("TrainerClassAttributes") + if not (sym and self.rom) then return nil end + local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, + sym.address + (group - 1) * GEN2_CLASS_ATTRIBUTE_BYTES, GEN2_CLASS_ATTRIBUTE_BYTES) + if not ok or type(row) ~= "table" or #row ~= GEN2_CLASS_ATTRIBUTE_BYTES then return nil end + for i = 1, GEN2_CLASS_ATTRIBUTE_BYTES do + if type(row[i]) ~= "number" or row[i] % 1 ~= 0 or row[i] < 0 or row[i] > 255 then return nil end + end + local attributes, items = {}, {} + for i = 1, GEN2_CLASS_ATTRIBUTE_BYTES do attributes[i] = row[i] end + for slot = 1, 2 do + if row[slot] ~= 0 then items[#items + 1] = string.format("ITEM_%03d", row[slot]) end + end + return attributes, items +end + function RomExtractorGen2:gen2TrainerBaseMoney(group) local sym = self:symbol("TrainerClassAttributes") if not (sym and self.rom) then return 0 end @@ -3908,12 +3926,15 @@ function RomExtractorGen2:gen2Trainers() if used[label] then label = label .. "_" .. tostring(group) end used[label] = true local id = "OPP_" .. label + local attributes, items = self:gen2TrainerAttributes(group) byClass[id] = { id = id, index = group, name = className or label, source = "ROM:TrainerGroups", baseMoney = self:gen2TrainerBaseMoney(group), + attributes = attributes, + items = items, parties = parties, partyNames = names, } diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index d9c7919de..d66aa39a5 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -40514,6 +40514,34 @@ function RomExtractorGen3.signedByte(v) return v > 127 and (v - 256) or v end +-- Retain the native effect bytes as well as the existing parsed medicine +-- view. Battle AI reads individual X-item bits and raw sentinel amounts. +-- Multiple EV arguments each consume a byte; the two PP bits share one. +function RomExtractorGen3.itemEffectBytes(rom, at) + local bytes = {} + for i = 0, 5 do + local value = rom:u8(at + i) + assert(type(value) == "number" and value % 1 == 0 and value >= 0 and value <= 255, + "truncated item effect header") + bytes[i + 1] = value + end + local function bits(value) + local count = 0 + while value > 0 do count, value = count + value % 2, math.floor(value / 2) end + return count + end + local e4, e5 = bytes[5], bytes[6] + local count = bits(e4 % 8) + (math.floor(e4 / 8) % 4 ~= 0 and 1 or 0) + + bits(e5 % 16) + bits(math.floor(e5 / 32)) + for i = 1, count do + local value = rom:u8(at + 5 + i) + assert(type(value) == "number" and value % 1 == 0 and value >= 0 and value <= 255, + "truncated item effect arguments") + bytes[#bytes + 1] = value + end + return bytes +end + function RomExtractorGen3:extractItemEffects() self:beginStage("Gen3 item effects") local E = RomExtractorGen3.ITEM_EFFECTS @@ -40535,7 +40563,8 @@ function RomExtractorGen3:extractItemEffects() local b = {} for i = 0, 5 do b[i] = rom:u8(at + i) end local effect, arg, ok = { source = ("ROM:gItemEffectTable[%d] %08X") - :format(k, at + 0x08000000) }, E.ARGS_AT, true + :format(k, at + 0x08000000), + bytes = RomExtractorGen3.itemEffectBytes(rom, at) }, E.ARGS_AT, true -- ---- the battle-only three, recorded and not run ------------------ if b[0] % 0x40 ~= 0 or b[1] ~= 0 or b[2] ~= 0 then diff --git a/tests/engine/trainer_ai_metadata.lua b/tests/engine/trainer_ai_metadata.lua new file mode 100644 index 000000000..7e2b0a885 --- /dev/null +++ b/tests/engine/trainer_ai_metadata.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = dofile("tests/trainer_ai_metadata/fixtures.lua") +local rows, e = F.gen2("gen2") +T.same(F.normalized(rows), { + { index = 1, attributes = { 13, 13, 8, 170, 187, 1, 2 }, items = { "ITEM_013", "ITEM_013" }, baseMoney = 8 }, + { index = 2, attributes = { 0, 19, 12, 204, 221, 4, 0 }, items = { "ITEM_019" }, baseMoney = 12 }, +}, "native trainer extraction retains exact seven-byte records and stocked slots") +local attrs, items = e:gen2TrainerAttributes(1) +attrs[1], items[1] = 99, "EXTERNAL" +local again, stock = e:gen2TrainerAttributes(1) +T.eq(again[1], 13, "attribute reads are detached from ROM and other views") +T.eq(stock[1], "ITEM_013", "stock reads are detached") +for _, group in ipairs({ 0, -1, 1.5, 256 }) do + T.eq(e:gen2TrainerAttributes(group), nil, "invalid class row refused") +end +e.symbol = function() + return nil +end +T.eq(e:gen2TrainerAttributes(1), nil, "missing attributes do not fabricate native flags") +local G = require("src.import.RomExtractorGen3") +local R = require("src.import.RomGba") +local r = R.new(string.char(1, 2, 3, 4, 4, 0, 255)) +T.same(G.itemEffectBytes(r, 0), { 1, 2, 3, 4, 4, 0, 255 }, "raw heal sentinel and battle bits survive") +T.raises(function() + G.itemEffectBytes(R.new(string.char(0, 0, 0, 0, 4, 0)), 0) +end, "past the end", "truncated argument refused") +T.raises(function() + G.itemEffectBytes(R.new(string.char(0, 0, 0)), 0) +end, "past the end", "truncated header refused") +local source = string.char(9, 8, 7, 6, 24, 0, 123, 77) +T.same( + G.itemEffectBytes(R.new(source), 0), + { 9, 8, 7, 6, 24, 0, 123 }, + "two PP bits consume one shared byte without reading the next record" +) +local emitted +local effect = { 1, 0, 0, 0, 4, 224, 20, 255, 2, 3 } +local blob = {} +for i, v in ipairs(effect) do + blob[i] = string.char(v) +end +local real = R.new(table.concat(blob)) +local extractor = setmetatable({ + rom = { + u8 = function(_, at) + return real:u8(at - 100) + end, + pointer = function(_, at) + if at == G.ITEM_EFFECTS.TABLE then + return 100 + end + end, + }, + _constants = { itemOrder = { [13] = "ITEM_013" } }, + beginStage = function() end, + write = function(_, name, value) + if name == "constants" then + emitted = value + end + end, +}, { __index = G }) +extractor:extractItemEffects() +local native = assert(extractor._constants.gen3ItemEffects.ITEM_013) +T.same(native.bytes, effect, "actual item-effects stage publishes raw bytes alongside parsed medicine") +T.eq(native.heal, true, "existing parsed medicine is preserved") +T.eq(native.amount, 20, "existing parsed amount is preserved") +T.same(native.friendship, { -1, 2, 3 }, "signed friendship parser remains intact") +T.eq(emitted, extractor._constants, "the actual stage emits the extended constants record") +local saved = assert(loadstring(require("src.import.LuaWriter").encode(emitted)))() +T.same(saved.gen3ItemEffects.ITEM_013.bytes, effect, "raw bytes survive native cache serialization") +T.finish("native trainer AI import metadata") diff --git a/tests/trainer_ai_metadata/fixtures.lua b/tests/trainer_ai_metadata/fixtures.lua new file mode 100644 index 000000000..a3d2b0a14 --- /dev/null +++ b/tests/trainer_ai_metadata/fixtures.lua @@ -0,0 +1,101 @@ +local F = {} +function F.gen2(backend) + local Extractor = require("src.import.RomExtractorGen2") + local Rom = require("src.import.Rom") + local bytes = {} + for i = 1, 0xC000 do + bytes[i] = 255 + end + local function put(bank, at, values) + local first = Rom.offset(bank, at) + 1 + for i, value in ipairs(values) do + bytes[first + i - 1] = value + end + end + put(1, 0x4000, { 0x00, 0x41, 0x05, 0x41 }) + put(1, 0x4100, { 0x50, 0, 5, 4, 255, 0x50, 0, 6, 4, 255 }) + put(1, 0x4200, { 0x80, 0x50, 0x81, 0x50 }) + put(1, 0x4300, { 0, 0, 0 }) + put(2, 0x5000, { 13, 13, 8, 170, 187, 1, 2, 0, 19, 12, 204, 221, 4, 0 }) + local chunks = {} + for i, value in ipairs(bytes) do + chunks[i] = string.char(value) + end + local symbols = { + TrainerGroups = { bank = 1, address = 0x4000 }, + Trainers = { bank = 1, address = 0x4100 }, + TrainerClassNames = { bank = 1, address = 0x4200 }, + TrainerEncounterMusic = { bank = 1, address = 0x4300 }, + TrainerClassAttributes = { bank = 2, address = 0x5000 }, + } + local manifest = { + constants = { + trainerClassOrder = { "NONE", "ALPHA", "BETA" }, + trainerClassMembers = { ALPHA = { "ONE" }, BETA = { "ONE" } }, + trainerTypeOrder = { "NORMAL" }, + itemOrder = { [13] = "ITEM_013", [19] = "ITEM_019" }, + moveOrder = {}, + speciesOrder = { "NONE", "A", "B", "C", "CHARMANDER" }, + }, + charmap = { ["128"] = "A", ["129"] = "B" }, + } + local e = setmetatable( + { rom = Rom.new(table.concat(chunks)), manifest = manifest, symbols = {} }, + { __index = Extractor } + ) + e.symbol = function(_, name) + return symbols[name] + end + e.beginStage = function() end + e.tick = function() end + e.write = function() end + e.readBattleTowerRoster = function() + return nil + end + -- Both extractors use their real group/party/attribute readers. Only the + -- manifest and symbol-table IO are synthetic, not the new field producer. + if backend == "gen1" then + return e:extractTrainers().classes, e + end + e.constants = function() + return manifest.constants + end + return e:gen2Trainers().byClass, e +end +function F.normalized(rows) + local out = {} + for _, row in pairs(rows) do + out[#out + 1] = + { index = row.index, attributes = row.attributes, items = row.items, baseMoney = row.baseMoney } + end + table.sort(out, function(a, b) + return a.index < b.index + end) + return out +end +function F.effects() + local out, seen = {}, {} + local function add(e4, e5) + local key = e4 .. ":" .. e5 + if seen[key] then + return + end + seen[key] = true + local bytes = { 3, 0xA2, 0x91, 0xFF, e4, e5 } + for i = 1, 11 do + bytes[#bytes + 1] = i * 17 + end + out[#out + 1] = bytes + end + for n = 0, 255 do + add(n, 0) + add(0, n) + end + for _, e4 in ipairs({ 3, 7, 8, 16, 24, 31, 64, 128, 255 }) do + for _, e5 in ipairs({ 1, 15, 16, 32, 64, 128, 224, 255 }) do + add(e4, e5) + end + end + return out +end +return F diff --git a/tests/trainer_ai_metadata/worker.lua b/tests/trainer_ai_metadata/worker.lua new file mode 100644 index 000000000..893985665 --- /dev/null +++ b/tests/trainer_ai_metadata/worker.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, kind, version = unpack(arg) +require("tests.modkit") +local F = dofile(root .. "/tests/trainer_ai_metadata/fixtures.lua") +local out = {} +if kind == "gen2" then + out = F.normalized(F.gen2(backend)) +else + local E, R + if backend == "gen2" then + E = require("src.import.RomExtractorGen3") + R = require("src.import.RomGba") + else + require("src.core.GameVersion").set(version) + local Versions = require("src.import.gba.versions") + Versions.select(version) + -- Bound this synthetic item-table fixture to the first effect-bearing + -- row. All effect parsing and pack serialization remain native. + Versions.ITEMS_COUNT = 14 + E = require("src.import.gba.items_extract") + end + for _, bytes in ipairs(F.effects()) do + if backend == "gen2" then + local chars = {} + for i, v in ipairs(bytes) do + chars[i] = string.char(v) + end + out[#out + 1] = E.itemEffectBytes(R.new(table.concat(chars)), 0) + else + local V = require("src.import.gba.versions") + local addr = 0x1000 + local rom = { + get = function(_, off) + if off >= addr and off < addr + #bytes then + return bytes[off - addr + 1] + end + return 0 + end, + u32 = function(_, off) + if off == V.ITEM_EFFECT_TABLE then + return addr + 0x08000000 + end + return 0 + end, + } + local written + E.run(rom, { + write = function(_, _, text) + written = text + end, + }, { force = true, cacheRoot = "fixture" }) + local pack = assert(loadstring(assert(written)))() + out[#out + 1] = assert(pack.items[13].effect) + end + end +end +print("TRAINER_AI_METADATA " .. require("src.link.Json").encode(out)) diff --git a/tools/trainer-ai-metadata-contract-check/README.md b/tools/trainer-ai-metadata-contract-check/README.md new file mode 100644 index 000000000..e865a8ee6 --- /dev/null +++ b/tools/trainer-ai-metadata-contract-check/README.md @@ -0,0 +1,25 @@ +# Native trainer AI metadata + +```sh +python3 tools/trainer-ai-metadata-contract-check/check.py --gen1 /path/to/gen1 --lua /path/to/luajit +``` + +The reference must be clean at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +Six isolated workers compare native importer outputs, using only synthetic +cartridge bytes. Gen 2 reads two real seven-byte attribute rows through each +engine's actual trainer/group reader, including duplicate/empty stocked slots. +Manifest/symbol-table IO is fixture data; the attribute producer is not mocked. + +For FireRed and Emerald, the reference's actual item-pack extractor reads 583 +effect-byte combinations and serializes its native cache records. The host +reads the same bytes through `RomGba` and its new raw-effect reader. Coverage +includes every byte-4/byte-5 mask independently and compound EV/PP/friendship +masks. The synthetic reference item-table fixture is bounded to native row 13; +other cartridge records/assets are not needed or copied. The paired results +include 1,168 exact metadata records in total. + +This is an importer/format contract, not mod-callback, AI policy, full-ROM +reimport, native gameplay, presentation or save compatibility proof. The host +unit suite also executes its actual item-effects stage, checks that existing +parsed healing/friendship values survive, round-trips the emitted cache data, +and refuses missing/truncated/invalid attribute/effect records. diff --git a/tools/trainer-ai-metadata-contract-check/check.py b/tools/trainer-ai-metadata-contract-check/check.py new file mode 100644 index 000000000..dc68ac7f4 --- /dev/null +++ b/tools/trainer-ai-metadata-contract-check/check.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare native importer results using synthetic cartridge bytes, no ROM assets.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--report", type=Path) + a = p.parse_args() + source, host = a.gen1.resolve(), a.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + p.error("reference must be clean at " + REFERENCE) + runs, count = [], 0 + for kind, version in [("gen2", "gold"), ("gba", "firered"), ("gba", "emerald")]: + observations = [] + for backend, engine in [("gen1", source), ("gen2", host)]: + run = subprocess.run([a.lua, str(host / "tests/trainer_ai_metadata/worker.lua"), str(host), + backend, kind, version], cwd=engine, + capture_output=True, text=True, timeout=60) + lines = [l.removeprefix("TRAINER_AI_METADATA ") for l in run.stdout.splitlines() + if l.startswith("TRAINER_AI_METADATA ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + value = json.loads(lines[0]); observations.append(value) + runs.append({"backend": backend, "kind": kind, "version": version, "observations": value}) + if observations[0] != observations[1]: + for i, (left, right) in enumerate(zip(*observations)): + if left != right: + raise AssertionError(f"{version}/{i}: reference={left!r}, host={right!r}") + raise AssertionError(f"{version}: different observation counts") + count += len(observations[0]) + print(f"PASS {version}: {len(observations[0])} matching native metadata records") + assert not git(source, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"reference_ref": REFERENCE, + "host_ref": git(host, "rev-parse", "HEAD"), "host_dirty": bool(git(host, "status", "--porcelain")), + "paired_records": count, "runs": runs}, indent=2) + "\n") + + +if __name__ == "__main__": + main() From 3797315be3991c296ac6425145d156faf9d23c28 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:38:56 +0200 Subject: [PATCH 76/86] Check invalid native trainer rows and clarify raw effect bounds --- docs/native-trainer-ai-metadata.md | 4 ++-- tests/engine/trainer_ai_metadata.lua | 29 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/native-trainer-ai-metadata.md b/docs/native-trainer-ai-metadata.md index ef491ae2e..f1fc23a1f 100644 --- a/docs/native-trainer-ai-metadata.md +++ b/docs/native-trainer-ai-metadata.md @@ -18,8 +18,8 @@ Gen-2 rows use the existing seven-byte class stride and version-selected symbol lookup. Missing, invalid or truncated optional rows leave AI metadata absent; they do not fabricate flags or stock. An empty valid stock becomes an empty list. References use the host's existing `ITEM_nnn` format. GBA payloads -use native amount-field counts, including the shared PP argument, and ordinary -ROM bounds checks refuse truncated data instead of reading a neighboring item. +use native amount-field counts, including the shared PP argument. Ordinary ROM +bounds checks refuse truncated headers and argument payloads. These fields are generated during import. Existing caches retain their previous format until reimported; this change does not invent missing bytes or mutate a diff --git a/tests/engine/trainer_ai_metadata.lua b/tests/engine/trainer_ai_metadata.lua index 7e2b0a885..c7dd44059 100644 --- a/tests/engine/trainer_ai_metadata.lua +++ b/tests/engine/trainer_ai_metadata.lua @@ -3,7 +3,12 @@ local T = require("tests.modkit") local F = dofile("tests/trainer_ai_metadata/fixtures.lua") local rows, e = F.gen2("gen2") T.same(F.normalized(rows), { - { index = 1, attributes = { 13, 13, 8, 170, 187, 1, 2 }, items = { "ITEM_013", "ITEM_013" }, baseMoney = 8 }, + { + index = 1, + attributes = { 13, 13, 8, 170, 187, 1, 2 }, + items = { "ITEM_013", "ITEM_013" }, + baseMoney = 8, + }, { index = 2, attributes = { 0, 19, 12, 204, 221, 4, 0 }, items = { "ITEM_019" }, baseMoney = 12 }, }, "native trainer extraction retains exact seven-byte records and stocked slots") local attrs, items = e:gen2TrainerAttributes(1) @@ -18,6 +23,28 @@ e.symbol = function() return nil end T.eq(e:gen2TrainerAttributes(1), nil, "missing attributes do not fabricate native flags") +for _, row in ipairs({ { 1, 2, 3, 4, 5, 6 }, { 1, 2, 3, 4, 5, 6, -1 }, { 1, 2, 3, 4, 5, 6, 256 }, { + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, +} }) do + local invalid = setmetatable({ + symbol = function() + return { bank = 1, address = 0x4000 } + end, + rom = { + bytes = function() + return row + end, + }, + }, { __index = require("src.import.RomExtractorGen2") }) + T.eq(invalid:gen2TrainerAttributes(1), nil, "incomplete or invalid attribute row refused") +end local G = require("src.import.RomExtractorGen3") local R = require("src.import.RomGba") local r = R.new(string.char(1, 2, 3, 4, 4, 0, 255)) From 0f9f5cebbcb0abb0098f68370f2ce7962c0b54ac Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:40:53 +0200 Subject: [PATCH 77/86] Refuse malformed optional trainer symbols and invalid effect offsets --- src/import/RomExtractorGen2.lua | 7 +++- src/import/RomExtractorGen3.lua | 1 + tests/engine/trainer_ai_metadata.lua | 49 ++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 01c480894..deee30a35 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3800,8 +3800,13 @@ function RomExtractorGen2:gen2TrainerAttributes(group) if type(group) ~= "number" or group % 1 ~= 0 or group < 1 or group > 255 then return nil end local sym = self:symbol("TrainerClassAttributes") if not (sym and self.rom) then return nil end + if type(sym.bank) ~= "number" or sym.bank % 1 ~= 0 or sym.bank < 0 + or type(sym.address) ~= "number" or sym.address % 1 ~= 0 then return nil end + local at = sym.address + (group - 1) * GEN2_CLASS_ATTRIBUTE_BYTES + local first, limit = sym.bank == 0 and 0 or 0x4000, sym.bank == 0 and 0x4000 or 0x8000 + if at < first or at + GEN2_CLASS_ATTRIBUTE_BYTES > limit then return nil end local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, - sym.address + (group - 1) * GEN2_CLASS_ATTRIBUTE_BYTES, GEN2_CLASS_ATTRIBUTE_BYTES) + at, GEN2_CLASS_ATTRIBUTE_BYTES) if not ok or type(row) ~= "table" or #row ~= GEN2_CLASS_ATTRIBUTE_BYTES then return nil end for i = 1, GEN2_CLASS_ATTRIBUTE_BYTES do if type(row[i]) ~= "number" or row[i] % 1 ~= 0 or row[i] < 0 or row[i] > 255 then return nil end diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index d66aa39a5..13ec7968a 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -40518,6 +40518,7 @@ end -- view. Battle AI reads individual X-item bits and raw sentinel amounts. -- Multiple EV arguments each consume a byte; the two PP bits share one. function RomExtractorGen3.itemEffectBytes(rom, at) + assert(type(at) == "number" and at % 1 == 0 and at >= 0, "invalid item effect offset") local bytes = {} for i = 0, 5 do local value = rom:u8(at + i) diff --git a/tests/engine/trainer_ai_metadata.lua b/tests/engine/trainer_ai_metadata.lua index c7dd44059..1f0ad9255 100644 --- a/tests/engine/trainer_ai_metadata.lua +++ b/tests/engine/trainer_ai_metadata.lua @@ -23,16 +23,21 @@ e.symbol = function() return nil end T.eq(e:gen2TrainerAttributes(1), nil, "missing attributes do not fabricate native flags") -for _, row in ipairs({ { 1, 2, 3, 4, 5, 6 }, { 1, 2, 3, 4, 5, 6, -1 }, { 1, 2, 3, 4, 5, 6, 256 }, { - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, -} }) do +for _, row in ipairs({ + { 1, 2, 3, 4, 5, 6 }, + { 1, 2, 3, 4, 5, 6, -1 }, + { 1, 2, 3, 4, 5, 6, 256 }, + { + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + }, +}) do local invalid = setmetatable({ symbol = function() return { bank = 1, address = 0x4000 } @@ -46,9 +51,33 @@ for _, row in ipairs({ { 1, 2, 3, 4, 5, 6 }, { 1, 2, 3, 4, 5, 6, -1 }, { 1, 2, 3 T.eq(invalid:gen2TrainerAttributes(1), nil, "incomplete or invalid attribute row refused") end local G = require("src.import.RomExtractorGen3") +for _, sym in ipairs({ + { bank = 1, address = 0x7FFF }, + { bank = 0, address = 0x3FFF }, + { bank = 1, address = false }, + { bank = -1, address = 0x4000 }, +}) do + local invalid = setmetatable({ + symbol = function() + return sym + end, + rom = { + bytes = function() + error("invalid symbol reached ROM reader") + end, + }, + }, { __index = require("src.import.RomExtractorGen2") }) + T.eq(invalid:gen2TrainerAttributes(1), nil, "invalid or bank-crossing optional attribute symbols refused") +end local R = require("src.import.RomGba") local r = R.new(string.char(1, 2, 3, 4, 4, 0, 255)) T.same(G.itemEffectBytes(r, 0), { 1, 2, 3, 4, 4, 0, 255 }, "raw heal sentinel and battle bits survive") +T.raises(function() + G.itemEffectBytes(r, -1) +end, "invalid item effect offset", "negative effect offset refused") +T.raises(function() + G.itemEffectBytes(r, 0.5) +end, "invalid item effect offset", "fractional effect offset refused") T.raises(function() G.itemEffectBytes(R.new(string.char(0, 0, 0, 0, 4, 0)), 0) end, "past the end", "truncated argument refused") From 2ef7341810df813e09f0cc30f648cee1c57f5074 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:42:12 +0200 Subject: [PATCH 78/86] Verify invalid optional symbols never reach the cartridge reader --- tests/engine/trainer_ai_metadata.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/trainer_ai_metadata.lua b/tests/engine/trainer_ai_metadata.lua index 1f0ad9255..fdf9651ea 100644 --- a/tests/engine/trainer_ai_metadata.lua +++ b/tests/engine/trainer_ai_metadata.lua @@ -57,17 +57,20 @@ for _, sym in ipairs({ { bank = 1, address = false }, { bank = -1, address = 0x4000 }, }) do + local reads = 0 local invalid = setmetatable({ symbol = function() return sym end, rom = { bytes = function() - error("invalid symbol reached ROM reader") + reads = reads + 1 + return { 0, 0, 0, 0, 0, 0, 0 } end, }, }, { __index = require("src.import.RomExtractorGen2") }) T.eq(invalid:gen2TrainerAttributes(1), nil, "invalid or bank-crossing optional attribute symbols refused") + T.eq(reads, 0, "invalid optional symbol is refused before reading cartridge bytes") end local R = require("src.import.RomGba") local r = R.new(string.char(1, 2, 3, 4, 4, 0, 255)) From 0244afae3f573e9680525b3ef0f81a5452722a24 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:45:48 +0200 Subject: [PATCH 79/86] Record validated native AI metadata contracts and combined suite results --- docs/native-trainer-ai-metadata.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/native-trainer-ai-metadata.md b/docs/native-trainer-ai-metadata.md index f1fc23a1f..f8a5f6ed1 100644 --- a/docs/native-trainer-ai-metadata.md +++ b/docs/native-trainer-ai-metadata.md @@ -35,3 +35,12 @@ records across the Gen-2 reader and FireRed/Emerald effect-byte matrices. See `tools/trainer-ai-metadata-contract-check/README.md` for reproduction. No copyrighted ROM assets or text are included. This is not a full-ROM reimport, native gameplay, AI-policy, save or pixel-equivalence claim. + +The feature runtime `2ef7341810df813e09f0cc30f648cee1c57f5074` passes 32 focused +checks. Its complete ROM-free run is 138/148 engine suites, with exactly the +same 26 failure lines across ten baseline suites as parent #172; SDK 3/3 and +Dramatic Shape B30 pass. The combined runtime +`ddac8aaf1d6b042ddd3defe57fe3e8de1b8b2039`, including repairs #128–134, passes +193/193 engine suites, SDK 3/3 and B30. Formatting of the new Lua tests, +actionlint, Python compilation and diff checks also pass. Hosted CI remains +separate from these local results. From 8bb262c3d0b269e531a5748a0dc64594874e68d4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:34:45 +0200 Subject: [PATCH 80/86] Normalize portable AI-class item references and detached content results --- src/mods/UnifiedContent.lua | 33 ++++++++++++++++++++ tests/engine/unified_content.lua | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 8f91fe7d5..3d13a1bac 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -415,6 +415,39 @@ function U.new(loader, mod, native, gen) if native.marts then out.marts = require("src.mods.MartContent").api(loader, mod, native.marts, gen, true) end + if native.ai_classes and gen <= 3 then + local source, handle = native.ai_classes, {} + for key, value in pairs(source) do handle[key] = value end + local function project(value) + value = copy(value) + if type(value) == "table" and value.item ~= nil then + value.item = Ids.new(loader):read("items", value.item) + end + return value + end + handle.get = function(_, id) return project(source:get(id)) end + handle.each = function() + local nextRow = source:each() + return function() + local id, value = nextRow() + if id ~= nil then return id, project(value) end + end + end + for _, verb in ipairs({ "register", "override", "patch" }) do + handle[verb] = function(_, id, value) + return protect(function() + finite(value) + value = copy(value) + if type(value) == "table" and value.item ~= nil and value.item ~= DELETE then + value.item = resolved({ ids = Ids.new(loader) }, "items", value.item) + end + source[verb](source, id, value) + return handle:get(id) + end) + end + end + out.ai_classes = 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 diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua index b7695caa9..ccc28819d 100644 --- a/tests/engine/unified_content.lua +++ b/tests/engine/unified_content.lua @@ -466,5 +466,57 @@ 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") +-- Class item references use the same canonical boundary as party item refs. +for gen = 1, 3 do + local input, keys = fixture(gen) + if gen == 3 then + input.items.ITEM_013, input.items.POTION = input.items.POTION, nil + input.items.ITEM_013.id = "ITEM_013" + input.trainers[keys.trainer].parties[1][1].item = "ITEM_013" + keys.item = "ITEM_013" + end + local api, loader, data = run(gen, function(mod, loading) + local handle = mod.content.ai_classes + local row = handle:register("AI_PORTABLE", { kind = "class", uses = 1, item = "POTION" }) + T.eq(row.item, "POTION", "class register result uses canonical item") + row.uses = 99 + T.eq(handle:get("AI_PORTABLE").uses, 1, "class register result is detached") + local before = ops(loading.content.ai_classes) + local value, reason = handle:patch("AI_PORTABLE", { item = "UNKNOWN_ITEM" }) + T.eq(value, nil, "unknown class item is refused before a write") + T.check(reason:find("unknown items ID", 1, true) ~= nil, "unknown class item explains refusal") + T.eq(ops(loading.content.ai_classes), before, "unknown class item records no operation") + raises(function() handle:patch("AI_PORTABLE", { uses = math.huge }) end, "non-finite") + local cyclic = {}; cyclic.item = cyclic + raises(function() handle:patch("AI_PORTABLE", cyclic) end, "cyclic record") + handle:patch("AI_PORTABLE", { item = DELETE }) + T.eq(handle:get("AI_PORTABLE").item, nil, "class optional item can be deleted") + handle:override("AI_PORTABLE", { kind = "class", uses = 2, item = "POTION" }) + for id, iterated in handle:each() do + if id == "AI_PORTABLE" then iterated.item = "UNKNOWN_ITEM" end + end + T.eq(handle:get("AI_PORTABLE").item, "POTION", "class iteration is detached") + end, "unified", nil, Merge.deepCopy(input)) + T.eq(#loader.errors, 0, "canonical class reference passes native validation") + T.eq(data.ai_classes.AI_PORTABLE.item, keys.item, "native class consumer receives physical item key") + raises(function() api.content.ai_classes:patch("AI_PORTABLE", { item = "POTION" }) end, "frozen") + local _, failed, rolledBack = run(gen, function(mod) + mod.content.ai_classes:register("AI_ROLLBACK", { uses = 1, item = "POTION" }) + error("intentional class entry failure") + end, "unified", nil, Merge.deepCopy(input)) + T.eq(failed.mods.probe.state, "failed", "class entry failure attributed") + T.eq(rolledBack.ai_classes.AI_ROLLBACK, nil, "class registration rolls back with failed owner") + local _, removed = run(gen, function(mod) + mod.content.ai_classes:register("AI_DANGLING", { uses = 1, item = "POTION" }) + mod.content.items:remove("POTION") + end, "unified", nil, Merge.deepCopy(input)) + T.check(#removed.errors > 0, "class item retains native dangling-reference diagnostics") + local _, legacy, legacyData = run(gen, function(mod) + mod.content.ai_classes:register("AI_NATIVE", { uses = 1, item = keys.item }) + end, "native", nil, Merge.deepCopy(input)) + T.eq(#legacy.errors, 0, "legacy native class references still load") + T.eq(legacyData.ai_classes.AI_NATIVE.item, keys.item, "legacy class item key is unchanged") +end + Version.set(original) T.finish("unified content") From bcb655acdd29723c13e3484e0498fd34affc456e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:36:41 +0200 Subject: [PATCH 81/86] Verify portable AI-class metadata through both public SDKs --- .../workflows/ai-class-content-contract.yml | 37 +++++++++++ docs/api3-portability.md | 3 + docs/portable-ai-class-content.md | 57 ++++++++++++++++ examples/portable_ai_class_content/main.lua | 66 +++++++++++++++++++ .../portable_ai_class_content/manifest.json | 8 +++ tests/ai_class_content/cases.lua | 40 +++++++++++ tests/ai_class_content/fixture.lua | 35 ++++++++++ tests/ai_class_content/worker.lua | 5 ++ tests/drivers/ai_class_content.lua | 60 +++++++++++++++++ tests/engine/ai_class_content.lua | 7 ++ .../ai-class-content-contract-check/README.md | 33 ++++++++++ .../ai-class-content-contract-check/check.py | 64 ++++++++++++++++++ 12 files changed, 415 insertions(+) create mode 100644 .github/workflows/ai-class-content-contract.yml create mode 100644 docs/portable-ai-class-content.md create mode 100644 examples/portable_ai_class_content/main.lua create mode 100644 examples/portable_ai_class_content/manifest.json create mode 100644 tests/ai_class_content/cases.lua create mode 100644 tests/ai_class_content/fixture.lua create mode 100644 tests/ai_class_content/worker.lua create mode 100644 tests/drivers/ai_class_content.lua create mode 100644 tests/engine/ai_class_content.lua create mode 100644 tools/ai-class-content-contract-check/README.md create mode 100644 tools/ai-class-content-contract-check/check.py diff --git a/.github/workflows/ai-class-content-contract.yml b/.github/workflows/ai-class-content-contract.yml new file mode 100644 index 000000000..86cf970e8 --- /dev/null +++ b/.github/workflows/ai-class-content-contract.yml @@ -0,0 +1,37 @@ +name: portable AI class content +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ai-class-content-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check item-reference and loader boundaries + run: | + luajit tests/engine/unified_content.lua + luajit tests/engine/ai_class_content.lua + - name: Compare unchanged public consumer + run: | + mkdir -p /tmp/ai-class-content + python3 tools/ai-class-content-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 3 --report /tmp/ai-class-content/api3.json + python3 tools/ai-class-content-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/ai-class-content/api2.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: ai-class-content + path: /tmp/ai-class-content + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 6dfa29a59..0c36628f6 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -190,3 +190,6 @@ GBA bindings, see [portable ROM prose](api3-rom-text.md). For native FireRed/Emerald party action labels, separate cancel buttons and FireRed Item-PC withdrawal labels, see [GBA menu prose](portable-gba-menu-prose.md). + +For common AI-class item IDs, detached definition records and the API-2/unified +boundary, see [portable AI-class content](portable-ai-class-content.md). diff --git a/docs/portable-ai-class-content.md b/docs/portable-ai-class-content.md new file mode 100644 index 000000000..6e9810c9f --- /dev/null +++ b/docs/portable-ai-class-content.md @@ -0,0 +1,57 @@ +# Portable AI-class definitions + +API 3 exposes `mod.content.ai_classes` on Gen-1, Gen-2 and the registered +FireRed/Emerald profiles. Class `item` references use common names such as +`POTION` and `FULL_RESTORE`, resolved against the mounted item registry. Mods +do not need generation-specific `ITEM_018` or `ITEM_013` keys. + +```lua +local mod = ... +if not mod:supports("content", "ai_classes") then return end +mod.content.ai_classes:register("MY_CLASS", { + kind = "class", uses = 2, chance = 256, item = "POTION", +}) +``` + +`register`, `override` and `patch` translate an optional `item` before recording +the native operation and return a detached record with its common item name. +Unknown item names return `nil, reason` without recording that operation; +non-finite numbers and cyclic records are errors. Native schema, duplicate-ID +and dangling-reference checks still apply. `override` replaces the definition, +whereas `patch` preserves omitted fields. Use `mod.DELETE` in a patch to remove +the optional item. `remove` retains the ordinary registry behavior. + +`get` and `each` also return detached records. Editing these copies cannot +change the runtime AI class. Use registry operations during mod entry to +author definitions; writes freeze after loading, and a failed entry rolls its +operations back. Functions in `kind = "brain"` records retain their callable +identity; copying a record does not sandbox a callback's captured state. + +Gen2Recomped also applies this boundary to API 2 mods explicitly opting into +`content_format = "unified"`. The shared reference contract is **Gen1Recomp +API 3**, compared with Gen2Recomped API 3 and API 2/unified. Gen1Recomp's API-2 +AI-class passthrough exposes live records even with that manifest field; this +PR does not claim equal API-2 read-aliasing behavior on both engines. Native +API-1/2 content formats retain their existing physical item keys and handles. + +This aligns definition records, not the native battle policies that consume +them. It does not enable `battle.enemy_switch_or_item`, add new writable +trainer fields, or establish identical brain execution and enemy item/switch +decisions. Those runtime contracts require separate native tests. + +`tests/engine/unified_content.lua` covers resolution, detachment, refusal, +rollback, dangling references, deletion and native formats. The unchanged +`examples/portable_ai_class_content` consumer is exercised through both real +SDKs by [the shared checker](../tools/ai-class-content-contract-check/README.md). + +For actual imported data, install the example in an isolated test identity: + +```sh +POKEPORT_VERSION=gold POKEPORT_DRIVER=tests/drivers/ai_class_content.lua \ + bash scripts/run-portability-test.sh +``` + +The driver checks mod load, common/native item resolution, detached records +and frozen writes at the title and after an in-memory New Game. It supports +Red, Gold, FireRed and Emerald. It does not execute a normal SAVE action, +battle decision, complete playthrough or fresh full-ROM import. diff --git a/examples/portable_ai_class_content/main.lua b/examples/portable_ai_class_content/main.lua new file mode 100644 index 000000000..e7384b055 --- /dev/null +++ b/examples/portable_ai_class_content/main.lua @@ -0,0 +1,66 @@ +local mod = ... +mod.exports.api = mod +local h = mod.content.ai_classes +local mode = mod:read("case.txt") or "register" +local out = { operations = {} } +mod.exports.state = out +local function record(value) + if value == nil then + return false + end + local result = {} + for _, key in ipairs({ + "kind", + "uses", + "chance", + "item", + "switch", + "switchChance", + "switchBelow", + "hpBelow", + "onStatus", + }) do + result[key] = value[key] + end + for _, key in ipairs({ "brain", "score", "choose" }) do + result[key] = type(value[key]) == "function" + end + return result +end +local id = "PORTABLE_METADATA_CLASS" +local registered = h:register(id, { kind = "class", uses = 2, chance = 256, item = "POTION" }) +out.operations[#out.operations + 1] = record(registered) +registered.uses, registered.item = 99, "FULL_RESTORE" +out.writeDetached = h:get(id).uses == 2 and h:get(id).item == "POTION" +if mode == "patch" then + out.operations[#out.operations + 1] = record(h:patch(id, { item = "FULL_RESTORE", hpBelow = 2 })) +elseif mode == "override" then + out.operations[#out.operations + 1] = + record(h:override(id, { kind = "class", uses = 1, item = "FULL_RESTORE", onStatus = true })) +elseif mode == "delete-item" then + out.operations[#out.operations + 1] = record(h:patch(id, { item = mod.DELETE })) +elseif mode == "brain" then + out.operations[#out.operations + 1] = record(h:override(id, { + kind = "brain", + brain = function() + return 1 + end, + })) +elseif mode == "remove" then + h:remove(id) +elseif mode == "rollback" then + error("intentional AI class metadata rollback") +end +local value = h:get(id) +out.current = record(value) +if value then + value.uses, value.item = 55, "UNKNOWN_ITEM" + out.readDetached = h:get(id).uses ~= 55 and h:get(id).item ~= "UNKNOWN_ITEM" +end +for key, entry in h:each() do + if key == id then + out.iterated = record(entry) + entry.uses = 77 + end +end +out.iterationDetached = h:get(id) == nil or h:get(id).uses ~= 77 diff --git a/examples/portable_ai_class_content/manifest.json b/examples/portable_ai_class_content/manifest.json new file mode 100644 index 000000000..2edc1cf6e --- /dev/null +++ b/examples/portable_ai_class_content/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_ai_class_content", + "name": "Portable AI Class Records", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} diff --git a/tests/ai_class_content/cases.lua b/tests/ai_class_content/cases.lua new file mode 100644 index 000000000..69e7f53a4 --- /dev/null +++ b/tests/ai_class_content/cases.lua @@ -0,0 +1,40 @@ +return function(root, backend, version, api) + local rows = {} + for _, mode in ipairs({ "register", "patch", "override", "delete-item", "brain", "remove", "rollback" }) do + local f = dofile(root .. "/tests/ai_class_content/fixture.lua")(root, backend, version, api, mode) + local T, h = f.T, f.e and f.e.api.content.ai_classes + local raw = f.result.loader.content.ai_classes:get("PORTABLE_METADATA_CLASS") + T.eq(#f.result.errors > 0, mode == "rollback", "AI class entry outcome " .. mode) + if mode == "rollback" then + T.eq(raw, nil, "failed entry rolls AI class operation back") + rows[#rows + 1] = { mode = mode, failed = true, absent = raw == nil } + else + local state = f.e.state + T.eq(state.writeDetached, true, "AI class write result is detached") + T.eq(state.iterationDetached, true, "AI class iteration is detached") + if mode ~= "remove" then + T.eq(state.readDetached, true, "AI class get is detached") + if mode == "brain" then + T.eq(raw.uses, nil, "class override replaces omitted native fields") + else + T.eq(raw.uses, mode == "override" and 1 or 2, "native class uses survive detached edits") + end + if mode ~= "delete-item" and mode ~= "brain" then + local canonical = (mode == "patch" or mode == "override") and "FULL_RESTORE" or "POTION" + T.eq(state.current.item, canonical, "public class item is canonical") + if backend == "gen2" and f.gen >= 2 then + local index = canonical == "POTION" and (f.gen == 2 and 18 or 13) or (f.gen == 2 and 14 or 19) + T.eq(raw.item, ("ITEM_%03d"):format(index), "native class receives actual fixture item key") + end + end + else + T.eq(raw, nil, "removed class has no native record") + end + rows[#rows + 1] = { mode = mode, failed = false, state = state } + -- Opaque API handle is an export, not part of the metadata observations. + T.check(h ~= nil, "class accessor survives entry") + end + f.close() + end + return rows +end diff --git a/tests/ai_class_content/fixture.lua b/tests/ai_class_content/fixture.lua new file mode 100644 index 000000000..d3df57981 --- /dev/null +++ b/tests/ai_class_content/fixture.lua @@ -0,0 +1,35 @@ +return function(root, backend, version, api, mode) + return dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + "unified", + false, + mode == "rollback", + "portable_ai_class_content", + function(data, gen) + if backend == "gen1" and gen == 3 then + data.gen3Items._byId[13] = { name = "POTION", price = 300, pocket = "ITEMS" } + data.gen3Items._byId[19] = { name = "FULL RESTORE", price = 3000, pocket = "ITEMS" } + else + data.items = data.items or {} + for _, row in ipairs({ + { "POTION", gen == 1 and 20 or gen == 2 and 18 or 13 }, + { "FULL_RESTORE", gen == 1 and 16 or gen == 2 and 14 or 19 }, + }) do + local id = backend == "gen2" and gen >= 2 and ("ITEM_%03d"):format(row[2]) or row[1] + data.items[id] = { id = id, index = row[2], name = row[1]:gsub("_", " "), price = 300 } + end + end + end, + function(files) + local Json = require("src.link.Json") + local path = "mods/portable_ai_class_content/" + local manifest = Json.decode(files[path .. "manifest.json"]) + manifest.content_format = "unified" + files[path .. "manifest.json"] = Json.encode(manifest) + files[path .. "case.txt"] = mode + end + ) +end diff --git a/tests/ai_class_content/worker.lua b/tests/ai_class_content/worker.lua new file mode 100644 index 000000000..fb0e0cc45 --- /dev/null +++ b/tests/ai_class_content/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api = unpack(arg) +local rows = dofile(root .. "/tests/ai_class_content/cases.lua")(root, backend, version, tonumber(api)) +print("AI_CLASS_CONTENT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("portable AI class content") diff --git a/tests/drivers/ai_class_content.lua b/tests/drivers/ai_class_content.lua new file mode 100644 index 000000000..8fe31d189 --- /dev/null +++ b/tests/drivers/ai_class_content.lua @@ -0,0 +1,60 @@ +-- Actual imported-data loader/read/freeze smoke; isolated identity only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported game") + local function check(stage) + local e = assert(game.mods.exports.portable_ai_class_content, "install portable class example") + local h, id = e.api.content.ai_classes, "PORTABLE_METADATA_CLASS" + assert( + e.state.writeDetached and e.state.readDetached and e.state.iterationDetached, + "entry metadata snapshots" + ) + local current = assert(h:get(id), "missing portable class") + assert(current.item == "POTION" and current.uses == 2, "portable imported item reference") + local raw = assert(game.data.ai_classes[id], "missing native class fold") + local item = assert(game.data.items[raw.item], "native item target missing") + assert(item.name == "POTION", "native class references actual imported Potion") + local physical = raw.item + current.item, current.uses = "UNKNOWN_ITEM", 99 + assert( + raw.item == physical and raw.uses == 2 and h:get(id).item == "POTION", + "read cannot alter runtime class" + ) + local found + for key, value in h:each() do + if key == id then + assert(value.item == "POTION", "portable class iteration") + value.item = "UNKNOWN_ITEM" + found = true + end + end + assert(found and raw.item == physical, "iteration cannot alter runtime class") + local ok, reason = pcall(function() + h:patch(id, { item = "POTION" }) + end) + assert(not ok and tostring(reason):find("frozen", 1, true), "runtime metadata writes stay frozen") + print("PORTABILITY_AI_CLASS_CONTENT_CASE " .. V.get() .. " " .. stage) + end + check("title") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + check("new-game") + print("PORTABILITY_AI_CLASS_CONTENT_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/ai_class_content.lua b/tests/engine/ai_class_content.lua new file mode 100644 index 000000000..05d604fea --- /dev/null +++ b/tests/engine/ai_class_content.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, api in ipairs({ 2, 3 }) do + dofile("tests/ai_class_content/cases.lua")(".", "gen2", version, api) + end +end +require("tests.modkit").finish("portable AI class content") diff --git a/tools/ai-class-content-contract-check/README.md b/tools/ai-class-content-contract-check/README.md new file mode 100644 index 000000000..c74b2fb8d --- /dev/null +++ b/tools/ai-class-content-contract-check/README.md @@ -0,0 +1,33 @@ +# Shared AI-class content contracts + +Run the same public metadata consumer through both real SDK loaders. The +Gen1Recomp reference must be clean at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +```sh +python3 tools/ai-class-content-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 3 --report /tmp/ai-class-api3.json +python3 tools/ai-class-content-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 2 --report /tmp/ai-class-api2.json +``` + +Use `--lua /path/to/luajit` when necessary. Each invocation runs 16 isolated +workers across Red/Blue/Yellow, Gold/Silver/Crystal and FireRed/Emerald, +comparing 56 pairs of observations. Both workers load the same consumer file; +only the version/data fixtures and host SDK mode differ. File hashes and +engine refs are recorded, and the checker refuses a changed reference. + +The source always uses API 3; the host uses API 3 or API 2 with explicit unified +content. Gen1Recomp API-2 AI-class reads expose live passthrough records, so +this is not a claim of shared API-2 read-aliasing behavior. API 3 is the common +detached-record contract. Native API-1/2 formats are tested separately by the +host unit suite. + +Seven cases cover register, patch, replacement override, optional-item deletion, +brain-record metadata, removal and failed-entry rollback. Observations include +public canonical item IDs and detached write/read/iteration results. Host +assertions also verify that physical imported-style item keys reach the native +registry. Brain function presence is recorded; no brain is executed here. + +These are ROM-free synthetic metadata fixtures through real loaders, not native +battle-policy tests. Run `tests/drivers/ai_class_content.lua` separately against +an isolated imported-data installation for title/New Game metadata validation. +See [the API contract](../../docs/portable-ai-class-content.md). diff --git a/tools/ai-class-content-contract-check/check.py b/tools/ai-class-content-contract-check/check.py new file mode 100644 index 000000000..e9d3eaab6 --- /dev/null +++ b/tools/ai-class-content-contract-check/check.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Compare the unchanged public AI-class metadata consumer through both SDKs.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" +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", type=Path, required=True) + 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("--host-api", type=int, choices=(2, 3), default=3) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + parser.error("reference must be clean at " + REFERENCE) + paths = ["examples/portable_ai_class_content/manifest.json", "examples/portable_ai_class_content/main.lua"] + paths += ["tests/ai_class_content/" + name for name in ("fixture.lua", "cases.lua", "worker.lua")] + hashes = {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths} + runs, differences = [], [] + for version in VERSIONS: + pair = [] + for backend, engine in (("gen1", source), ("gen2", host)): + result = subprocess.run( + [args.lua, str(host / "tests/ai_class_content/worker.lua"), str(host), backend, version, str(3 if backend == "gen1" else args.host_api)], + cwd=engine, capture_output=True, text=True, timeout=45, + ) + lines = [line.removeprefix("AI_CLASS_CONTENT ") for line in result.stdout.splitlines() + if line.startswith("AI_CLASS_CONTENT ")] + if result.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {result.stdout}{result.stderr}") + rows = json.loads(lines[0]) + pair.append(rows) + runs.append({"backend": backend, "version": version, "observations": rows}) + if pair[0] != pair[1]: + differences.append({"version": version, "reference": pair[0], "host": pair[1]}) + print("FAIL" if pair[0] != pair[1] else "PASS", version) + assert not git(source, "status", "--porcelain"), "reference changed" + assert hashes == {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths}, "fixtures changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "reference_api": 3, "host_api": args.host_api, + "format": "unified", "files_sha256": hashes, "runs": runs, "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + for difference in differences: + print(json.dumps(difference, sort_keys=True)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() From 6b16be59a7bb5f6b966cbd21454579fa20bac024 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:37:36 +0200 Subject: [PATCH 82/86] Document AI-class metadata validation and legacy boundary --- docs/portable-ai-class-content.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/portable-ai-class-content.md b/docs/portable-ai-class-content.md index 6e9810c9f..597d89a01 100644 --- a/docs/portable-ai-class-content.md +++ b/docs/portable-ai-class-content.md @@ -55,3 +55,18 @@ The driver checks mod load, common/native item resolution, detached records and frozen writes at the title and after an in-memory New Game. It supports Red, Gold, FireRed and Emerald. It does not execute a normal SAVE action, battle decision, complete playthrough or fresh full-ROM import. + +## Validation + +The isolated feature passes **442/442** unified-content checks and **670/670** +loader checks. Each SDK comparison mode passes **16 workers / 56 paired +observations** against the clean reference above. The unchanged #173 parent +fails the same public consumer in Gold and FireRed with an unresolved item +reference, providing a negative control. + +The complete feature run passes **139/149** engine suites, **3/3** SDK suites +and B30. Its 26 FAIL lines exactly match the predecessor's ten baseline +failure suites; the separate repair PRs #128–134 are not part of this feature +branch. Native LÖVE 11.5 checks pass for imported Red, Gold, FireRed and +Emerald at title and in-memory New Game, within the metadata scope above. +The installed temporary example was removed after verifying its file hashes. From 19997e91ef462833c570d2b61484af811fa676a4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:14:36 +0200 Subject: [PATCH 83/86] Apply native Gen-3 critical multiplier before type rounding --- .github/workflows/critical-order-contract.yml | 37 +++++++ docs/api3-portability.md | 3 + docs/gen3-critical-order.md | 57 ++++++++++ examples/portable_critical_order/main.lua | 10 ++ .../portable_critical_order/manifest.json | 8 ++ src/battle/Damage.lua | 12 ++- src/battle/rulesets/gen3_emerald.lua | 3 + tests/critical_order/cases.lua | 100 ++++++++++++++++++ tests/critical_order/worker.lua | 5 + tests/drivers/critical_order.lua | 99 +++++++++++++++++ tests/engine/critical_order_contract.lua | 7 ++ tests/engine/gen3_critical_order.lua | 68 ++++++++++++ tools/critical-order-contract-check/README.md | 22 ++++ tools/critical-order-contract-check/check.py | 64 +++++++++++ 14 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/critical-order-contract.yml create mode 100644 docs/gen3-critical-order.md create mode 100644 examples/portable_critical_order/main.lua create mode 100644 examples/portable_critical_order/manifest.json create mode 100644 tests/critical_order/cases.lua create mode 100644 tests/critical_order/worker.lua create mode 100644 tests/drivers/critical_order.lua create mode 100644 tests/engine/critical_order_contract.lua create mode 100644 tests/engine/gen3_critical_order.lua create mode 100644 tools/critical-order-contract-check/README.md create mode 100644 tools/critical-order-contract-check/check.py diff --git a/.github/workflows/critical-order-contract.yml b/.github/workflows/critical-order-contract.yml new file mode 100644 index 000000000..d4c927fe7 --- /dev/null +++ b/.github/workflows/critical-order-contract.yml @@ -0,0 +1,37 @@ +name: native critical/type ordering +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: critical-order-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check critical/type boundaries + run: | + luajit tests/engine/gen3_critical_order.lua + luajit tests/engine/critical_order_contract.lua + - name: Compare unchanged public consumer + run: | + mkdir -p /tmp/critical-order + python3 tools/critical-order-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 3 --report /tmp/critical-order/api3.json + python3 tools/critical-order-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/critical-order/api2.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: critical-order + path: /tmp/critical-order + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 0c36628f6..9aecb4c52 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -193,3 +193,6 @@ FireRed Item-PC withdrawal labels, see [GBA menu prose](portable-gba-menu-prose. For common AI-class item IDs, detached definition records and the API-2/unified boundary, see [portable AI-class content](portable-ai-class-content.md). + +For the native damage-order correction used by portable `battle.crit` mods, +see [Gen-3 critical/type rounding](gen3-critical-order.md). diff --git a/docs/gen3-critical-order.md b/docs/gen3-critical-order.md new file mode 100644 index 000000000..d809f2320 --- /dev/null +++ b/docs/gen3-critical-order.md @@ -0,0 +1,57 @@ +# Gen-3 critical/type rounding + +A critical hit against a resistant target could deal one HP less than the +same move in Gen1Recomp's native GBA engine. With base damage 11, critical x2, +Steel resistance and random roll 85, the result must be **9 HP**, not 8: +`floor(floor(11 * 2 / 2) * 85 / 100)`. + +The Gen-3 ruleset now opts into `critBeforeType`, applying its damage multiplier +after weather and before STAB and per-type rounding. Profiles without that +flag retain their existing order. This is an engine ruleset correction, with +no new public mod API or change to critical-hit probability. + +It matters to portable mods that override `battle.crit`: the same public +critical decision should reach the same tested native damage result on both +engines. The unchanged `examples/portable_critical_order` consumer forces or +suppresses critical hits through that hook, using no engine imports. + +The shared checker runs actual move-resolution pipelines on FireRed and +Emerald. Ninety-six cases per profile cover player/enemy attacks, critical and +ordinary hits, STAB, neutral/resisted/doubly resisted/immune targets and random +rolls 85/92/100. HP, PP and public hook-call counts are compared. Fixture stats +and type rows are explicitly equivalent; neither engine's damage calculation +is replaced. Both API modes compare with the clean Gen1 API-3 reference. +See [the checker](../tools/critical-order-contract-check/README.md). + +The focused arithmetic suite also checks authored super-effectiveness rows, +immunity and profiles without the new flag. These tests establish critical/type +ordering for the covered conditions, not universal damage-formula or native +battle-rule parity. Other modifiers and battle-policy gaps remain separate. + +Install the example in an isolated imported-data test identity to run the +native LÖVE driver: + +```sh +POKEPORT_VERSION=firered POKEPORT_DRIVER=tests/drivers/critical_order.lua \ + bash scripts/run-portability-test.sh +``` + +FireRed and Emerald each run six constructed battles with actual FIGHT/move +input and full native turn queues. The same public mod controls both critical +decisions; HP and PP are checked. The driver starts New Game in memory and +stops before battle finish, afterBattle or normal SAVE. It does not perform a +complete playthrough or fresh full-ROM import. + +## Validation + +The feature passes **15/15** arithmetic regressions and **780/780** real-loader +checks. Each host API mode passes **4 workers / 192 paired observations**. +Unchanged #174 differs in **18 cases** and fails **2 Steel regression assertions +per GBA profile** with the same current fixtures. Both native imported-data +drivers pass all six battles; the temporary example was removed with hash +guards after both processes exited. + +The complete feature passes **141/151** engine suites, **3/3** SDK suites and +B30. All **26 FAIL lines** exactly match #174's ten baseline failure suites; +their separate repairs in #128–134 are outside this feature branch. Hosted CI +approval/execution is separate from these local results. diff --git a/examples/portable_critical_order/main.lua b/examples/portable_critical_order/main.lua new file mode 100644 index 000000000..3e285e680 --- /dev/null +++ b/examples/portable_critical_order/main.lua @@ -0,0 +1,10 @@ +local mod = ... +local state = { critical = true, calls = 0 } +mod.exports.state = state +mod.exports.available = mod:supports("hooks", "battle.crit") +if mod.exports.available then + mod.hooks:wrap("battle.crit", function() + state.calls = state.calls + 1 + return state.critical + end) +end diff --git a/examples/portable_critical_order/manifest.json b/examples/portable_critical_order/manifest.json new file mode 100644 index 000000000..c9c880f34 --- /dev/null +++ b/examples/portable_critical_order/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_critical_order", + "name": "Portable critical-hit probe", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["gen3"] +} diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 37d8b6390..9dca55bd4 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -451,6 +451,10 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = Weather.applyModifier(d, opts.weather, move.type, move.effect) end + if crit and ruleset.critMultiplier and ruleset.critBeforeType then + d = math.floor(d * ruleset.critMultiplier) + end + local mult = 10 if not opts.typeless then -- STAB @@ -545,9 +549,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- random factor; the typeless confusion self-hit skips RandomizeDamage -- along with AdjustDamageForMoveType (HandleSelfConfusionDamage calls -- CalculateDamage directly), so it is fully deterministic - -- Gen 3's critical multiplier lands here, after STAB and the type chart - -- and before the random factor, which is where pokeemerald applies it. - if crit and ruleset.critMultiplier then + -- Profiles without the Gen-3 ordering flag retain their existing final + -- multiplier. Gen 3 has already applied it before STAB/type rounding. + if crit and ruleset.critMultiplier and not ruleset.critBeforeType then d = math.floor(d * ruleset.critMultiplier) end if d > 1 and not opts.typeless then @@ -808,4 +812,4 @@ function Damage.rollHit(accuracy, accuracyStage, evasionStage, random) return roll < value end -return Damage \ No newline at end of file +return Damage diff --git a/src/battle/rulesets/gen3_emerald.lua b/src/battle/rulesets/gen3_emerald.lua index 3a7fc6011..69fdb6458 100644 --- a/src/battle/rulesets/gen3_emerald.lua +++ b/src/battle/rulesets/gen3_emerald.lua @@ -32,6 +32,9 @@ return { -- re-floors, so the two produce different numbers; this is not a -- reformulation of the same rule. critMultiplier = 2, + -- Cmd_critcalc's multiplier is applied before Cmd_typecalc's STAB and + -- per-type floors. Applying it after resistance loses odd damage points. + critBeforeType = true, -- TWO ON THE FIELD CHANGES TWO NUMBERS, and both of them live in -- CalculateBaseDamage (0806957C) rather than anywhere the port would diff --git a/tests/critical_order/cases.lua b/tests/critical_order/cases.lua new file mode 100644 index 000000000..4c67faaae --- /dev/null +++ b/tests/critical_order/cases.lua @@ -0,0 +1,100 @@ +return function(root, backend, version, api) + local native = backend == "gen1" + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + "unified", + false, + false, + "portable_critical_order", + function(data) + if not native then + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + end + end + ) + local T, rows = f.T, {} + f.game.input.wasPressed = function(_, key) + return key == "a" + end + f.game.input.isDown = function(_, key) + return key == "a" + end + T.eq(f.e.available, true, "real public critical producer is available") + for _, enemy in ipairs({ false, true }) do + for _, critical in ipairs({ false, true }) do + for _, stab in ipairs({ false, true }) do + for _, types in ipairs({ { "NORMAL" }, { "STEEL" }, { "ROCK", "STEEL" }, { "GHOST" } }) do + for _, roll in ipairs({ 85, 92, 100 }) do + local s = f.fresh("TACKLE", { enemy = enemy }) + local ids = { NORMAL = 0, ROCK = 5, GHOST = 7, STEEL = 8, FIRE = 10 } + for _, who in ipairs({ s.user, s.target }) do + if native then + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + who.mon[key] = 50 + end + else + who.mon.stats = { + hp = 400, + attack = 50, + defense = 50, + speed = 50, + special = 50, + spatk = 50, + spdef = 50, + } + who.curStats = who.mon.stats + end + end + if native then + s.user.type1, s.user.type2 = stab and 0 or 10, stab and 0 or 10 + s.target.type1, s.target.type2 = ids[types[1]], ids[types[2] or types[1]] + else + s.user.curTypes, s.target.curTypes = { stab and "NORMAL" or "FIRE" }, types + end + s.battle.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return roll + end + return lo or 0 + end + f.e.state.critical, f.e.state.calls = critical, 0 + local before = s.hp() + s.use() + if not native then + local count = 0 + while s.battle:updateQueue() do + count = count + 1 + assert(count < 10000, "native move queue did not settle") + end + end + local amount = before - s.hp() + T.eq(s.pp(), 9, "native move spends one PP, including immunity") + T.eq(f.e.state.calls, 1, "one real public critical decision per move") + if critical and not stab and #types == 1 and types[1] == "STEEL" and roll == 85 then + T.eq(amount, 9, "critical Steel regression through real move pipeline") + end + rows[#rows + 1] = { + enemy = enemy, + critical = critical, + stab = stab, + types = types, + roll = roll, + amount = amount, + pp = s.pp(), + calls = f.e.state.calls, + } + end + end + end + end + end + f.close() + return rows +end diff --git a/tests/critical_order/worker.lua b/tests/critical_order/worker.lua new file mode 100644 index 000000000..4a46d48ad --- /dev/null +++ b/tests/critical_order/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api = unpack(arg) +local rows = dofile(root .. "/tests/critical_order/cases.lua")(root, backend, version, tonumber(api)) +print("CRITICAL_ORDER " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native critical/type ordering") diff --git a/tests/drivers/critical_order.lua b/tests/drivers/critical_order.lua new file mode 100644 index 000000000..f6da56076 --- /dev/null +++ b/tests/drivers/critical_order.lua @@ -0,0 +1,99 @@ +-- Actual imported-data FIGHT/move/controller smoke in an isolated identity. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION") and V.isGen3(), "wrong imported game") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + local e = assert(game.mods.exports.portable_critical_order, "install critical example") + assert(e.available and game.save and game.overworld, "native mod/New Game missing") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local function named(registry, name) + for id, row in pairs(registry) do + if row.name == name then + return id + end + end + error("missing imported record " .. name) + end + local species, tackle = named(game.data.pokemon, "MEW"), named(game.data.moves, "TACKLE") + assert(game.data.moves[tackle].power == 35, "native cartridge Tackle power") + local function mon(name) + local p = P.new(game.data, species, 30) + p.nickname, p.hp = name, 400 + p.stats = { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + p.moves = { { id = tackle, pp = 10 } } + return p + end + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + for _, spec in ipairs({ + { critical = false, types = { "STEEL" }, want = 4 }, + { critical = true, types = { "STEEL" }, want = 9 }, + { critical = true, types = { "ROCK", "STEEL" }, want = 4 }, + { critical = false, stab = true, types = { "STEEL" }, want = 6 }, + { critical = true, stab = true, types = { "STEEL" }, want = 13 }, + { critical = true, stab = true, types = { "ROCK", "STEEL" }, want = 6 }, + }) do + game.save.party = { mon("PLAYER") } + local old = game.data.trainers.PORTABLE_CRITICAL + game.data.trainers.PORTABLE_CRITICAL = { + name = "PORTABLE", + items = {}, + parties = { { { prebuilt = mon("ENEMY") } } }, + } + local b = Battle.newTrainer(game, "PORTABLE_CRITICAL", 1) + game.data.trainers.PORTABLE_CRITICAL = old + b:syncSides() + b.player.curTypes, b.enemy.curTypes = { spec.stab and "NORMAL" or "FIRE" }, spec.types + b.player.curStats.speed = 80 + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { game.overworld, b } + e.state.critical, e.state.calls = spec.critical, 0 + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT opens moves") + U.tap(game, "a") + for _ = 1, 4000 do + if b.phase == "menu" then + break + end + U.tap(game, "a") + end + assert(b.phase == "menu", "native turn did not settle") + assert(b.enemy.mon.hp == 400 - spec.want, "native critical/type rounding") + assert(b.player.curMoves[1].pp == 9 and b.enemy.curMoves[1].pp == 9, "native PP accounting") + assert(e.state.calls == 2, "both native critical decisions use the public consumer") + print( + "PORTABILITY_CRITICAL_CASE " + .. V.get() + .. " " + .. tostring(spec.critical) + .. " " + .. tostring(spec.stab or false) + .. " " + .. table.concat(spec.types, "/") + .. " " + .. spec.want + ) + end + print("PORTABILITY_CRITICAL_ORDER_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/critical_order_contract.lua b/tests/engine/critical_order_contract.lua new file mode 100644 index 000000000..7b9fd6d31 --- /dev/null +++ b/tests/engine/critical_order_contract.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, api in ipairs({ 2, 3 }) do + dofile("tests/critical_order/cases.lua")(".", "gen2", version, api) + end +end +require("tests.modkit").finish("native critical/type loader contracts") diff --git a/tests/engine/gen3_critical_order.lua b/tests/engine/gen3_critical_order.lua new file mode 100644 index 000000000..daf578d98 --- /dev/null +++ b/tests/engine/gen3_critical_order.lua @@ -0,0 +1,68 @@ +-- Regression from actual FireRed/Emerald opponent-controller comparisons: +-- base damage 11, critical x2, Steel resistance and roll 85 gives 9, not 8. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Damage, TypeChart = require("src.battle.Damage"), require("src.battle.TypeChart") +local copy = require("src.mods.Merge").deepCopy +local rules = require("src.battle.rulesets.gen3_emerald") +TypeChart.load({ + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "RESIST", multiplier = 5 }, + { attacker = "NORMAL", defender = "WEAK", multiplier = 20 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + }, + }, +}) +local function battler(types) + return { + mon = { level = 30, hp = 400 }, + def = {}, + stages = {}, + curTypes = types, + curStats = { hp = 400, attack = 50, defense = 50, special = 50, spatk = 50, spdef = 50 }, + } +end +local move = { id = "TACKLE", type = "NORMAL", power = 35 } +-- Literal expectations guard resistance, authored super-effectiveness, +-- STAB, immunity and adjacent noncritical boundaries. The Steel regression +-- is also reproduced through both actual native opponent controllers. +for _, row in ipairs({ + { false, false, { "STEEL" }, 85, 4 }, + { true, false, { "STEEL" }, 85, 9 }, + { true, false, { "STEEL" }, 100, 11 }, + { true, false, { "STEEL", "RESIST" }, 85, 4 }, + { true, false, { "STEEL", "RESIST" }, 100, 5 }, + { true, false, { "NEUTRAL" }, 85, 18 }, + { true, false, { "NEUTRAL" }, 100, 22 }, + { true, false, { "WEAK" }, 85, 37 }, + { true, true, { "STEEL" }, 85, 13 }, + { true, true, { "STEEL", "RESIST" }, 85, 6 }, + { true, true, { "NEUTRAL" }, 85, 28 }, + { true, false, { "GHOST" }, 85, 0 }, +}) do + local value = Damage.compute(rules, battler({ row[2] and "NORMAL" or "OTHER" }), battler(row[3]), move, { + forceCrit = row[1], + rng = function() + return row[4] + end, + }) + T.eq(value, row[5], "native critical/type rounding " .. table.concat(row[3], "/") .. " roll " .. row[4]) +end +local legacy = copy(rules) +legacy.critBeforeType = nil +T.eq( + Damage.compute(legacy, battler({ "OTHER" }), battler({ "STEEL" }), move, { + forceCrit = true, + rng = function() + return 85 + end, + }), + 8, + "profiles without the flag retain their ordering" +) +T.eq(require("src.battle.rulesets.gen4_platinum").critBeforeType, nil, "Gen-4 profile is not changed") +T.eq(require("src.battle.rulesets.gen1_faithful").critBeforeType, nil, "Gen-1 profile is not changed") +T.finish("Gen-3 critical/type ordering") diff --git a/tools/critical-order-contract-check/README.md b/tools/critical-order-contract-check/README.md new file mode 100644 index 000000000..db19847d0 --- /dev/null +++ b/tools/critical-order-contract-check/README.md @@ -0,0 +1,22 @@ +# Native critical/type contracts + +Use clean Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 3 --report /tmp/critical-api3.json +python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 2 --report /tmp/critical-api2.json +``` + +Set `--lua /path/to/luajit` if needed. Each invocation runs four isolated +workers and compares **192 pairs** of observations on FireRed/Emerald. The +source always uses API 3; the host uses API 3 or API 2/unified. Both load the +same public `battle.crit` consumer through their real SDKs, then run native +move pipelines with equivalent stats, live types and random damage rolls. + +Observations include damage, PP and public critical-hook counts for both +attackers, critical/noncritical, STAB, neutral/half/quarter/immune type effects +and rolls 85/92/100. Reports preserve the engine refs and consumer/fixture +hashes; the checker refuses a different or modified reference. These are +synthetic ROM-free fixtures, not native GUI or complete playthrough tests. +The isolated imported-data driver is documented in +[the correction's scope](../../docs/gen3-critical-order.md). diff --git a/tools/critical-order-contract-check/check.py b/tools/critical-order-contract-check/check.py new file mode 100644 index 000000000..691f07c36 --- /dev/null +++ b/tools/critical-order-contract-check/check.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Compare the unchanged public critical-hit consumer through both SDKs.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" +VERSIONS = ("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", type=Path, required=True) + 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("--host-api", type=int, choices=(2, 3), default=3) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + parser.error("reference must be clean at " + REFERENCE) + paths = ["examples/portable_critical_order/manifest.json", "examples/portable_critical_order/main.lua"] + paths += ["tests/critical_order/" + name for name in ("cases.lua", "worker.lua")] + hashes = {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths} + runs, differences = [], [] + for version in VERSIONS: + pair = [] + for backend, engine in (("gen1", source), ("gen2", host)): + result = subprocess.run( + [args.lua, str(host / "tests/critical_order/worker.lua"), str(host), backend, version, str(3 if backend == "gen1" else args.host_api)], + cwd=engine, capture_output=True, text=True, timeout=45, + ) + lines = [line.removeprefix("CRITICAL_ORDER ") for line in result.stdout.splitlines() + if line.startswith("CRITICAL_ORDER ")] + if result.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {result.stdout}{result.stderr}") + rows = json.loads(lines[0]) + pair.append(rows) + runs.append({"backend": backend, "version": version, "observations": rows}) + if pair[0] != pair[1]: + differences.append({"version": version, "reference": pair[0], "host": pair[1]}) + print("FAIL" if pair[0] != pair[1] else "PASS", version) + assert not git(source, "status", "--porcelain"), "reference changed" + assert hashes == {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths}, "fixtures changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "reference_api": 3, "host_api": args.host_api, + "format": "unified", "files_sha256": hashes, "runs": runs, "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + for difference in differences: + print(json.dumps(difference, sort_keys=True)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() From 8d2342fba492307c8c59e6c80c620c4de7b0332e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:53:02 +0200 Subject: [PATCH 84/86] Use native Gen-2 critical base before final minimum damage --- src/battle/Damage.lua | 7 ++++- tests/engine/gen2_critical_base.lua | 45 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/engine/gen2_critical_base.lua diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 9dca55bd4..7c368f1bb 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -404,7 +404,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- formula; Gen 3 leaves the level alone and doubles the finished damage -- instead. Those are not the same number -- doubling the level also -- doubles the +2 constant and re-floors -- so the two cannot share a path. - if crit and not ruleset.critMultiplier then level = level * 2 end + local gen2Crit = crit and not ruleset.critMultiplier + and opts.data and opts.data.constants and opts.data.constants.generation == 2 + if crit and not ruleset.critMultiplier and not gen2Crit then level = level * 2 end local d = math.floor(math.floor(2 * level / 5) + 2) -- THE MOVE'S POWER, AS THIS TURN FINDS IT. Most moves use the byte off @@ -432,6 +434,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d * ruleset.spreadNum / ruleset.spreadDen) end + -- Gen 2 doubles DamageCalc's truncated base, before its cap and +2. + -- Doubling the level uses a different floor at levels such as 29. + if gen2Crit then d = d * 2 end d = math.min(d, 997) + 2 -- Held type boosters live inside BattleCommand_DamageCalc, before weather, diff --git a/tests/engine/gen2_critical_base.lua b/tests/engine/gen2_critical_base.lua new file mode 100644 index 000000000..ec98e8bef --- /dev/null +++ b/tests/engine/gen2_critical_base.lua @@ -0,0 +1,45 @@ +-- Found by ordinary Gen-2 turns in the cross-engine decision contracts. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Damage = require("src.battle.Damage") +require("src.battle.TypeChart").load({ type_chart = { types = {}, matchups = {} } }) +local rules = require("src.battle.rulesets.gen1_faithful") +local function mon(level, types) + return { + mon = { level = level, hp = 400 }, + def = {}, + stages = {}, + curTypes = types, + curStats = { hp = 400, attack = 50, defense = 50, special = 50 }, + } +end +local move = { id = "TACKLE", type = "NORMAL", power = 35 } +for _, row in ipairs({ + { 29, true, false, 17 }, + { 29, true, true, 25 }, + { 29, false, false, 9 }, + { 1, true, false, 3 }, + { 3, true, false, 5 }, + { 30, true, false, 17 }, +}) do + local got = + Damage.compute(rules, mon(row[1], { row[3] and "NORMAL" or "OTHER" }), mon(30, { "OTHER" }), move, { + forceCrit = row[2], + data = { constants = { generation = 2 } }, + rng = function() + return 217 + end, + }) + T.eq(got, row[4], "Gen-2 native critical base level " .. row[1]) +end +T.eq( + Damage.compute(rules, mon(29, { "OTHER" }), mon(30, { "OTHER" }), move, { + forceCrit = true, + rng = function() + return 217 + end, + }), + 16, + "Gen-1 still doubles the level" +) +T.finish("Gen-2 critical base") From 397d7d6fb48bcf8f539c152397bb3e5ad220d23d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:07:45 +0200 Subject: [PATCH 85/86] Compare Gen-2 critical base through both public SDKs --- .github/workflows/critical-order-contract.yml | 1 + docs/api3-portability.md | 3 + docs/gen2-critical-base.md | 43 ++++++++ .../portable_critical_order/manifest.json | 2 +- tests/critical_order/cases.lua | 3 + tests/critical_order/gen2_cases.lua | 97 +++++++++++++++++ tests/drivers/gen2_critical_base.lua | 101 ++++++++++++++++++ tests/engine/critical_order_contract.lua | 2 +- tools/critical-order-contract-check/README.md | 17 +-- tools/critical-order-contract-check/check.py | 4 +- 10 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 docs/gen2-critical-base.md create mode 100644 tests/critical_order/gen2_cases.lua create mode 100644 tests/drivers/gen2_critical_base.lua diff --git a/.github/workflows/critical-order-contract.yml b/.github/workflows/critical-order-contract.yml index d4c927fe7..192c4051e 100644 --- a/.github/workflows/critical-order-contract.yml +++ b/.github/workflows/critical-order-contract.yml @@ -23,6 +23,7 @@ jobs: - name: Check critical/type boundaries run: | luajit tests/engine/gen3_critical_order.lua + luajit tests/engine/gen2_critical_base.lua luajit tests/engine/critical_order_contract.lua - name: Compare unchanged public consumer run: | diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 9aecb4c52..1a6184093 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -196,3 +196,6 @@ boundary, see [portable AI-class content](portable-ai-class-content.md). For the native damage-order correction used by portable `battle.crit` mods, see [Gen-3 critical/type rounding](gen3-critical-order.md). + +For the distinct Gold/Silver/Crystal critical-base calculation used by the +same public consumer, see [native Gen-2 critical base](gen2-critical-base.md). diff --git a/docs/gen2-critical-base.md b/docs/gen2-critical-base.md new file mode 100644 index 000000000..e19b4ac87 --- /dev/null +++ b/docs/gen2-critical-base.md @@ -0,0 +1,43 @@ +# Native Gen-2 critical base + +Gen-2 critical hits double the truncated base damage before the damage cap +and final +2. Using Gen-1's doubled-level calculation instead changes the +rounding at levels such as 1, 3 and 29. At level 29, power 35 and equal +50-point Attack/Defense, a non-STAB critical at random 217 deals **17 HP**; +the doubled-level path dealt 16. With STAB the result is **25 HP**, not 23. + +Imported `constants.generation = 2` now selects that native critical-base +step when the ruleset has no explicit critical multiplier. Gen-1's existing +level calculation and explicit-multiplier profiles retain their own paths. +This does not change critical probability or add another public mod API. + +The same `examples/portable_critical_order` consumer now supports Gen 2 as +well as Gen 3. It forces or suppresses `battle.crit` using only the public hook. +The Gen-2 contract cases run actual native move pipelines through both real +SDKs on Gold/Silver/Crystal: both attackers, levels 1/3/29/30/99/100, +critical/noncritical, STAB/non-STAB, and random rolls 217/255. Damage, PP and +public callback counts are compared. Stats, active types and generation +metadata are explicitly equivalent; neither native damage function is mocked. + +The existing FireRed/Emerald matrix also remains in the shared checker. +See [the combined critical checker](../tools/critical-order-contract-check/README.md) +and [the earlier GBA correction](gen3-critical-order.md). + +These tests establish the covered critical-base rounding. Further Gen-2 +modifiers, stage/badge interactions and full battle-rule parity require their +own validation. Callback context equivalence beyond what the shared public +consumer reads is not asserted by this damage matrix. + +For native LÖVE, install the example in an isolated imported Gold identity: + +```sh +POKEPORT_VERSION=gold POKEPORT_DRIVER=tests/drivers/gen2_critical_base.lua \ + bash scripts/run-portability-test.sh +``` + +The driver starts New Game in memory and constructs six battles with controlled +session stats. Actual FIGHT/move input and complete turn queues check ordinary +hits, critical level boundaries and STAB through the public consumer. It stops +before battle finish, afterBattle or normal SAVE. This is not a complete +playthrough or fresh full-ROM import; native Silver/Crystal GUI runs are not +implied by the headless matrix. diff --git a/examples/portable_critical_order/manifest.json b/examples/portable_critical_order/manifest.json index c9c880f34..3363e096f 100644 --- a/examples/portable_critical_order/manifest.json +++ b/examples/portable_critical_order/manifest.json @@ -4,5 +4,5 @@ "version": "1.0.0", "api": 3, "entry": "main.lua", - "games": ["gen3"] + "games": ["gen2", "gen3"] } diff --git a/tests/critical_order/cases.lua b/tests/critical_order/cases.lua index 4c67faaae..4c1c1cac0 100644 --- a/tests/critical_order/cases.lua +++ b/tests/critical_order/cases.lua @@ -1,4 +1,7 @@ return function(root, backend, version, api) + if version == "gold" or version == "silver" or version == "crystal" then + return dofile(root .. "/tests/critical_order/gen2_cases.lua")(root, backend, version, api) + end local native = backend == "gen1" local f = dofile(root .. "/tests/charge_required/fixture.lua").new( root, diff --git a/tests/critical_order/gen2_cases.lua b/tests/critical_order/gen2_cases.lua new file mode 100644 index 000000000..7503ef953 --- /dev/null +++ b/tests/critical_order/gen2_cases.lua @@ -0,0 +1,97 @@ +-- Same public critical consumer through both native Gen-2 move pipelines. +return function(root, backend, version, api) + local native = backend == "gen1" + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + "unified", + false, + false, + "portable_critical_order", + function(data) + data.constants = data.constants or {} + data.constants.generation = 2 + end + ) + local T, rows = f.T, {} + T.eq(f.e.available, true, "real Gen-2 critical producer is available") + f.game.input.wasPressed = function(_, key) + return key == "a" + end + f.game.input.isDown = function(_, key) + return key == "a" + end + for _, enemy in ipairs({ false, true }) do + for _, level in ipairs({ 1, 3, 29, 30, 99, 100 }) do + for _, critical in ipairs({ false, true }) do + for _, stab in ipairs({ false, true }) do + for _, roll in ipairs({ 217, 255 }) do + local s = f.fresh("TACKLE", { enemy = enemy }) + for _, who in ipairs({ s.user, s.target }) do + local mon = native and who or who.mon + mon.level, mon.hp, mon.maxHp = 30, 400, 400 + mon.stats = { + hp = 400, + attack = 50, + defense = 50, + speed = 50, + special = 50, + spatk = 50, + spdef = 50, + specialAttack = 50, + specialDefense = 50, + } + if not native then + who.curStats = mon.stats + end + end + local user = native and s.user or s.user.mon + user.level = level + if native then + s.battle:volatile(s.user).types = { stab and "NORMAL" or "GRASS" } + s.battle:volatile(s.target).types = { "NORMAL" } + s.battle.random = function(n) + return n == 39 and roll - 217 or 0 + end + else + s.user.curTypes, s.target.curTypes = { stab and "NORMAL" or "GRASS" }, { "NORMAL" } + s.battle.rng = function(lo, hi) + return lo == 217 and hi == 255 and roll or lo or 0 + end + end + f.e.state.critical, f.e.state.calls = critical, 0 + local before = s.hp() + s.use() + if not native then + local count = 0 + while s.battle:updateQueue() do + count = count + 1 + assert(count < 10000, "Gen-2 move queue did not settle") + end + end + local amount = before - s.hp() + T.eq(s.pp(), 9, "Gen-2 native move spends one PP on either side") + T.eq(f.e.state.calls, 1, "one public critical decision per Gen-2 move") + if level == 29 and critical and not stab and roll == 217 then + T.eq(amount, 17, "Gen-2 level-29 critical regression through native pipeline") + end + rows[#rows + 1] = { + enemy = enemy, + level = level, + critical = critical, + stab = stab, + roll = roll, + amount = amount, + pp = s.pp(), + calls = f.e.state.calls, + } + end + end + end + end + end + f.close() + return rows +end diff --git a/tests/drivers/gen2_critical_base.lua b/tests/drivers/gen2_critical_base.lua new file mode 100644 index 000000000..42f57de72 --- /dev/null +++ b/tests/drivers/gen2_critical_base.lua @@ -0,0 +1,101 @@ +-- Actual imported-data FIGHT/move/controller smoke in an isolated identity. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION") and V.isGen2(), "wrong imported game") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + local e = assert(game.mods.exports.portable_critical_order, "install critical example") + assert(e.available and game.save and game.overworld, "native mod/New Game missing") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local function named(registry, name) + for id, row in pairs(registry) do + if row.name == name then + return id + end + end + error("missing imported record " .. name) + end + local species, tackle = named(game.data.pokemon, "MEW"), named(game.data.moves, "TACKLE") + assert(game.data.moves[tackle].power == 35, "native cartridge Tackle power") + local function mon(name, level) + local p = P.new(game.data, species, level or 30) + p.nickname, p.hp = name, 400 + p.stats = { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + p.moves = { { id = tackle, pp = 10 } } + return p + end + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + for _, spec in ipairs({ + { level = 29, critical = false, types = { "NORMAL" }, want = 9 }, + { level = 29, critical = true, types = { "NORMAL" }, want = 17 }, + { level = 29, critical = true, stab = true, types = { "NORMAL" }, want = 25 }, + { level = 1, critical = true, types = { "NORMAL" }, want = 3 }, + { level = 3, critical = true, types = { "NORMAL" }, want = 5 }, + { level = 30, critical = true, types = { "NORMAL" }, want = 17 }, + }) do + game.save.party = { mon("PLAYER", spec.level) } + local old = game.data.trainers.PORTABLE_CRITICAL + game.data.trainers.PORTABLE_CRITICAL = { + name = "PORTABLE", + items = {}, + parties = { { { prebuilt = mon("ENEMY") } } }, + } + local b = Battle.newTrainer(game, "PORTABLE_CRITICAL", 1) + game.data.trainers.PORTABLE_CRITICAL = old + b:syncSides() + b.player.curTypes, b.enemy.curTypes = { spec.stab and "NORMAL" or "FIRE" }, spec.types + b.player.curStats.speed = 80 + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { game.overworld, b } + e.state.critical, e.state.calls = spec.critical, 0 + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT opens moves") + U.tap(game, "a") + for _ = 1, 4000 do + if b.phase == "menu" then + break + end + U.tap(game, "a") + end + assert(b.phase == "menu", "native turn did not settle") + assert(b.enemy.mon.hp == 400 - spec.want, "native Gen-2 critical-base rounding") + assert(b.player.curMoves[1].pp == 9 and b.enemy.curMoves[1].pp == 9, "native PP accounting") + assert(e.state.calls == 2, "both native critical decisions use the public consumer") + print( + "PORTABILITY_GEN2_CRITICAL_CASE " + .. V.get() + .. " " + .. spec.level + .. " " + .. tostring(spec.critical) + .. " " + .. tostring(spec.stab or false) + .. " " + .. table.concat(spec.types, "/") + .. " " + .. spec.want + ) + end + print("PORTABILITY_GEN2_CRITICAL_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/engine/critical_order_contract.lua b/tests/engine/critical_order_contract.lua index 7b9fd6d31..f9b3ed9c4 100644 --- a/tests/engine/critical_order_contract.lua +++ b/tests/engine/critical_order_contract.lua @@ -1,5 +1,5 @@ package.path = "./?.lua;./?/init.lua;" .. package.path -for _, version in ipairs({ "firered", "emerald" }) do +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do for _, api in ipairs({ 2, 3 }) do dofile("tests/critical_order/cases.lua")(".", "gen2", version, api) end diff --git a/tools/critical-order-contract-check/README.md b/tools/critical-order-contract-check/README.md index db19847d0..82a6a79ba 100644 --- a/tools/critical-order-contract-check/README.md +++ b/tools/critical-order-contract-check/README.md @@ -7,16 +7,19 @@ python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 2 --report /tmp/critical-api2.json ``` -Set `--lua /path/to/luajit` if needed. Each invocation runs four isolated -workers and compares **192 pairs** of observations on FireRed/Emerald. The +Set `--lua /path/to/luajit` if needed. Each invocation runs ten isolated +workers and compares **480 pairs** on Gold/Silver/Crystal/FireRed/Emerald. The source always uses API 3; the host uses API 3 or API 2/unified. Both load the same public `battle.crit` consumer through their real SDKs, then run native move pipelines with equivalent stats, live types and random damage rolls. -Observations include damage, PP and public critical-hook counts for both -attackers, critical/noncritical, STAB, neutral/half/quarter/immune type effects -and rolls 85/92/100. Reports preserve the engine refs and consumer/fixture +Gen-2 observations cover both attackers, levels 1/3/29/30/99/100, +critical/noncritical, STAB/non-STAB and rolls 217/255: 96 cases per version. +The GBA cases cover both attackers, critical/noncritical, STAB, +neutral/half/quarter/immune type effects and rolls 85/92/100: 96 per profile. +Damage, PP and public critical-hook counts are compared. Reports preserve the engine refs and consumer/fixture hashes; the checker refuses a different or modified reference. These are synthetic ROM-free fixtures, not native GUI or complete playthrough tests. -The isolated imported-data driver is documented in -[the correction's scope](../../docs/gen3-critical-order.md). +The isolated imported-data drivers and native-rule boundaries are documented +in the [Gen-2 correction](../../docs/gen2-critical-base.md) and +[GBA correction](../../docs/gen3-critical-order.md). diff --git a/tools/critical-order-contract-check/check.py b/tools/critical-order-contract-check/check.py index 691f07c36..d172508ae 100644 --- a/tools/critical-order-contract-check/check.py +++ b/tools/critical-order-contract-check/check.py @@ -8,7 +8,7 @@ import subprocess REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" -VERSIONS = ("firered", "emerald") +VERSIONS = ("gold", "silver", "crystal", "firered", "emerald") def git(root, *args): @@ -27,7 +27,7 @@ def main(): if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): parser.error("reference must be clean at " + REFERENCE) paths = ["examples/portable_critical_order/manifest.json", "examples/portable_critical_order/main.lua"] - paths += ["tests/critical_order/" + name for name in ("cases.lua", "worker.lua")] + paths += ["tests/critical_order/" + name for name in ("cases.lua", "gen2_cases.lua", "worker.lua")] hashes = {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths} runs, differences = [], [] for version in VERSIONS: From 127ff4cb2be7d465b3bc7c86727f34112b349ba2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:24:40 +0200 Subject: [PATCH 86/86] Record validated Gen-2 critical contracts and native regressions --- docs/gen2-critical-base.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/gen2-critical-base.md b/docs/gen2-critical-base.md index e19b4ac87..7f9307561 100644 --- a/docs/gen2-critical-base.md +++ b/docs/gen2-critical-base.md @@ -41,3 +41,27 @@ hits, critical level boundaries and STAB through the public consumer. It stops before battle finish, afterBattle or normal SAVE. This is not a complete playthrough or fresh full-ROM import; native Silver/Crystal GUI runs are not implied by the headless matrix. + +## Validation + +**7/7** focused arithmetic regressions and **1,950/1,950** loader checks pass. +Each host API mode passes **10 workers / 480 paired observations** against +clean Gen1 `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The unchanged #175 parent +differs in **32 damage observations** and fails **two level-29 regression +assertions per Gen-2 version** with the same current fixtures. + +Native LÖVE 11.5 passes six constructed battles each on imported Gold, +FireRed and Emerald: **18 battles and 36 public critical decisions**, at clean +feature runtime `397d7d6fb48bcf8f539c152397bb3e5ad220d23d`. Gold checks the new +level boundaries; the GBA runs retain the earlier type-rounding regressions. +The temporary installed example was removed with hash guards. Native +Silver/Crystal GUI runs are not claimed. Later changes record validation only. + +The complete feature passes **142/152** engine suites, **3/3** SDK suites and +B30. All **26 FAIL lines** exactly match the parent's ten known baseline +failure suites; their separate #128–134 repairs are outside this feature. +The combined testing runtime `6ba0ca1569a27faba67f0481e602df777fa5dc39`, with +those repairs, passes **197/197** engine suites, **3/3** SDK suites, B30 and both +shared contract modes. Critical implementation/consumer/driver files are byte +identical between feature and combined trees; the native results above retain +their feature runtime and are not relabeled as a new composed native run.