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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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/90] 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 64068911a5b48a9332b69428b4b12f159036649a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:47:44 +0200 Subject: [PATCH 13/90] Normalize public GBA enemy move choices and preserve native PP ownership --- .github/workflows/enemy-action-contract.yml | 42 +++ docs/portable-enemy-move-choices.md | 61 +++ examples/portable_enemy_action/main.lua | 13 + examples/portable_enemy_action/manifest.json | 11 + src/battle/BattleState.lua | 17 +- src/battle/EnemyMoveChoice.lua | 169 +++++++++ src/mods/CallbackRecords.lua | 13 + tests/enemy_actions/cases.lua | 353 ++++++++++++++++++ tests/enemy_actions/fixture.lua | 272 ++++++++++++++ .../engine/portable_enemy_action_choices.lua | 9 + tools/enemy-action-contract-check/check.py | 68 ++++ tools/enemy-action-contract-check/worker.lua | 7 + 12 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/enemy-action-contract.yml create mode 100644 docs/portable-enemy-move-choices.md create mode 100644 examples/portable_enemy_action/main.lua create mode 100644 examples/portable_enemy_action/manifest.json create mode 100644 src/battle/EnemyMoveChoice.lua create mode 100644 tests/enemy_actions/cases.lua create mode 100644 tests/enemy_actions/fixture.lua create mode 100644 tests/engine/portable_enemy_action_choices.lua create mode 100644 tools/enemy-action-contract-check/check.py create mode 100644 tools/enemy-action-contract-check/worker.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml new file mode 100644 index 000000000..3645ad8b0 --- /dev/null +++ b/.github/workflows/enemy-action-contract.yml @@ -0,0 +1,42 @@ +name: Enemy move choice contracts +on: + pull_request: + paths: + - '.github/workflows/enemy-action-contract.yml' + - 'src/**' + - 'examples/portable_enemy_action/**' + - 'tests/enemy_actions/**' + - 'tests/engine/portable_enemy_action_choices.lua' + - 'tools/enemy-action-contract-check/**' + push: + branches: [main, dev] +permissions: + contents: read +concurrency: + group: enemy-action-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: gen1-reference + - name: Install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Actual public hook and native move execution + run: >- + python3 gen2/tools/enemy-action-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/enemy-action-report.json" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: enemy-action-contract-report + path: ${{ runner.temp }}/enemy-action-report.json + if-no-files-found: error diff --git a/docs/portable-enemy-move-choices.md b/docs/portable-enemy-move-choices.md new file mode 100644 index 000000000..178c35bbe --- /dev/null +++ b/docs/portable-enemy-move-choices.md @@ -0,0 +1,61 @@ +# Portable GBA enemy move choices + +This change closes the result-normalization gap at `battle.enemy_action` for +FireRed and Emerald. A shared API-3 mod (or API-2 unified mod) can return: + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + return { kind = "move", move = "SCRATCH" } +end) +``` + +Bare canonical names, numeric native move IDs, numeric strings, and records +with `move` or `id` are accepted. IDs resolve through this game's declared move +rows, rather than a hard-coded move-number table. A supplied matching `slot` +is retained; otherwise the first matching learned slot is selected. Returning +nil, false, or an unknown name falls back to the captured native choice, or +asks native AI once if the hook did not delegate. + +Actions own execution choices; the native slot owns PP. Replacement records +cannot supply extra party PP, and selecting an exhausted learned slot does +not attack. A declared unlearned move runs without consuming another slot's +PP. Duplicate learned moves respect a valid explicit slot. Pressure charges +the actual selected slot. Native charge continuations retain identity and +spend PP once. Unmodified delegated choices and explicitly returned native +move handles retain identity. A delegated move edit uses the already-selected +slot without rewriting the learned move; the existing Gen2 `action.id` edit +remains supported. Mimic can recover a detached action's owned move slot. + +The boundary change is scoped to generation 3 and this action result. Other +generations and unrelated record types keep their current validation. + +## Verification and limits + +`tests/engine/portable_enemy_action_choices.lua` loads the actual public SDK +consumer and executes real battle actions on FireRed/Emerald, across API-1 +native, API-2 native/unified, and API-3 unified modes. It also covers right-flank +PP ownership and the existing delegated `id` editing convention. + +```sh +LUA=luajit bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The paired checker pins unchanged Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` as a read-only reference. It runs the +same mod files through both real loaders, then compares selected move numbers, +party PP, learned move preservation, actual damage occurrence, and charge +continuation outcomes. Reports retain both processes' output, exit status, +fixture hashes, and host cleanliness. It does not compare damage amounts, +RNG traces, native AI scoring, or entire battles. + +This is a move-result contract. The engines still differ in the hook's second +argument and native action record fields. Portable consumers should forward +arguments with `...` and return a fresh choice record. Public item/switch/watch +records and explicit double-battle target conversion remain separate work. +No Gen1 changes or publications are needed. + +The feature is stacked on the API-3 entry prerequisite (#102); review the +feature commit relative to that prerequisite to isolate this change. diff --git a/examples/portable_enemy_action/main.lua b/examples/portable_enemy_action/main.lua new file mode 100644 index 000000000..a5301a4f9 --- /dev/null +++ b/examples/portable_enemy_action/main.lua @@ -0,0 +1,13 @@ +return function(mod) + local choice + mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + if choice then + return choice(nextAction, ...) + end + return nextAction(...) + end) + mod.exports.choose = function(callback) + choice = callback + end + mod.exports.api = mod +end diff --git a/examples/portable_enemy_action/manifest.json b/examples/portable_enemy_action/manifest.json new file mode 100644 index 000000000..74fff025a --- /dev/null +++ b/examples/portable_enemy_action/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "portable_enemy_action", + "name": "Portable enemy action", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": [ + "gen3" + ], + "affects_link": true +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c2f51594a..7962d7f34 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -39,6 +39,7 @@ local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") local BattleCommands = require("src.battle.BattleCommands") +local EnemyMoveChoice = require("src.battle.EnemyMoveChoice") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -4658,7 +4659,7 @@ function BattleState:applyMimic(user, target, moveInst, slot) if not (src and src.id) then return end local mySlot for i, m in ipairs(user.curMoves) do - if m == moveInst then mySlot = i break end + if m == EnemyMoveChoice.slot(moveInst) then mySlot = i break end end if not mySlot then -- a called Mimic (Metronome) isn't in the list. For the player, @@ -5399,6 +5400,15 @@ function BattleState:enemyAction(battler) if move then return move end end if Runtime.wantsHook("battle.enemy_action") then + if GameVersion.generation() == 3 then + local delegated, captured + local result = Runtime.call("battle.enemy_action", function(battle, who) + delegated, captured = EnemyMoveChoice.delegate(who or battle.enemy, battle:vanillaEnemyAction(who)) + return delegated + end, self, battler) + return EnemyMoveChoice.normalize(self.data, battler, result, delegated, captured) + or (captured and captured.raw) or delegated or self:vanillaEnemyAction(battler) + end return Runtime.call("battle.enemy_action", function(battle, who) return battle:vanillaEnemyAction(who) end, self, battler) @@ -7993,6 +8003,11 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima or moveInst == user.rageMove local enemyUnlimited = not user.isPlayer and self.kind ~= "link" and self.ruleset and self.ruleset.enemyUnlimitedPP + if not isContinuation and not moveInst.struggle and not isCalled + and not enemyUnlimited and EnemyMoveChoice.exhausted(moveInst) then + self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + return + end if not isContinuation and not moveInst.struggle and not isCalled and not enemyUnlimited then -- PRESSURE costs the ATTACKER an extra point for every move aimed at its diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua new file mode 100644 index 000000000..336aafdb9 --- /dev/null +++ b/src/battle/EnemyMoveChoice.lua @@ -0,0 +1,169 @@ +-- Public GBA move choices are action records, not the learned move slot. +-- A separate action can change its move/target without rewriting party data; +-- its PP still belongs to the selected native slot (including Pressure). +local M = {} +local slots = setmetatable({}, { __mode = "k" }) +local function shallow(row) + local out = {} + for k, v in pairs(row) do + out[k] = v + end + return out +end +local function same(a, b) + for k, v in pairs(a) do + if b[k] ~= v then + return false + end + end + for k, v in pairs(b) do + if a[k] ~= v then + return false + end + end + return true +end +local function keyName(value) + return type(value) == "string" and value:upper():gsub("[%s%-]+", "_") or nil +end +local function resolve(data, ref) + if type(ref) ~= "string" and type(ref) ~= "number" then + return nil + end + local direct = data.moves[ref] + if type(direct) == "table" then + return ref, direct + end + local number = tonumber(ref) + if number and (number ~= number or number % 1 ~= 0 or number < 1 or number > 65535) then + return nil + end + local name, found, row = keyName(ref) + for id, def in pairs(data.moves) do + if type(def) == "table" then + local canonical = type(id) == "string" and not id:match("^MOVE_%d+$") and id or def.name + if + (number and (def.index == number or def.numId == number)) + or (name and keyName(canonical) == name) + then + if row and row ~= def then + return nil + end -- ambiguous names never select by iteration order + if found == nil or (def.id == id) or tostring(id) < tostring(found) then + found = id + end + row = def + end + end + end + return found, row +end +local function bind(action, slot) + slots[action] = slot + action.pp = nil + return setmetatable(action, { + __index = function(_, k) + if k == "pp" then + return slot.pp + end + end, + __newindex = function(t, k, v) + if k == "pp" then + slot.pp = v + else + rawset(t, k, v) + end + end, + }) +end +function M.slot(action) + return slots[action] or action +end +function M.exhausted(action) + return slots[action] ~= nil and action.pp <= 0 +end +function M.delegate(battler, action) + if + type(action) ~= "table" + or action.special + or action == battler.charging + or action == battler.thrashMove + or action == battler.rageMove + then + return action + end + for i, slot in ipairs(battler.curMoves or {}) do + if action == slot then + local copy = shallow(action) + copy.slot = i + return copy, { raw = action, before = shallow(copy) } + end + end + return action +end +function M.normalize(data, battler, value, delegated, captured) + if captured and value == delegated then + if same(value, captured.before) then + return captured.raw + end + local id = resolve(data, value.move or value.id) + if not id then + return captured.raw + end + local action = shallow(value) + action.id, action.move = id, nil + local slot = (battler.curMoves or {})[action.slot] + if type(slot) == "table" then + return bind(action, slot) + end + action.pp = 0 -- a delegated action without a slot has no party PP to spend + return action + end + if type(value) == "string" or type(value) == "number" then + value = { move = value } + end + if type(value) ~= "table" then + return nil + end + -- Legacy native handles and item/switch/continuation actions retain identity. + if + value.special + or value == battler.charging + or value == battler.thrashMove + or value == battler.rageMove + then + return value + end + for _, slot in ipairs(battler.curMoves or {}) do + if value == slot then + return value + end + end + if value.kind ~= nil and value.kind ~= "move" then + return nil + end + local id = resolve(data, value.move or value.id) + if not id then + return nil + end + local action = shallow(value) + action.id, action.move = id, nil + local moves = battler.curMoves or {} + local slot = moves[action.slot] + if not (type(slot) == "table" and slot.id == id) then + action.slot, slot = nil, nil + for i, move in ipairs(moves) do + if move.id == id then + action.slot, slot = i, move + break + end + end + end + if slot then + return bind(action, slot) + end + -- Gen1 permits a declared move that isn't learned: it runs without party PP. + action.pp = 0 + return action +end +return M diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 4ce1679c5..c442412b9 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -458,6 +458,12 @@ function R.new(loader, name, generation, api3) return out end restore = function(value, kind) + -- GBA enemy-action normalization accepts scalar move choices and false + -- fallback. Other record boundaries keep their existing validation. + if name == "battle.enemy_action" and generation == 3 and kind == "action" + and (type(value) == "string" or type(value) == "number" or value == false) then + return value + end if type(value) ~= "table" then assert( value == nil or (kind == "encounter" and value == false), @@ -647,6 +653,13 @@ function R.new(loader, name, generation, api3) if v ~= before[k] then stage(raw, k, restored) end + elseif kind == "action" and name == "battle.enemy_action" and generation == 3 + and not value.special and (k == "move" or k == "id") and v ~= before[k] + and (type(v) == "number" or type(v) == "string") then + local known = type(v) == "string" and ids:resolve("moves", v) + -- Keep canonical IDs native inside nested callbacks. Numeric and + -- alternate name spellings are resolved by the actual producer. + stage(raw, k, known and native("moves", v, old) or v) 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 diff --git a/tests/enemy_actions/cases.lua b/tests/enemy_actions/cases.lua new file mode 100644 index 000000000..d1aaf6270 --- /dev/null +++ b/tests/enemy_actions/cases.lua @@ -0,0 +1,353 @@ +local M = {} +function M.run(root, backend, version, api, format) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, api, format) + local T, observations = f.T, {} + local function fresh() + local c = f.fresh("TACKLE", { enemy = true }) + local u = c.battle.enemy + if backend == "gen1" then + u.mon.moves, u.mon.pp, u.mon.maxPp = { 33, 10, 10 }, { 10, 7, 5 }, { 10, 10, 10 } + else + u.curMoves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 7 }, + { id = f.ids.SCRATCH, pp = 5 }, + } + u.mon.moves = u.curMoves + end + return c, u + end + local function pp(u) + if backend == "gen1" then + return { unpack(u.mon.pp) } + end + local out = {} + for i, row in ipairs(u.curMoves) do + out[i] = row.pp + end + return out + end + local function number(action) + if backend == "gen1" then + return require("src.mods.Gen3Compat").moveId(action.move) + end + local row = f.data.moves[action.id] + return row and row.index + end + local function execute(b, a) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + b.enemy, + b.player, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(b.enemy, b.player, a) + end + end + local function choose(b) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, 1) + end + return b:enemyAction(b.enemy) + end + local specs = { + { + "name", + function() + return "SCRATCH" + end, + }, + { + "number", + function() + return 10 + end, + }, + { + "numeric-string", + function() + return "10" + end, + }, + { + "move-record", + function() + return { kind = "move", move = "SCRATCH" } + end, + }, + { + "numeric-record", + function() + return { move = 10 } + end, + }, + { + "numeric-string-record", + function() + return { move = "10" } + end, + }, + { + "lowercase-name", + function() + return "scratch" + end, + }, + { + "lowercase-record", + function() + return { move = "scratch" } + end, + }, + { + "numeric-id", + function() + return { id = 10 } + end, + }, + { + "id-record", + function() + return { id = "SCRATCH" } + end, + }, + { + "wrong-slot", + function() + return { move = "SCRATCH", slot = 1 } + end, + }, + { + "duplicate-slot", + function() + return { move = "SCRATCH", slot = 3 } + end, + 3, + }, + { + "fractional-slot", + function() + return { move = "SCRATCH", slot = 1.5 } + end, + }, + { + "forged-pp", + function() + return { id = "SCRATCH", pp = 900 } + end, + }, + { + "nil", + function() + return nil + end, + 1, + }, + { + "false", + function() + return false + end, + 1, + }, + { + "unknown", + function() + return "NO_SUCH_MOVE" + end, + 1, + }, + { + "forward", + function(nextAction, ...) + return nextAction(...) + end, + 1, + }, + { + "omitted-battler", + function(nextAction, battle) + return nextAction(battle) + end, + 1, + }, + { + "delegate-nil", + function(nextAction, ...) + nextAction(...) + return nil + end, + 1, + }, + { + "delegate-false", + function(nextAction, ...) + nextAction(...) + return false + end, + 1, + }, + { + "delegate-move", + function(nextAction, ...) + local action = nextAction(...) + action.move = "SCRATCH" + return action + end, + 1, + }, + } + for _, spec in ipairs(specs) do + local label = backend .. "/" .. version .. "/" .. api .. "/" .. spec[1] + local c, u = fresh() + local oldHp = c.hp() + f.e.choose(spec[2]) + local ok, action = pcall(choose, c.battle) + T.check(ok and type(action) == "table", label .. " produces an executable record") + if ok and type(action) == "table" then + local slot = spec[3] or 2 + local expected = slot == 1 and spec[1] ~= "delegate-move" and 33 or 10 + T.eq(number(action), expected, label .. " selects the requested move rather than fallback") + local ran, err = pcall(execute, c.battle, action) + T.check(ran, label .. " executes: " .. tostring(err)) + T.check(c.hp() < oldHp, label .. " damages the real opponent") + local want = { 10, 7, 5 } + want[slot] = want[slot] - 1 + T.same(pp(u), want, label .. " decrements only the owned slot") + local learned = backend == "gen1" and u.mon.moves[1] or f.data.moves[u.curMoves[1].id].index + T.eq(learned, 33, label .. " does not rewrite learned moves") + observations[#observations + 1] = + { case = spec[1], move = number(action), pp = pp(u), hit = c.hp() < oldHp, learned = learned } + end + end + -- Two hooks exercise translation of a scalar through next(), not only the final return. + local c, u = fresh() + f.e.choose(function() + return 10 + end) + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + return nextAction(...) + end, 100) + local a = choose(c.battle) + T.eq(number(a), 10, "nested hook retains numeric move choice") + execute(c.battle, a) + T.same(pp(u), { 10, 6, 5 }, "nested hook retains actual PP ownership") + stop() + local unlearned, actor = fresh() + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33 }, { 10 } + else + actor.curMoves = { actor.curMoves[1] } + actor.mon.moves = actor.curMoves + end + f.e.choose(function() + return { move = "SCRATCH" } + end) + local outside = choose(unlearned.battle) + T.eq(number(outside), 10, "declared unlearned move is selected") + execute(unlearned.battle, outside) + T.eq(unlearned.pp(), 10, "unlearned move cannot consume a different learned slot") + T.check(unlearned.hp() < 400, "unlearned declared move actually executes") + observations[#observations + 1] = { case = "unlearned", pp = pp(actor), hit = unlearned.hp() < 400 } + + -- A hook-created charge action must survive the native continuation by identity. + local charging = f.fresh("FLY", { enemy = true }) + f.e.choose(function() + return { move = "FLY" } + end) + local first = choose(charging.battle) + execute(charging.battle, first) + T.check(charging.charging(), "public charge choice enters native charge state") + T.eq(charging.pp(), 9, "charge spends owned PP once") + T.eq(charging.hp(), 400, "charge turn does not damage the target") + if backend == "gen2" then + charging.battle.enemy.chargeReady = true + end + f.e.choose(nil) + local second = choose(charging.battle) + if backend == "gen2" then + T.eq(first, second, "native charge continuation retains identity") + end + execute(charging.battle, second) + T.check(not charging.charging(), "native continuation releases the charge") + T.eq(charging.pp(), 9, "charge continuation does not spend PP twice") + T.check(charging.hp() < 400, "native continuation lands the move") + observations[#observations + 1] = + { case = "charge", pp = charging.pp(), hit = charging.hp() < 400, charging = charging.charging() } + local exhausted, exhaustedActor = fresh() + if backend == "gen1" then + exhaustedActor.mon.pp[2] = 0 + else + exhaustedActor.curMoves[2].pp = 0 + end + f.e.choose(function() + return { move = "SCRATCH", slot = 2, pp = 100 } + end) + execute(exhausted.battle, choose(exhausted.battle)) + T.eq(exhausted.hp(), 400, "forged PP cannot execute an exhausted learned move") + T.same(pp(exhaustedActor), { 10, 0, 5 }, "exhausted choice does not spend another slot") + observations[#observations + 1] = + { case = "exhausted", pp = pp(exhaustedActor), hit = exhausted.hp() < 400 } + if backend == "gen2" then + local pressure, actor = fresh() + pressure.battle.player.abilityOverride = "PRESSURE" + f.e.choose(function() + return { move = "SCRATCH" } + end) + execute(pressure.battle, choose(pressure.battle)) + T.same(pp(actor), { 10, 5, 5 }, "Pressure charges the actual slot twice") + + local legacy, legacyActor = fresh() + f.e.choose(function(nextAction, ...) + local result = nextAction(...) + result.id = format == "native" and f.ids.SCRATCH or "SCRATCH" + return result + end) + execute(legacy.battle, choose(legacy.battle)) + T.same(pp(legacyActor), { 9, 7, 5 }, "legacy delegated id edit retains its selected PP slot") + T.eq(legacyActor.curMoves[1].id, f.ids.TACKLE, "delegated id edit preserves learned move") + + local mimic, mimicActor = fresh() + mimicActor.curMoves[3] = { id = f.ids.MIMIC, pp = 5 } + f.e.choose(function() + return { move = "MIMIC" } + end) + execute(mimic.battle, choose(mimic.battle)) + T.eq(mimicActor.curMoves[3].id, f.ids.TACKLE, "Mimic replaces the actual selected slot") + T.eq(mimicActor.curMoves[3].pp, 4, "Mimic keeps PP on its actual slot") + T.check(mimicActor.curMoves[3].mimic, "native Mimic marks its owned slot") + T.eq(mimicActor.curMoves[1].mimic, nil, "Mimic does not overwrite the first slot") + + local double, left = fresh() + local b = double.battle + local Battle = require("src.battle.BattleState") + local mon = require("src.mods.Merge").deepCopy(left.mon) + local right = Battle.makeBattler(f.data, mon, false) + b.double = true + b:placeBattler(3, right) + b:syncSides() + f.e.choose(function() + return { move = "SCRATCH" } + end) + local rightAction = b:enemyAction(right) + b:executeAction(right, b.player, rightAction) + T.same(pp(right), { 10, 6, 5 }, "right-flank choice owns right-flank PP") + T.same(pp(left), { 10, 7, 5 }, "right-flank action leaves left-flank PP intact") + if format == "native" then + local raw = right.curMoves[2] + f.e.choose(function() + return raw + end) + T.eq(b:enemyAction(right), raw, "explicit native move handles retain identity") + end + end + T.eq(#f.result.errors, 0, "public consumer completes without swallowed hook failures") + f.close() + return observations +end +return M diff --git a/tests/enemy_actions/fixture.lua b/tests/enemy_actions/fixture.lua new file mode 100644 index 000000000..5f57774ce --- /dev/null +++ b/tests/enemy_actions/fixture.lua @@ -0,0 +1,272 @@ +local M = {} +function M.new(root, backend, version, api, format) + local consumer = "portable_enemy_action" + 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 = { + { "MIMIC", 102, 0, "NORMAL", "MIMIC_EFFECT", "EFFECT_MIMIC", 82 }, + { "SCRATCH", 10, 40, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, + { "FLY", 19, 70, "FLYING", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "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() + assert(gen == 3, "enemy action fixture requires GBA") + if backend ~= "gen1" 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 == 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/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = f:read("*a") + f:close() + end + 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/" .. consumer .. "/manifest.json"] = Json.encode(manifest) + local result = T.sdk.loadMods( + { "mods/" .. consumer }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + 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 + 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[consumer], + 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/engine/portable_enemy_action_choices.lua b/tests/engine/portable_enemy_action_choices.lua new file mode 100644 index 000000000..967e97d77 --- /dev/null +++ b/tests/engine/portable_enemy_action_choices.lua @@ -0,0 +1,9 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Cases = require("tests.enemy_actions.cases") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + Cases.run(".", "gen2", version, mode[1], mode[2]) + end +end +T.finish("portable enemy move choices") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py new file mode 100644 index 000000000..8b27d722e --- /dev/null +++ b/tools/enemy-action-contract-check/check.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Compare the same mod's GBA enemy move choices with a clean read-only Gen1 reference.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +GEN1_REF = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +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) + 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", required=True, type=Path) + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != GEN1_REF or git(source, "status", "--porcelain"): + parser.error(f"Gen1 must be clean at {GEN1_REF}; the checker never patches it") + paths = [*sorted((host / "examples/portable_enemy_action").glob("*")), + *sorted((host / "tests/enemy_actions").glob("*.lua")), Path(__file__).with_name("worker.lua")] + report = {"source_ref": GEN1_REF, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), + "fixture_sha256": {str(p.relative_to(host)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in paths}, "runs": [], "comparisons": []} + failed = False + for version in ("firered", "emerald"): + for api in (2, 3): + values = {} + for backend, root in (("gen1", source), ("gen2", host)): + cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), + str(host), backend, version, str(api)] + run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=60) + rows = [json.loads(line.removeprefix("ENEMY_ACTION_OBSERVATIONS ")) + for line in run.stdout.splitlines() if line.startswith("ENEMY_ACTION_OBSERVATIONS ")] + report["runs"].append({"backend": backend, "version": version, "api": api, + "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr}) + if run.returncode or len(rows) != 1: + failed = True + else: + values[backend] = rows[0] + equal = len(values) == 2 and values["gen1"] == values["gen2"] + failed |= not equal + report["comparisons"].append({"version": version, "api": api, "equal": equal, + "observations": values}) + print(f"{'PASS' if equal else 'FAIL'} {version}/API {api}: identical public move selections and execution") + report["passed"] = not failed + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + if git(source, "status", "--porcelain"): + raise RuntimeError("reference checkout changed during the comparison") + return int(failed) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua new file mode 100644 index 000000000..8863caefa --- /dev/null +++ b/tools/enemy-action-contract-check/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api = unpack(arg) +local T = require("tests.modkit") +local rows = + dofile(root .. "/tests/enemy_actions/cases.lua").run(root, backend, version, tonumber(api), "unified") +print("ENEMY_ACTION_OBSERVATIONS " .. require("src.link.Json").encode(rows)) +T.finish("enemy action contract") From 206052fb3b901d343017dac6212ef818ec13b05f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:02:16 +0200 Subject: [PATCH 14/90] Align API-3 GBA enemy-action battler selectors --- .github/workflows/enemy-action-contract.yml | 2 +- docs/portable-enemy-action-arguments.md | 55 +++++ docs/portable-enemy-move-choices.md | 5 +- src/battle/BattleState.lua | 21 +- src/battle/EnemyActionContext.lua | 53 +++++ src/battle/EnemyMoveChoice.lua | 4 +- src/mods/UnifiedCallbacks.lua | 21 +- tests/enemy_actions/arguments.lua | 218 ++++++++++++++++++ .../portable_enemy_action_arguments.lua | 112 +++++++++ tools/enemy-action-contract-check/check.py | 10 +- tools/enemy-action-contract-check/worker.lua | 11 +- 11 files changed, 487 insertions(+), 25 deletions(-) create mode 100644 docs/portable-enemy-action-arguments.md create mode 100644 src/battle/EnemyActionContext.lua create mode 100644 tests/enemy_actions/arguments.lua create mode 100644 tests/engine/portable_enemy_action_arguments.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 3645ad8b0..82160dcb3 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -6,7 +6,7 @@ on: - 'src/**' - 'examples/portable_enemy_action/**' - 'tests/enemy_actions/**' - - 'tests/engine/portable_enemy_action_choices.lua' + - 'tests/engine/portable_enemy_action_*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/portable-enemy-action-arguments.md b/docs/portable-enemy-action-arguments.md new file mode 100644 index 000000000..074f866d9 --- /dev/null +++ b/docs/portable-enemy-action-arguments.md @@ -0,0 +1,55 @@ +# API-3 GBA enemy-action arguments + +On FireRed and Emerald, API 3 exposes the same `battle.enemy_action` input as +Gen1Recomp: the opaque battle handle and a positional battler ID. An omitted +request remains nil, which selects the default left opponent. The native +single controller omits the selector; the double controller requests 1 and 3. + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, battle, battlerId) + if battlerId == 3 then return { move = "SCRATCH" } end + return nextAction(battle, battlerId) +end) +``` + +`nextAction()` retains the current arguments. `nextAction(battle)` and +`nextAction(battle, nil)` request the native default; an explicit positional +ID requests that actor's choice. A nested public hook sees the forwarded +selector. The native Gen2 hook layer continues to receive its actual battler +handle. API 1/2 native and API 2 unified retain their existing actor argument +contracts. This argument change applies to API 3 on generation 3 only. + +A request and each forwarded call own a synchronous context. Nested queries +of the same battle restore the outer selector, and a fatal native error +clears the context before propagation. Native AI is not retried after error. +There is no extra public/native argument or persistent flag on the battle. + +As in the native reference planner, querying another actor does not transfer +execution ownership: the caller executes the returned choice and spends its +own selected PP slot. A delegated native action retains its slot index. A new +replacement choice still validates the slot against the caller's learned +moves. Querying another flank does not consume that flank's PP. + +## Verification + +```sh +luajit tests/engine/portable_enemy_action_arguments.lua +luajit tests/engine/portable_enemy_action_choices.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The checker runs the identical consumer and shared fixtures against unchanged +read-only Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. API-3 argument +cases cover default/explicit requests, position-based choices, all forwarding +forms, nested public hooks, foreign native queries, real move execution and +PP ownership, and reentrant selection. Existing API-2/API-3 move-result cases +remain in the comparison. Local Gen2 checks additionally execute complete +native single/double turn queues, mix native/public callbacks, retain legacy +API-1/2 argument/handle identities, and verify native-error cleanup. + +Native result record fields, public item/switch/watch actions, explicit +move-target conversion, damage amounts and RNG traces remain separate +contracts. No Gen1 source change or publication is required. This feature is +stacked on #190; review its feature commit relative to that prerequisite. diff --git a/docs/portable-enemy-move-choices.md b/docs/portable-enemy-move-choices.md index 178c35bbe..acc62933d 100644 --- a/docs/portable-enemy-move-choices.md +++ b/docs/portable-enemy-move-choices.md @@ -51,8 +51,9 @@ continuation outcomes. Reports retain both processes' output, exit status, fixture hashes, and host cleanliness. It does not compare damage amounts, RNG traces, native AI scoring, or entire battles. -This is a move-result contract. The engines still differ in the hook's second -argument and native action record fields. Portable consumers should forward +This is a move-result contract. API-3 GBA input selectors are covered by the +follow-up [enemy-action argument contract](portable-enemy-action-arguments.md). +Native action record fields still differ. Portable consumers should forward arguments with `...` and return a fresh choice record. Public item/switch/watch records and explicit double-battle target conversion remain separate work. No Gen1 changes or publications are needed. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 7962d7f34..1782da653 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -40,6 +40,7 @@ local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") local BattleCommands = require("src.battle.BattleCommands") local EnemyMoveChoice = require("src.battle.EnemyMoveChoice") +local EnemyActionContext = require("src.battle.EnemyActionContext") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -5390,6 +5391,7 @@ end -- "menuLockedAction: attempt to index local 'battler' (a nil value)" after -- killing their first Pokemon. A slot with nobody in it has no action. function BattleState:enemyAction(battler) + local requestedBattler = battler battler = battler or self.enemy if not battler then return nil end -- The POKé DUDE opponent does not run the AI at all. Its controller feeds @@ -5401,13 +5403,16 @@ function BattleState:enemyAction(battler) end if Runtime.wantsHook("battle.enemy_action") then if GameVersion.generation() == 3 then - local delegated, captured - local result = Runtime.call("battle.enemy_action", function(battle, who) - delegated, captured = EnemyMoveChoice.delegate(who or battle.enemy, battle:vanillaEnemyAction(who)) - return delegated - end, self, battler) - return EnemyMoveChoice.normalize(self.data, battler, result, delegated, captured) - or (captured and captured.raw) or delegated or self:vanillaEnemyAction(battler) + return EnemyActionContext.call(self, requestedBattler, function() + local delegated, captured + local result = Runtime.call("battle.enemy_action", function(battle, who) + delegated, captured = EnemyMoveChoice.delegate(who or battle.enemy, battle:vanillaEnemyAction(who)) + return delegated + end, self, battler) + return EnemyMoveChoice.normalize(self.data, battler, result, delegated, captured) + or EnemyMoveChoice.normalize(self.data, battler, delegated, delegated, captured) + or self:vanillaEnemyAction(battler) + end) end return Runtime.call("battle.enemy_action", function(battle, who) return battle:vanillaEnemyAction(who) @@ -5502,7 +5507,7 @@ end function BattleState:resolveTurn(playerAction) -- nil in a double battle whose left-hand foe has fainted with nothing on -- the bench to replace it; `actionFor` asks each surviving slot for its own - local enemyAction = self:enemyAction(self.enemy) + local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) self.turnCount = (self.turnCount or 0) + 1 -- BATTLE ARENA: arenaTurnCounter if self.arena then require("src.battle.Gen3Arena").turnStarted(self) end diff --git a/src/battle/EnemyActionContext.lua b/src/battle/EnemyActionContext.lua new file mode 100644 index 000000000..1f9cc2643 --- /dev/null +++ b/src/battle/EnemyActionContext.lua @@ -0,0 +1,53 @@ +-- Keep the caller's omitted/explicit selector without changing native hook +-- arguments. Each synchronous hook/next call owns a frame, including reentry. +local C = {} +local frames = setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function C.call(battle, selector, fn, ...) + local previous = frames[battle] + frames[battle] = { explicit = selector ~= nil, default = battle.enemy } + local result = pack(pcall(fn, ...)) + frames[battle] = previous + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) +end +local function actorAt(battle, id) + if id == 0 then + return battle.player + end + if id == 1 then + return battle.enemy + end + return battle:battlerAt(id) +end +function C.publicId(battle, actor) + local frame = frames[battle] + if actor == nil or (frame and not frame.explicit and actor == frame.default) then + return nil + end + local id = actor == battle.player and 0 or actor == battle.enemy and 1 or actor.position + assert( + type(id) == "number" and id % 1 == 0 and id >= 0 and id <= 3, + "enemy action requires a positional battler" + ) + assert(actorAt(battle, id) == actor, "enemy action battler is no longer active") + return id +end +function C.nativeActor(battle, id) + if id == nil then + return battle.enemy + end + assert( + type(id) == "number" and id % 1 == 0 and id >= 0 and id <= 3, + "enemy action battler ID must be an integer from 0 to 3" + ) + local actor = actorAt(battle, id) + assert(actor, "enemy action battler is not active") + return actor +end +return C diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index 336aafdb9..4131cbeb3 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -96,14 +96,14 @@ function M.delegate(battler, action) if action == slot then local copy = shallow(action) copy.slot = i - return copy, { raw = action, before = shallow(copy) } + return copy, { raw = action, before = shallow(copy), owner = battler } end end return action end function M.normalize(data, battler, value, delegated, captured) if captured and value == delegated then - if same(value, captured.before) then + if captured.owner == battler and same(value, captured.before) then return captured.raw end local id = resolve(data, value.move or value.id) diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..b1952b3c4 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -1,5 +1,6 @@ local Api3Catalog = require("src.mods.Api3Catalog") local Records = require("src.mods.CallbackRecords") +local EnemyActionContext = require("src.battle.EnemyActionContext") local Ids = require("src.mods.UnifiedIds") local C = {} local unpack = table.unpack or unpack @@ -139,13 +140,16 @@ function C.install(loader, api, owner, generation) assert(type(callback) == "function", "hook callback must be a function") return loader.hooks:wrap(name, function(nextFn, ...) local boundary = Records.new(loader, name, generation, api3) - local roles = positions[name] or {} + local portableEnemy = api3 and generation == 3 and name == "battle.enemy_action" + local roles = portableEnemy and { false, "enemySlot" } or 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 + if role == "enemySlot" then + v = EnemyActionContext.publicId(args[1], v) + elseif role == "items" or role == "moves" then v = boundary.read(role, v) elseif role then v = boundary.view(v, role) @@ -159,13 +163,18 @@ 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 + if role == "enemySlot" then + v = EnemyActionContext.nativeActor(args[1], v) + elseif 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 + if portableEnemy and args.n < 2 then + out.n, out[2] = 2, EnemyActionContext.nativeActor(args[1], nil) + end return out end local public = translate(original) @@ -180,7 +189,11 @@ function C.install(loader, api, owner, generation) return rawArgs(args) end) called = true - downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if portableEnemy then + downstream = pack(pcall(EnemyActionContext.call, raw[1], args[2], nextFn, unpack(raw, 1, raw.n))) + else + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + end if downstream[1] then boundary.refresh() publicResult = pack(unpack(downstream, 2, downstream.n)) diff --git a/tests/enemy_actions/arguments.lua b/tests/enemy_actions/arguments.lua new file mode 100644 index 000000000..c56479837 --- /dev/null +++ b/tests/enemy_actions/arguments.lua @@ -0,0 +1,218 @@ +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified") + local T, observations = f.T, {} + local function setup(double) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if backend == "gen1" then + if double then + local State = require("src.core.game3.battle.state") + b.battlers[2] = + State.makeBattler(require("src.mods.Merge").deepCopy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = + State.makeBattler(require("src.mods.Merge").deepCopy(b.enemy.mon), "enemy", { id = 3 }) + end + elseif double then + local Battle = require("src.battle.BattleState") + b:placeBattler(2, Battle.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.player.mon), true)) + b:placeBattler(3, Battle.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.enemy.mon), false)) + end + if backend == "gen2" then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local actor = backend == "gen1" and b.battlers[id] or b:battlerAt(id) + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33, 10 }, { 10, 7 } + else + actor.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 7 } } + actor.mon.moves = actor.curMoves + end + end + return c, b + end + local function actorAt(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(id ~= nil and actorAt(b, id) or nil) + end + local function number(a) + if backend == "gen1" then + return require("src.mods.Gen3Compat").moveId(a.move) + end + return f.data.moves[a.id].index + end + local function pp(actor) + if backend == "gen1" then + return { unpack(actor.mon.pp) } + end + local out = {} + for i, move in ipairs(actor.curMoves) do + out[i] = move.pp + end + return out + end + local function execute(b, id, a) + local actor, target = actorAt(b, id), actorAt(b, 0) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + actor, + target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(actor, target, a) + end + end + for _, double in ipairs({ false, true }) do + for _, spec in ipairs({ + { name = "default" }, + { name = "explicit-left", id = 1 }, + { name = "explicit-right", id = 3 }, + }) do + if double or spec.id ~= 3 then + for _, forwarding in ipairs({ "replace", "by-position", "forward", "zero-next", "omit-battler" }) do + local c, b = setup(double) + local seen = {} + f.e.choose(function(nextAction, battle, id, ...) + T.eq(select("#", ...), 0, "native hook arity is preserved") + T.eq(battle, b, "opaque battle handle is preserved") + seen[#seen + 1] = id == nil and "nil" or type(id) == "number" and id or type(id) + if forwarding == "forward" then + return nextAction(battle, id) + end + if forwarding == "zero-next" then + return nextAction() + end + if forwarding == "omit-battler" then + return nextAction(battle) + end + return { move = forwarding == "by-position" and id ~= 3 and "TACKLE" or "SCRATCH" } + end) + local action = choose(b, spec.id) + local label = backend + .. "/" + .. version + .. "/" + .. tostring(double) + .. "/" + .. spec.name + .. "/" + .. forwarding + T.same(seen, { spec.id == nil and "nil" or spec.id }, label .. " publishes numeric/nil request") + local scratch = forwarding == "replace" or (forwarding == "by-position" and spec.id == 3) + T.eq(number(action), scratch and 10 or 33, label .. " returns actual choice") + local id = spec.id or 1 + execute(b, id, action) + local expected = scratch and { 10, 6 } or { 9, 7 } + T.same(pp(actorAt(b, id)), expected, label .. " spends caller PP") + local other = double and actorAt(b, id == 1 and 3 or 1) + if other then + T.same(pp(other), { 10, 7 }, label .. " preserves other flank PP") + end + observations[#observations + 1] = { + double = double, + case = spec.name, + forwarding = forwarding, + seen = seen, + move = number(action), + pp = pp(actorAt(b, id)), + other = other and pp(other) or nil, + } + end + end + end + end + for _, spec in ipairs({ + { name = "explicit-left", initial = 3, forwarded = 1 }, + { name = "explicit-right", initial = 1, forwarded = 3 }, + { name = "omit", initial = 3 }, + { name = "explicit-from-default", forwarded = 1 }, + }) do + local _, b = setup(true) + local trace = {} + f.e.choose(function(_, battle, id) + T.eq(battle, b, "downstream public callback retains opaque handle") + trace[#trace + 1] = id == nil and "nil" or id + return { move = id == 3 and "SCRATCH" or "TACKLE" } + end) + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, battle, id) + trace[#trace + 1] = id == nil and "nil" or id + return nextAction(battle, spec.forwarded) + end, 100) + local action = choose(b, spec.initial) + stop() + T.same( + trace, + { spec.initial == nil and "nil" or spec.initial, spec.forwarded == nil and "nil" or spec.forwarded }, + "nested next preserves explicit/default selector" + ) + local scratch = spec.forwarded == 3 + T.eq(number(action), scratch and 10 or 33, "nested numeric selector affects actual move") + execute(b, spec.initial or 1, action) + T.same( + pp(actorAt(b, spec.initial or 1)), + scratch and { 10, 6 } or { 9, 7 }, + "forwarded choice spends the caller's actual PP" + ) + observations[#observations + 1] = { + case = "nested-" .. spec.name, + seen = trace, + move = number(action), + pp = pp(actorAt(b, spec.initial or 1)), + } + end + -- Query native AI for the other flank, then execute as the original caller. + -- Native source planning keeps the caller while using the delegated slot. + local _, foreign = setup(true) + local right = actorAt(foreign, 3) + if backend == "gen1" then + right.mon.moves, right.mon.pp = { 10 }, { 5 } + else + right.curMoves = { { id = f.ids.SCRATCH, pp = 5 } } + right.mon.moves = right.curMoves + end + f.e.choose(function(nextAction, battle) + return nextAction(battle, 3) + end) + local choice = choose(foreign, 1) + T.eq(number(choice), 10, "forwarded native AI selects the right flank's actual move") + execute(foreign, 1, choice) + T.same(pp(actorAt(foreign, 1)), { 9, 7 }, "delegated native slot spends caller PP") + T.same(pp(right), { 5 }, "delegated native query does not spend queried actor PP") + observations[#observations + 1] = + { case = "foreign-native", move = number(choice), pp = pp(actorAt(foreign, 1)), other = pp(right) } + + local _, reentrant = setup(true) + local trace, active = {}, false + f.e.choose(function(nextAction, battle, id) + trace[#trace + 1] = id == nil and "nil" or id + if not active then + active = true + local inner = choose(battle, 3) + T.eq(number(inner), 33, "reentrant request completes native selection") + active = false + end + return nextAction(battle, id) + end) + local result = choose(reentrant) + T.same(trace, { "nil", 3 }, "reentrant native requests preserve both public selectors") + execute(reentrant, 1, result) + T.same(pp(actorAt(reentrant, 1)), { 9, 7 }, "outer request retains caller PP after reentry") + T.same(pp(actorAt(reentrant, 3)), { 10, 7 }, "nested query is read-only until execution") + observations[#observations + 1] = + { case = "reentry", seen = trace, pp = pp(actorAt(reentrant, 1)), other = pp(actorAt(reentrant, 3)) } + f.close() + return observations +end +return M diff --git a/tests/engine/portable_enemy_action_arguments.lua b/tests/engine/portable_enemy_action_arguments.lua new file mode 100644 index 000000000..d2507af0d --- /dev/null +++ b/tests/engine/portable_enemy_action_arguments.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.arguments").run(".", "gen2", version) +end +local function setup(f, double) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if double then + local Battle, Merge = require("src.battle.BattleState"), require("src.mods.Merge") + b:placeBattler(2, Battle.makeBattler(f.data, Merge.deepCopy(b.player.mon), true)) + b:placeBattler(3, Battle.makeBattler(f.data, Merge.deepCopy(b.enemy.mon), false)) + end + b:syncSides() + return b +end +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" } }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, mode[1], mode[2]) + local b = setup(f, true) + for _, actor in ipairs({ b.enemy, b:battlerAt(3) }) do + local seen + f.e.choose(function(nextAction, battle, who) + T.eq(battle, b, "legacy opaque battle remains identical") + seen = who + if mode[2] == "native" then + T.eq(who, actor, "API-1/2 native actor identity is preserved") + else + T.neq(who, actor, "API-2 unified actor remains detached") + T.eq(who.side, "enemy", "API-2 unified actor keeps side") + T.eq(who.mon.species, "CHARMANDER", "API-2 unified actor keeps canonical Pokemon") + end + return nextAction(battle, who) + end) + local action = b:enemyAction(actor) + T.eq(type(seen), "table", "existing API-1/2 argument contract stays a record") + T.eq(action, actor.curMoves[1], "legacy native choice handle is preserved") + end + f.close() + end + for _, double in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") + local b, seen, native = setup(f, double), {}, {} + f.e.choose(function(nextAction, battle, id) + seen[#seen + 1] = id == nil and "nil" or id + return nextAction(battle, id) + end) + local stop = f.result.loader.hooks:wrap("battle.enemy_action", function(nextAction, battle, actor, ...) + T.eq(select("#", ...), 0, "mixed native callback retains arity") + T.eq(battle, b, "mixed native callback retains battle") + T.check(actor == b.enemy or actor == b:battlerAt(3), "mixed native callback receives actual actor") + native[#native + 1] = actor + return nextAction(battle, actor) + end, -100, "native-argument-fixture") + if double then + b.pendingActions = { [2] = b:battlerAt(2).curMoves[1] } + end + b.queue = {} + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native turn queue completes") + T.same( + seen, + double and { 1, 3 } or { "nil" }, + "actual controller publishes shared single/double selectors" + ) + T.eq(#native, double and 2 or 1, "each native enemy choice runs once") + T.eq(native[1], b.enemy, "mixed native callback retains left actor identity") + if double then + T.eq(native[2], b:battlerAt(3), "mixed native callback retains right actor identity") + end + for _, actor in b:activeBattlers() do + T.eq(actor.curMoves[1].pp, 9, "actual controller spends each actor's PP exactly once") + end + stop() + if not double then + local entered = 0 + b.kind, b.trainer = + "trainer", { + brain = function() + entered = entered + 1 + error("intentional native brain failure") + end, + } + local ok = pcall(b.enemyAction, b) + T.check(not ok, "native AI failure reaches the caller") + T.eq(entered, 1, "native AI failure is not retried") + b.kind, b.trainer = "wild", nil + local later + f.e.choose(function(_, battle, id) + later = id + return "SCRATCH" + end) + local result = f.result.loader.hooks:call("battle.enemy_action", function() + error("must not delegate") + end, b, b.enemy) + T.eq(later, 1, "failed request leaves no stale default-selector scope") + T.eq(result, "SCRATCH", "independent native hook invocation still completes") + end + f.close() + end +end +T.finish("portable enemy action arguments") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 8b27d722e..cc53a14ac 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,15 +33,15 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api in (2, 3): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), - str(host), backend, version, str(api)] + str(host), backend, version, str(api), mode] run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=60) rows = [json.loads(line.removeprefix("ENEMY_ACTION_OBSERVATIONS ")) for line in run.stdout.splitlines() if line.startswith("ENEMY_ACTION_OBSERVATIONS ")] - report["runs"].append({"backend": backend, "version": version, "api": api, + report["runs"].append({"backend": backend, "version": version, "api": api, "mode": mode, "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr}) if run.returncode or len(rows) != 1: failed = True @@ -49,9 +49,9 @@ def main(): values[backend] = rows[0] equal = len(values) == 2 and values["gen1"] == values["gen2"] failed |= not equal - report["comparisons"].append({"version": version, "api": api, "equal": equal, + report["comparisons"].append({"version": version, "api": api, "mode": mode, "equal": equal, "observations": values}) - print(f"{'PASS' if equal else 'FAIL'} {version}/API {api}: identical public move selections and execution") + print(f"{'PASS' if equal else 'FAIL'} {version}/API {api}/{mode}: identical public move selections and execution") report["passed"] = not failed args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, indent=2) + "\n") diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 8863caefa..4e13e1745 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -1,7 +1,12 @@ package.path = "./?.lua;./?/init.lua;" .. package.path -local root, backend, version, api = unpack(arg) +local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") -local rows = - dofile(root .. "/tests/enemy_actions/cases.lua").run(root, backend, version, tonumber(api), "unified") +local rows +if mode == "arguments" then + rows = dofile(root .. "/tests/enemy_actions/arguments.lua").run(root, backend, version) +else + rows = + dofile(root .. "/tests/enemy_actions/cases.lua").run(root, backend, version, tonumber(api), "unified") +end print("ENEMY_ACTION_OBSERVATIONS " .. require("src.link.Json").encode(rows)) T.finish("enemy action contract") From 3f01f29e47c191eb1da1f0b80ab68eace8d1b035 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:24:47 +0200 Subject: [PATCH 15/90] Expose shared API-3 GBA native move records --- .github/workflows/enemy-action-contract.yml | 1 + docs/portable-enemy-action-arguments.md | 3 +- docs/portable-enemy-move-choices.md | 3 +- docs/portable-enemy-move-records.md | 63 +++++ src/battle/BattleState.lua | 2 +- src/battle/EnemyMoveChoice.lua | 58 +++- src/mods/CallbackRecords.lua | 36 ++- tests/enemy_actions/fixture.lua | 4 + tests/enemy_actions/records.lua | 281 +++++++++++++++++++ tests/engine/portable_enemy_move_records.lua | 64 +++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 12 files changed, 505 insertions(+), 16 deletions(-) create mode 100644 docs/portable-enemy-move-records.md create mode 100644 tests/enemy_actions/records.lua create mode 100644 tests/engine/portable_enemy_move_records.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 82160dcb3..072e67009 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -7,6 +7,7 @@ on: - 'examples/portable_enemy_action/**' - 'tests/enemy_actions/**' - 'tests/engine/portable_enemy_action_*.lua' + - 'tests/engine/portable_enemy_move_records.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/portable-enemy-action-arguments.md b/docs/portable-enemy-action-arguments.md index 074f866d9..7a4162234 100644 --- a/docs/portable-enemy-action-arguments.md +++ b/docs/portable-enemy-action-arguments.md @@ -49,7 +49,8 @@ remain in the comparison. Local Gen2 checks additionally execute complete native single/double turn queues, mix native/public callbacks, retain legacy API-1/2 argument/handle identities, and verify native-error cleanup. -Native result record fields, public item/switch/watch actions, explicit +Native move result fields are covered by the follow-up +[move record contract](portable-enemy-move-records.md). Public item/switch/watch actions, explicit move-target conversion, damage amounts and RNG traces remain separate contracts. No Gen1 source change or publication is required. This feature is stacked on #190; review its feature commit relative to that prerequisite. diff --git a/docs/portable-enemy-move-choices.md b/docs/portable-enemy-move-choices.md index acc62933d..6e724f8ac 100644 --- a/docs/portable-enemy-move-choices.md +++ b/docs/portable-enemy-move-choices.md @@ -53,7 +53,8 @@ RNG traces, native AI scoring, or entire battles. This is a move-result contract. API-3 GBA input selectors are covered by the follow-up [enemy-action argument contract](portable-enemy-action-arguments.md). -Native action record fields still differ. Portable consumers should forward +Native move result fields are covered by the follow-up +[move record contract](portable-enemy-move-records.md); other action records still differ. Portable consumers should forward arguments with `...` and return a fresh choice record. Public item/switch/watch records and explicit double-battle target conversion remain separate work. No Gen1 changes or publications are needed. diff --git a/docs/portable-enemy-move-records.md b/docs/portable-enemy-move-records.md new file mode 100644 index 000000000..d0f5bb84f --- /dev/null +++ b/docs/portable-enemy-move-records.md @@ -0,0 +1,63 @@ +# API-3 GBA native move records + +A native move returned by `next()` in `battle.enemy_action` now exposes the +common Gen1Recomp fields on Gen2Recomped, for FireRed/Emerald API 3: + +| Field | Common native meaning | +| --- | --- | +| `kind` | `"move"` | +| `move` | Declared native numeric move ID; native exhausted fallback uses `"STRUGGLE"` | +| `slot` | Native selected learned slot, or nil when no slot owns PP | +| `user` | `"enemy"` | +| `battler` | Explicit/default native positional selection; single default omits it | +| `targetType` | Native target byte on a double-battle action; absent on the single action | + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + if type(action) == "table" and action.kind == "move" then + action.move = "SCRATCH" + end + return action +end) +``` + +The view does not rewrite learned moves or the native handle. Returning an +unchanged native delegate keeps its identity. Editing a bound delegate keeps +its native selected PP slot, as Gen1's native action does. Returning a new +replacement record validates the slot against its requested move instead. +The existing Gen2 canonical `id` field remains available, and a legacy `id` +edit still works when the synthesized `move` alias is unchanged. + +Reference forms authored by another API-3 hook remain intact between hooks: +`{move=10}`, `{move="SCRATCH"}`, `{id=10}` and `{id="SCRATCH"}` retain their +original reference forms between public hooks. Native hook links retain +physical Gen2 representations. A native rewrite invalidates the previous +reference snapshot so an outer public hook sees the new choice. Final move +normalization still runs after the whole hook chain. Native delegate metadata and authored provenance +are held in weak side tables, without public engine bookkeeping fields. +API 1/2 views retain their existing format. Legacy native queries with detached +actors also remain supported. A native row without a declared numeric move +index is not assigned an invented number. + +## Checks and boundaries + +```sh +luajit tests/engine/portable_enemy_move_records.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The same consumer/fixtures run against unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Cases compare actual common fields, +mutation versus replacement semantics, selected-slot PP, nested authored and +native-edited references, charge continuations, and the real Struggle fallback. +`battle.move_used` proves which move actually executes, alongside HP/PP checks. +The existing move-result and selector contract cases remain in the checker. + +This is the native **move** record contract, not complete action/AI parity. +Scores, selected double-battle targets, item/switch/watch records, other native +engine fields, damage amounts, RNG traces and full gameplay remain separate. +No score or target-selection result is fabricated. No Gen1 source change or +publication is required. The feature is stacked on #191. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1782da653..c14bb604b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5406,7 +5406,7 @@ function BattleState:enemyAction(battler) return EnemyActionContext.call(self, requestedBattler, function() local delegated, captured local result = Runtime.call("battle.enemy_action", function(battle, who) - delegated, captured = EnemyMoveChoice.delegate(who or battle.enemy, battle:vanillaEnemyAction(who)) + delegated, captured = EnemyMoveChoice.delegate(battle, who or battle.enemy, battle:vanillaEnemyAction(who)) return delegated end, self, battler) return EnemyMoveChoice.normalize(self.data, battler, result, delegated, captured) diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index 4131cbeb3..0244e6009 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -1,7 +1,10 @@ -- Public GBA move choices are action records, not the learned move slot. -- A separate action can change its move/target without rewriting party data; -- its PP still belongs to the selected native slot (including Pressure). +local Context = require("src.battle.EnemyActionContext") local M = {} +local nativeViews = setmetatable({}, { __mode = "k" }) +local publicReferences = setmetatable({}, { __mode = "k" }) local slots = setmetatable({}, { __mode = "k" }) local function shallow(row) local out = {} @@ -82,24 +85,59 @@ end function M.exhausted(action) return slots[action] ~= nil and action.pp <= 0 end -function M.delegate(battler, action) - if - type(action) ~= "table" - or action.special - or action == battler.charging - or action == battler.thrashMove - or action == battler.rageMove - then +function M.nativeView(action) + return nativeViews[action] +end +function M.rememberReference(action, key, public, native) + local refs = publicReferences[action] + if not refs then + refs = {} + publicReferences[action] = refs + end + refs[key] = { public = public, native = native } +end +function M.publicReference(action, key) + local ref = publicReferences[action] and publicReferences[action][key] + if ref then + return ref.public, action[key] == ref.native, true + end +end +function M.delegate(battle, battler, action) + if type(action) ~= "table" or action.special then return action end + local slotIndex + for i, slot in ipairs(battler.curMoves or {}) do + if M.slot(action) == slot then + slotIndex = i + break + end + end + local function describe(value) + if value.id ~= nil then + local known, requested = pcall(Context.publicId, battle, battler) + if not known then + return value + end -- legacy native AI queries may use a detached actor + nativeViews[value] = { + slot = slotIndex, + battler = battle:isDouble() and (requested or 1) or requested ~= nil and 1 or nil, + double = battle:isDouble(), + } + end + return value + end + if action == battler.charging or action == battler.thrashMove or action == battler.rageMove then + return describe(action) + end for i, slot in ipairs(battler.curMoves or {}) do if action == slot then local copy = shallow(action) copy.slot = i - return copy, { raw = action, before = shallow(copy), owner = battler } + return describe(copy), { raw = action, before = shallow(copy), owner = battler } end end - return action + return describe(action) end function M.normalize(data, battler, value, delegated, captured) if captured and value == delegated then diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index c442412b9..c7cf75d1a 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -1,4 +1,5 @@ local Api3Records = require("src.mods.Api3Records") +local EnemyMoveChoice = require("src.battle.EnemyMoveChoice") -- 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") @@ -141,6 +142,7 @@ local function array(t, label, max) end function R.new(loader, name, generation, api3) + local portableEnemyAction = api3 and generation == 3 and name == "battle.enemy_action" local ids = Ids.new(loader) local portable = api3 and Api3Records or nil local function readStatus(value) @@ -292,6 +294,18 @@ function R.new(loader, name, generation, api3) return out end local out = shallow(raw) + local nativeAction = portableEnemyAction and kind == "action" and EnemyMoveChoice.nativeView(raw) + if nativeAction then + local def = moveDef(raw.id) + local number = def and tonumber(def.index or def.numId) + if not number and type(raw.id) == "number" then number = raw.id end + out.kind = raw.kind or "move" + out.move = raw.move ~= nil and raw.move or (raw.struggle and "STRUGGLE" or number) + out.slot = raw.slot or nativeAction.slot + out.user = raw.user or "enemy" + out.battler = raw.battler ~= nil and raw.battler or nativeAction.battler + out.targetType = raw.targetType or (nativeAction.double and (tonumber(def and def.target) or 0)) or nil + end for k, v in pairs(raw) do local role = recordRoles[k] if name == "world.interacted" and k == "target" then @@ -303,7 +317,9 @@ function R.new(loader, name, generation, api3) if name == "pokemon.before_give" and k == "ctx" then role = nil end - if k == "status" then + if nativeAction and k == "move" then + out[k] = v + elseif k == "status" then out[k] = readStatus(v) elseif k == "move" and type(v) ~= "table" then out[k] = read("moves", v) @@ -332,6 +348,16 @@ function R.new(loader, name, generation, api3) end end end + if portableEnemyAction and kind == "action" then + for _, key in ipairs({ "move", "id" }) do + local authored, unchanged, known = EnemyMoveChoice.publicReference(raw, key) + if unchanged then out[key] = authored + elseif known and key == "move" then + local def = moveDef(raw[key]) + out[key] = def and tonumber(def.index or def.numId) or raw[key] + end + end + end return out end view = function(raw, kind) @@ -653,6 +679,14 @@ function R.new(loader, name, generation, api3) if v ~= before[k] then stage(raw, k, restored) end + elseif portableEnemyAction and kind == "action" and not value.special + and (k == "move" or k == "id") and v ~= before[k] then + local known = type(v) == "string" and ids:resolve("moves", v) + local converted = known and native("moves", v, old) or v + stage(raw, k, converted) + pending[#pending + 1] = function() + EnemyMoveChoice.rememberReference(raw, k, v, converted) + end elseif kind == "action" and name == "battle.enemy_action" and generation == 3 and not value.special and (k == "move" or k == "id") and v ~= before[k] and (type(v) == "number" or type(v) == "string") then diff --git a/tests/enemy_actions/fixture.lua b/tests/enemy_actions/fixture.lua index 5f57774ce..8e857a99a 100644 --- a/tests/enemy_actions/fixture.lua +++ b/tests/enemy_actions/fixture.lua @@ -7,6 +7,7 @@ function M.new(root, backend, version, api, format) V.set(version) local gen = V.generation() local rows = { + { "STRUGGLE", 165, 50, "NORMAL", "RECOIL_EFFECT", "EFFECT_RECOIL_HIT", 48 }, { "MIMIC", 102, 0, "NORMAL", "MIMIC_EFFECT", "EFFECT_MIMIC", 82 }, { "SCRATCH", 10, 40, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, { "FLY", 19, 70, "FLYING", "FLY_EFFECT", "EFFECT_FLY", 155 }, @@ -56,6 +57,9 @@ function M.new(root, backend, version, api, format) } end end + if backend == "gen2" then + data.moves.STRUGGLE = data.moves[ids.STRUGGLE] + 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/enemy_actions/records.lua b/tests/enemy_actions/records.lua new file mode 100644 index 000000000..f90100e4c --- /dev/null +++ b/tests/enemy_actions/records.lua @@ -0,0 +1,281 @@ +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified") + local T, observations = f.T, {} + local used = {} + local stopUsed = f.e.api.events:on("battle.move_used", function(event) + used[#used + 1] = event.move.id + end) + local function actorAt(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function setup(double, move) + local c = f.fresh(move or "TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if double then + if backend == "gen1" then + local S, Copy = require("src.core.game3.battle.state"), require("src.mods.Merge").deepCopy + b.battlers[2] = S.makeBattler(Copy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(Copy(b.enemy.mon), "enemy", { id = 3 }) + else + local S, Copy = require("src.battle.BattleState"), require("src.mods.Merge").deepCopy + b:placeBattler(2, S.makeBattler(f.data, Copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, Copy(b.enemy.mon), false)) + end + end + if backend == "gen2" then + b:syncSides() + end + if not move then + for id = 0, double and 3 or 1 do + local actor = actorAt(b, id) + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33, 10 }, { 10, 7 } + else + actor.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 7 } } + actor.mon.moves = actor.curMoves + end + end + end + return c, b + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(id and actorAt(b, id) or nil) + end + local function execute(b, id, action) + local user, target = actorAt(b, id), actorAt(b, 0) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + user, + target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, action) + end + end + local function pp(actor) + if backend == "gen1" then + return { unpack(actor.mon.pp) } + end + local rows = {} + for i, move in ipairs(actor.curMoves) do + rows[i] = move.pp + end + return rows + end + local function snapshot(action) + return { + kind = action.kind, + move = action.move, + slot = action.slot, + user = action.user, + battler = action.battler, + targetType = action.targetType, + } + end + for _, double in ipairs({ false, true }) do + for _, spec in ipairs({ { name = "default" }, { name = "left", id = 1 }, { name = "right", id = 3 } }) do + if double or spec.id ~= 3 then + for _, mode in ipairs({ "forward", "edit", "copy-edit", "slot" }) do + local c, b = setup(double) + local seen + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + seen = snapshot(action) + if mode == "copy-edit" then + local copied = snapshot(action) + copied.move = 10 + return copied + end + if mode == "edit" and action.kind == "move" and action.move == 33 then + action.move = 10 + end + if mode == "slot" then + action.slot = 2 + end + return action + end) + local action = choose(b, spec.id) + local expected = { + kind = "move", + move = 33, + slot = 1, + user = "enemy", + battler = double and (spec.id or 1) or spec.id, + targetType = double and 0 or nil, + } + local label = backend + .. "/" + .. version + .. "/" + .. tostring(double) + .. "/" + .. spec.name + .. "/" + .. mode + T.same(seen, expected, label .. " exposes common native move fields") + used = {} + execute(b, spec.id or 1, action) + T.eq( + used[#used], + (mode == "edit" or mode == "copy-edit") and "SCRATCH" or "TACKLE", + label .. " executes the selected move through the public event producer" + ) + local want = (mode == "copy-edit" or mode == "slot") and { 10, 6 } or { 9, 7 } + T.same(pp(actorAt(b, spec.id or 1)), want, label .. " preserves native selected-slot PP semantics") + T.check(c.hp() < 400, label .. " actually executes the action") + observations[#observations + 1] = { + double = double, + case = spec.name, + mode = mode, + seen = seen, + pp = pp(actorAt(b, spec.id or 1)), + hit = c.hp() < 400, + used = used[#used], + } + end + end + end + end + for _, row in ipairs({ + { field = "move", value = 10 }, + { field = "move", value = "SCRATCH" }, + { field = "id", value = 10 }, + { field = "id", value = "SCRATCH" }, + }) do + local _, b = setup(false) + f.e.choose(function() + return { [row.field] = row.value } + end) + local seen + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + seen = { kind = action.kind, field = action[row.field], fieldType = type(action[row.field]) } + return action + end, 100) + local action = choose(b, 1) + stop() + T.same( + seen, + { field = row.value, fieldType = type(row.value) }, + "nested authored record preserves reference form" + ) + used = {} + execute(b, 1, action) + T.eq(used[#used], "SCRATCH", "nested authored record executes the requested move") + T.same(pp(actorAt(b, 1)), { 10, 6 }, "nested authored record selects the actual learned slot") + observations[#observations + 1] = + { case = "authored-" .. row.field, value = row.value, seen = seen, pp = pp(actorAt(b, 1)) } + end + for _, value in ipairs({ 10, "SCRATCH" }) do + local _, b = setup(false) + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + action.move = value + return action + end) + local seen + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + seen = action.move + return action + end, 100) + local action = choose(b, 1) + stop() + T.eq(seen, value, "nested native edit preserves the submitted move reference") + used = {} + execute(b, 1, action) + T.eq(used[#used], "SCRATCH", "nested native edit executes the requested move") + T.same(pp(actorAt(b, 1)), { 9, 7 }, "nested native edit retains the original selected PP slot") + observations[#observations + 1] = + { case = "native-edit", value = value, seen = seen, used = used[#used], pp = pp(actorAt(b, 1)) } + end + local _, paired = setup(false) + f.e.choose(function() + return { kind = "move", move = 10, id = "TACKLE" } + end) + local both + local stopBoth = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + both = { kind = action.kind, move = action.move, id = action.id } + return action + end, 100) + local pairedChoice = choose(paired, 1) + stopBoth() + T.same( + both, + { kind = "move", move = 10, id = "TACKLE" }, + "both authored references retain independent forms" + ) + used = {} + execute(paired, 1, pairedChoice) + T.eq(used[#used], "SCRATCH", "explicit move takes precedence over the legacy id alias") + T.same(pp(paired.enemy), { 10, 6 }, "two-reference record charges the actual selected move slot") + observations[#observations + 1] = + { case = "both-references", seen = both, pp = pp(paired.enemy), used = used[#used] } + local charging, b = setup(false, "FLY") + local seen = {} + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + seen[#seen + 1] = snapshot(action) + return action + end) + execute(b, 1, choose(b)) + if backend == "gen2" then + b.enemy.chargeReady = true + end + execute(b, 1, choose(b)) + T.same(seen, { + { kind = "move", move = 19, slot = 1, user = "enemy" }, + { kind = "move", move = 19, slot = 1, user = "enemy" }, + }, "charge continuation exposes common move fields") + T.eq(charging.pp(), 9, "public charge record retains once-only PP ownership") + T.check(charging.hp() < 400, "public charge record actually releases") + observations[#observations + 1] = + { case = "charge", seen = seen, pp = charging.pp(), hit = charging.hp() < 400 } + local exhausted, exhaustedBattle = setup(false) + if backend == "gen1" then + exhaustedBattle.enemy.mon.pp = { 0, 0 } + else + for _, move in ipairs(exhaustedBattle.enemy.curMoves) do + move.pp = 0 + end + end + local struggleView + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + struggleView = snapshot(action) + return action + end) + local struggle = choose(exhaustedBattle) + T.same( + struggleView, + { kind = "move", move = "STRUGGLE", user = "enemy" }, + "native exhausted fallback retains the reference's symbolic Struggle move" + ) + used = {} + execute(exhaustedBattle, 1, struggle) + T.eq(used[#used], "STRUGGLE", "exhausted fallback executes actual Struggle") + T.same(pp(exhaustedBattle.enemy), { 0, 0 }, "Struggle cannot spend exhausted party PP") + T.check(exhausted.hp() < 400, "Struggle fallback actually hits") + observations[#observations + 1] = { + case = "struggle", + seen = struggleView, + pp = pp(exhaustedBattle.enemy), + used = used[#used], + hit = exhausted.hp() < 400, + } + stopUsed() + f.close() + return observations +end +return M diff --git a/tests/engine/portable_enemy_move_records.lua b/tests/engine/portable_enemy_move_records.lua new file mode 100644 index 000000000..7285d5cdb --- /dev/null +++ b/tests/engine/portable_enemy_move_records.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.records").run(".", "gen2", version) +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 1, "native") + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + local detached = require("src.mods.Merge").deepCopy(b.enemy) + detached.curMoves = { { id = f.ids.SCRATCH, pp = 7 } } + detached.mon.moves = detached.curMoves + f.e.choose(function(nextAction, battle) + return nextAction(battle, detached) + end) + local action = b:enemyAction() + T.eq(action.id, f.ids.SCRATCH, "native cloned-actor query remains supported") + b:executeAction(b.enemy, b.player, action) + T.eq(b.enemy.curMoves[1].pp, 9, "native cloned-actor choice spends caller PP") + T.eq(detached.curMoves[1].pp, 7, "native cloned-actor query does not mutate queried PP") + f.close() +end +for _, version in ipairs({ "firered", "emerald" }) do + for _, field in ipairs({ "move", "id" }) do + for _, changeNative in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.enemy.curMoves[2] = { id = f.ids.SCRATCH, pp = 7 } + f.e.choose(function() + return { [field] = "SCRATCH" } + end) + local nativeValue, publicValue + local stopNative = f.result.loader.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + nativeValue = action[field] + if changeNative then + action[field] = f.ids.TACKLE + end + return action + end, 100, "native-result-fixture") + local stopPublic = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + publicValue = action[field] + return action + end, 200) + local action = b:enemyAction() + T.eq(nativeValue, f.ids.SCRATCH, "mixed native hook retains physical move representation") + local expected = changeNative and (field == "move" and 33 or "TACKLE") or "SCRATCH" + T.eq(publicValue, expected, "public view retains authored form or observes native rewrite") + b:executeAction(b.enemy, b.player, action) + T.eq( + b.enemy.curMoves[1].pp, + changeNative and 9 or 10, + "mixed result preserves native rewrite PP ownership" + ) + T.eq(b.enemy.curMoves[2].pp, changeNative and 7 or 6, "mixed result spends actual selected PP") + stopPublic() + stopNative() + f.close() + end + end +end +T.finish("portable enemy move records") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index cc53a14ac..c72b94319 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 4e13e1745..51666e0c2 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "arguments" then +if mode == "records" then + rows = dofile(root .. "/tests/enemy_actions/records.lua").run(root, backend, version) +elseif mode == "arguments" then rows = dofile(root .. "/tests/enemy_actions/arguments.lua").run(root, backend, version) else rows = From f84dfb5664d28f47b763e4cf701be1cb5c82556a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:57:04 +0200 Subject: [PATCH 16/90] Honor portable GBA action targets through native execution --- .github/workflows/enemy-action-contract.yml | 1 + docs/portable-enemy-move-records.md | 3 +- docs/portable-enemy-targets.md | 62 +++ src/battle/BattleState.lua | 36 +- src/battle/EffectRegistry.lua | 5 +- src/battle/Targeting.lua | 96 +++++ tests/enemy_actions/fixture.lua | 5 +- tests/enemy_actions/targets.lua | 399 +++++++++++++++++++ tests/engine/portable_enemy_targets.lua | 74 ++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 11 files changed, 672 insertions(+), 15 deletions(-) create mode 100644 docs/portable-enemy-targets.md create mode 100644 tests/enemy_actions/targets.lua create mode 100644 tests/engine/portable_enemy_targets.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 072e67009..9a73105d7 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -8,6 +8,7 @@ on: - 'tests/enemy_actions/**' - 'tests/engine/portable_enemy_action_*.lua' - 'tests/engine/portable_enemy_move_records.lua' + - 'tests/engine/portable_enemy_targets.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/portable-enemy-move-records.md b/docs/portable-enemy-move-records.md index d0f5bb84f..dc67f73bb 100644 --- a/docs/portable-enemy-move-records.md +++ b/docs/portable-enemy-move-records.md @@ -57,7 +57,8 @@ native-edited references, charge continuations, and the real Struggle fallback. The existing move-result and selector contract cases remain in the checker. This is the native **move** record contract, not complete action/AI parity. -Scores, selected double-battle targets, item/switch/watch records, other native +Mod-selected double-battle targets are covered by the follow-up +[target contract](portable-enemy-targets.md). Scores, item/switch/watch records, other native engine fields, damage amounts, RNG traces and full gameplay remain separate. No score or target-selection result is fabricated. No Gen1 source change or publication is required. The feature is stacked on #191. diff --git a/docs/portable-enemy-targets.md b/docs/portable-enemy-targets.md new file mode 100644 index 000000000..61079b5c2 --- /dev/null +++ b/docs/portable-enemy-targets.md @@ -0,0 +1,62 @@ +# Portable GBA enemy action targets + +On FireRed/Emerald, a mod-selected `battle.enemy_action` move now honors its +positional `target` field through the native Gen2 planner/executor path: + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, battle, battlerId) + local action = nextAction(battle, battlerId) + if action.kind == "move" then action.target = 2 end + return action +end) +``` + +Positions are 0 player-left, 1 enemy-left, 2 player-right, 3 enemy-right. +The numeric choice stays on the action until execution. The engine resolves +the current occupant, so a replacement is hit and an absent selected slot +redirects through native targeting. A move's explicit target takes precedence +over an older UI choice. Existing native actor handles are also accepted; +malformed hints use the ordinary safe fallback. + +The move's target policy remains authoritative. Self moves stay on the user, +random-target moves retain their existing selection path, both-foe moves +start with the chosen foe, and all-other-battler moves execute in positional +order. Targeted Electric attacks respect active Lightning Rod holders and +the actual turn order, without later-generation immunity/stat boosts. +Native AI scoring/automatic target selection is not fabricated. + +GBA Pressure PP costs count all relevant holders for both-foe, all-other and +opponents-field moves. Self-targeting does not charge the user's own Pressure. +Spread execution spends PP once and emits `battle.move_used` once per action; +actual hit targets remain available through `battle.damage_dealt`. The GBA +zero-power default path no longer publishes a zero-damage hit it did not deal; +explicit custom/fixed damage paths and earlier-generation events are retained. + +## Verification + +```sh +luajit tests/engine/portable_enemy_targets.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The identical public consumer and shared fixture run against unchanged +read-only Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The reference uses +its actual native planner and move executor; the host additionally drains +actual single-turn double-battle queues. Cases cover all four selected +positions from both opposing flanks, mutation/replacement, move/damage event +cardinality and order, PP, field/self/spread policies, Pressure, Lightning Rod +priority, cached targets and replaced/absent occupants. The fixture declares +native-shaped synthetic move rows to isolate target policies, not real-game +Spikes or other unimplemented field effects. + +Gen2-only regressions cover malformed hints, legacy handles and unchanged +random-target behavior/target RNG against a control with identical combat +stats. Existing move-result, selector and record contracts stay in the runner. + +This validates selected action targets, not complete targeting/gameplay parity. +Follow Me and other unported native effect producers, automatic AI target +scoring, full random-target/native RNG parity, called moves, link ordering, +damage amounts and item/switch/watch actions remain separate work. No Gen1 +source change or publication is required. The feature is stacked on #194. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c14bb604b..3ace455e5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5632,6 +5632,10 @@ function BattleState:resolveTurn(playerAction) self.phase = "messages" self.afterQueue = "menu" + if GameVersion.generation() == 3 then + self._actionTargetOrder = {} + for _, entry in ipairs(order) do self._actionTargetOrder[#self._actionTargetOrder + 1] = entry[1].position end + end for _, entry in ipairs(order) do self:act(function() @@ -5661,7 +5665,7 @@ function BattleState:resolveTurn(playerAction) end self:act(function() -- the collected choices belong to the turn that just ran, not the next - self.pendingActions, self.chosenTargets = nil, nil + self.pendingActions, self.chosenTargets, self._actionTargetOrder = nil, nil, nil self:endOfTurn() end) end @@ -7492,6 +7496,14 @@ function BattleState:executeAction(user, target, action) if not (user and user.mon) then return end if user.mon.hp <= 0 then return end if not action then return end + local explicitTarget = false + if GameVersion.generation() == 3 and self:isDouble() and not action.special then + local selected + selected, explicitTarget = Targeting.fromAction(self, action) + if explicitTarget then + target = Targeting.redirect(self, user, nil, selected)[1] or self:defaultTargetFor(user) + end + end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to @@ -7648,8 +7660,9 @@ function BattleState:executeAction(user, target, action) local targetKind = tonumber(def.target) spreadAnimation = targetKind == Targeting.BOTH or targetKind == Targeting.FOES_AND_ALLY - local chosen = (self.chosenTargets or {})[user.position] or target - local got = Targeting.resolve(self, user, def, chosen) + local chosen = explicitTarget and target or (self.chosenTargets or {})[user.position] or target + local got = explicitTarget and Targeting.actionTargets(self, user, def, chosen) + or Targeting.resolve(self, user, def, chosen) if #got > 0 then list = got end end end @@ -7658,7 +7671,7 @@ function BattleState:executeAction(user, target, action) if one and one.mon and (one.mon.hp or 0) > 0 then -- only the first announces; the rest are the same move landing again self:performMove(user, one, action, i > 1, - spreadAnimation and animationPlayed) + spreadAnimation and animationPlayed, GameVersion.generation() == 3 and i > 1) -- FireRed's Cmd_attackanimation increments animTargetsHit only when an -- animation actually survives the no-effect/failure path. If the -- first target cancels it, a later spread target is still allowed to @@ -7940,7 +7953,7 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8018,7 +8031,8 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- PRESSURE costs the ATTACKER an extra point for every move aimed at its -- holder. Two per use, not double the total -- which is why it is -- subtracted here rather than multiplying the cost. - local cost = 1 + Abilities.extraPP(target) + local cost = GameVersion.generation() == 3 and Targeting.gen3PPCost(self, user, target, move) + or 1 + Abilities.extraPP(target) moveInst.pp = math.max(0, moveInst.pp - cost) end @@ -8039,10 +8053,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end - Runtime.emit("battle.move_used", { - battle = self, user = user, target = target, move = move, - isCalled = isCalled or false, - }) + if not spreadContinuation then + Runtime.emit("battle.move_used", { + battle = self, user = user, target = target, move = move, + isCalled = isCalled or false, + }) + end -- PROTECT stops a status move too, and it is the only check that has to -- happen before the effect record is consulted at all. diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 0f731f0aa..c56d4524b 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -348,7 +348,10 @@ function EffectRegistry.runDamaging(battle, ctx, record) elseif info.typeMult < 10 then battle:sayNext(Strings("It's not very\neffective...")) end - if Runtime.wants("battle.damage_dealt") then + -- GBA's zero-power default script never runs the HP-damage command. + -- Keep explicit custom/fixed damage and the earlier generations' events. + if Runtime.wants("battle.damage_dealt") and (dealt > 0 or move.power ~= 0 + or (record and record.chooseDamage) or not require("src.core.GameVersion").isGen3()) then Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, damage = dealt, crit = info.crit, typeMult = info.typeMult, diff --git a/src/battle/Targeting.lua b/src/battle/Targeting.lua index 6f1c9b876..166317b86 100644 --- a/src/battle/Targeting.lua +++ b/src/battle/Targeting.lua @@ -280,4 +280,100 @@ function Targeting.redirect(battle, user, move, target) return { pool[1] } end +-- Public GBA actions retain positional targets until execution, so a switch +-- between selection and execution resolves the current occupant of that slot. +function Targeting.fromAction(battle, action) + if type(action) ~= "table" or action.target == nil then + return nil, false + end + local ref, id = action.target + if type(ref) == "number" then + id = ref + elseif type(ref) == "table" then + id = ref == battle.player and 0 or ref == battle.enemy and 1 or ref.position + end + if type(id) ~= "number" or id % 1 ~= 0 or id < 0 or id > 3 then + return nil, true + end + if id == 0 then + return battle.player, true + end + if id == 1 then + return battle.enemy, true + end + return battle:battlerAt(id), true +end + +function Targeting.actionTargets(battle, user, move, chosen) + local kind = kindOf(move) + local abilities = require("src.battle.Abilities") + -- Gen 3 Lightning Rod redirects Electric selected attacks; it does not + -- grant the later-generation immunity or Special Attack boost. + if + (kind == Targeting.SELECTED or kind == Targeting.DEPENDS or kind == Targeting.USER_OR_SELECTED) + and move.type == "ELECTRIC" + and abilities.of(chosen) ~= "LIGHTNING_ROD" + then + local rank = {} + for i, id in ipairs(battle._actionTargetOrder or { 0, 1, 2, 3 }) do + rank[id] = i + end + local best, bestRank + for _, actor in battle:activeBattlers() do + if + actor.isPlayer ~= user.isPlayer + and actor.mon + and (actor.mon.hp or 0) > 0 + and abilities.of(actor) == "LIGHTNING_ROD" + then + local n = rank[actor.position] or 5 + if not bestRank or n < bestRank then + best, bestRank = actor, n + end + end + end + if best then + chosen = best + end + end + local targets = Targeting.resolve(battle, user, move, chosen) + if kind == Targeting.BOTH then + for i, actor in ipairs(targets) do + if actor == chosen and i > 1 then + table.remove(targets, i) + table.insert(targets, 1, actor) + break + end + end + elseif kind == Targeting.FOES_AND_ALLY then + table.sort(targets, function(a, b) + return a.position < b.position + end) + end + return targets +end + +function Targeting.gen3PPCost(battle, user, target, move) + local extra = require("src.battle.Abilities").extraPP + local kind, cost = kindOf(move), 1 + if + battle:isDouble() + and (kind == Targeting.BOTH or kind == Targeting.FOES_AND_ALLY or kind == Targeting.OPPONENTS_FIELD) + then + for _, actor in battle:activeBattlers() do + if + actor ~= user + and actor.mon + and (actor.mon.hp or 0) > 0 + and (kind == Targeting.FOES_AND_ALLY or actor.isPlayer ~= user.isPlayer) + then + cost = cost + extra(actor) + end + end + elseif target and target ~= user and kind ~= Targeting.USER then + cost = cost + extra(target) + end + return cost +end + return Targeting diff --git a/tests/enemy_actions/fixture.lua b/tests/enemy_actions/fixture.lua index 8e857a99a..c14cb664f 100644 --- a/tests/enemy_actions/fixture.lua +++ b/tests/enemy_actions/fixture.lua @@ -1,5 +1,5 @@ local M = {} -function M.new(root, backend, version, api, format) +function M.new(root, backend, version, api, format, configure) local consumer = "portable_enemy_action" local T = require("tests.modkit") local V = require("src.core.GameVersion") @@ -60,6 +60,9 @@ function M.new(root, backend, version, api, format) if backend == "gen2" then data.moves.STRUGGLE = data.moves[ids.STRUGGLE] end + if configure then + configure(data, backend, ids, 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/enemy_actions/targets.lua b/tests/enemy_actions/targets.lua new file mode 100644 index 000000000..fdc157bb8 --- /dev/null +++ b/tests/enemy_actions/targets.lua @@ -0,0 +1,399 @@ +local M = {} +function M.run(root, backend, version) + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ + { "TARGET_BOTH", 600, 8 }, + { "TARGET_ALL", 601, 32 }, + { "TARGET_SELF", 602, 16 }, + { "TARGET_FIELD", 603, 64 }, + { "THUNDERSHOCK", 84, 0 }, + }) do + local name, number, target = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + local electric = name == "THUNDERSHOCK" + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = 0, + power = target == 64 and 0 or 40, + type = electric and 13 or 0, + accuracy = 100, + pp = 20, + secondaryChance = 0, + target = target, + priority = 0, + flags = 51, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + name = name, + index = number, + power = target == 64 and 0 or 40, + type = electric and "ELECTRIC" or "NORMAL", + pp = 20, + accuracy = 100, + accuracyRaw = 255, + target = target, + effect = "NO_ADDITIONAL_EFFECT", + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, observations, events, damages = f.T, {}, {}, {} + local stopEvent = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damages[#damages + 1] = e.target.mon.nickname + end) + local function at(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function fresh(moveName) + local c = f.fresh("TACKLE", { enemy = true }) + local b, copy = c.battle, require("src.mods.Merge").deepCopy + b.double = true + if backend == "gen1" then + local S = require("src.core.game3.battle.state") + b.battlers[2] = S.makeBattler(copy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(copy(b.enemy.mon), "enemy", { id = 3 }) + else + local S = require("src.battle.BattleState") + b:placeBattler(2, S.makeBattler(f.data, copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, copy(b.enemy.mon), false)) + b:syncSides() + end + local number = ({ + TACKLE = 33, + TARGET_BOTH = 600, + TARGET_ALL = 601, + TARGET_SELF = 602, + TARGET_FIELD = 603, + THUNDERSHOCK = 84, + })[moveName] + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname = "P" .. id + a.mon.hp = 400 + if backend == "gen1" then + a.mon.moves, a.mon.pp = { number }, { 20 } + a.mon.speed, a.mon.ability, a.ability = 400 - id * 50, 0, 0 + else + a.curMoves = { { id = f.ids[moveName], pp = 20 } } + a.mon.moves = a.curMoves + a.curStats.speed, a.abilityOverride = 400 - id * 50, false + end + end + return b + end + local function ability(b, id, name) + local a = at(b, id) + if backend == "gen1" then + a.ability, a.mon.ability = name == "PRESSURE" and 46 or 31, name == "PRESSURE" and 46 or 31 + else + a.abilityOverride = name + end + end + local function pp(a) + return backend == "gen1" and a.mon.pp[1] or a.curMoves[1].pp + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(at(b, id)) + end + local function execute(b, id, action) + if backend == "gen1" then + local Engine, Adapter = + require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + local ad = Adapter.new(b) + local chosen = { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + chosen[id] = action + local rows = Engine.planTurnActions(b, ad, chosen) + for _, row in ipairs(rows) do + if row.kind == "move" then + Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + b:executeAction(at(b, id), at(b, 0), action) + end + end + local specs = {} + for _, user in ipairs({ 1, 3 }) do + for target = 0, 3 do + specs[#specs + 1] = { + name = "selected-" .. target, + move = "TACKLE", + user = user, + target = target, + victims = { "P" .. target }, + cost = 1, + } + end + specs[#specs + 1] = { + name = "both-right-first", + move = "TARGET_BOTH", + user = user, + target = 2, + victims = { "P2", "P0" }, + cost = 1, + } + specs[#specs + 1] = { + name = "all", + move = "TARGET_ALL", + user = user, + target = 2, + victims = user == 1 and { "P0", "P2", "P3" } or { "P0", "P1", "P2" }, + cost = 1, + } + specs[#specs + 1] = { + name = "self", + move = "TARGET_SELF", + user = user, + target = user, + victims = { "P" .. user }, + cost = 1, + } + end + for _, spec in ipairs({ + { + name = "cached-target", + move = "TACKLE", + user = 1, + target = 2, + cached = true, + victims = { "P2" }, + cost = 1, + }, + { + name = "pressure-selected", + move = "TACKLE", + user = 1, + target = 2, + pressure = { 2 }, + victims = { "P2" }, + cost = 2, + }, + { + name = "pressure-both", + move = "TARGET_BOTH", + user = 1, + target = 2, + pressure = { 0, 2 }, + victims = { "P2", "P0" }, + cost = 3, + }, + { + name = "pressure-all", + move = "TARGET_ALL", + user = 3, + target = 2, + pressure = { 0, 1, 2, 3 }, + victims = { "P0", "P1", "P2" }, + cost = 4, + }, + { + name = "pressure-self", + move = "TARGET_SELF", + user = 1, + target = 1, + pressure = { 1 }, + victims = { "P1" }, + cost = 1, + }, + { + name = "field", + move = "TARGET_FIELD", + user = 1, + target = 2, + pressure = { 0, 2 }, + victims = { "P2" }, + cost = 3, + }, + { + name = "lightning-rod", + move = "THUNDERSHOCK", + user = 1, + target = 0, + rod = { 2 }, + victims = { "P2" }, + cost = 1, + }, + }) do + specs[#specs + 1] = spec + end + for _, spec in ipairs(specs) do + for _, mode in ipairs({ "replace", "delegate" }) do + local b = fresh(spec.move) + if spec.cached then + if backend == "gen1" then + b.moveTarget = { [1] = 0 } + else + b.chosenTargets = { [1] = at(b, 0) } + end + end + for _, id in ipairs(spec.pressure or {}) do + ability(b, id, "PRESSURE") + end + for _, id in ipairs(spec.rod or {}) do + ability(b, id, "LIGHTNING_ROD") + end + f.e.choose(function(nextAction, ...) + if mode == "delegate" then + local a = nextAction(...) + a.target = spec.target + return a + end + return { kind = "move", move = spec.move, target = spec.target } + end) + events, damages = {}, {} + local action = choose(b, spec.user) + T.eq(action.target, spec.target, "target remains positional until execution") + execute(b, spec.user, action) + local targets = {} + for _, e in ipairs(events) do + targets[#targets + 1] = e.target + end + local label = backend .. "/" .. version .. "/" .. spec.name .. "/" .. spec.user .. "/" .. mode + T.same( + targets, + { spec.victims[1] }, + label .. " emits one native move event for the actual first target" + ) + T.same( + damages, + spec.move == "TARGET_FIELD" and {} or spec.victims, + label .. " actual native damage events use selected targets/order" + ) + T.eq(pp(at(b, spec.user)), 20 - spec.cost, label .. " spends native PP cost exactly once") + observations[#observations + 1] = { + case = spec.name, + user = spec.user, + mode = mode, + target = action.target, + events = events, + damages = damages, + pp = pp(at(b, spec.user)), + } + end + end + -- A new occupant must be resolved after the choice has already been made. + for _, remove in ipairs({ false, true }) do + local b = fresh("TACKLE") + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + local action = choose(b, 1) + if remove then + if backend == "gen1" then + b.absent[0] = true + b.battlers[0] = nil + else + b:placeBattler(0, nil) + end + else + local copy = require("src.mods.Merge").deepCopy(b.player.mon) + copy.nickname = "P0_NEXT" + if backend == "gen1" then + b.battlers[0] = require("src.core.game3.battle.state").makeBattler(copy, "player", { id = 0 }) + else + b:placeBattler(0, require("src.battle.BattleState").makeBattler(f.data, copy, true)) + end + end + events, damages = {}, {} + execute(b, 1, action) + local expected = remove and "P2" or "P0_NEXT" + T.eq(events[1] and events[1].target, expected, "selected slot resolves the living current occupant") + T.same(damages, { expected }, "switched/absent slot reaches native actual damage") + T.eq(pp(at(b, 1)), 19, "late resolution spends one owned PP") + observations[#observations + 1] = { + case = remove and "absent" or "replacement", + events = events, + damages = damages, + pp = pp(at(b, 1)), + } + end + + -- Both engines' own planner/executor path, with actual host queue draining. + for _, lightning in ipairs({ false, true }) do + local b = fresh(lightning and "THUNDERSHOCK" or "TACKLE") + if lightning then + ability(b, 0, "LIGHTNING_ROD") + ability(b, 2, "LIGHTNING_ROD") + if backend == "gen1" then + b.player.mon.speed, at(b, 2).mon.speed = 700, 800 + else + b.player.curStats.speed, at(b, 2).curStats.speed = 700, 800 + end + end + f.e.choose(function(_, _, id) + return { + move = lightning and "THUNDERSHOCK" or "TACKLE", + target = lightning and (id == 1 and 3 or 1) or (id == 1 and 2 or 0), + } + end) + events, damages = {}, {} + if backend == "gen1" then + local E, A = require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + local ad = A.new(b) + local rows = E.planTurnActions( + b, + ad, + { [0] = { kind = "watch" }, [2] = { kind = "watch" }, [1] = choose(b, 1), [3] = choose(b, 3) } + ) + for _, row in ipairs(rows) do + if row.kind == "move" then + E.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = { special = "recharge" } } + b:resolveTurn({ special = "recharge" }) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native turn queue drains") + end + local expected = lightning and { "P2", "P2" } or { "P2", "P0" } + local usedTargets = {} + for _, e in ipairs(events) do + usedTargets[#usedTargets + 1] = e.target + end + T.same( + usedTargets, + expected, + "actual double planner honors both selected targets and Lightning Rod order" + ) + T.same(damages, expected, "actual double planner reaches both native damage targets") + T.eq(pp(at(b, 1)), 19, "left enemy queue action spends its own PP once") + T.eq(pp(at(b, 3)), 19, "right enemy queue action spends its own PP once") + observations[#observations + 1] = { + case = lightning and "queue-lightning-order" or "queue-selected", + events = events, + damages = damages, + pp = { pp(at(b, 1)), pp(at(b, 3)) }, + } + end + stopDamage() + stopEvent() + f.close() + return observations +end +return M diff --git a/tests/engine/portable_enemy_targets.lua b/tests/engine/portable_enemy_targets.lua new file mode 100644 index 000000000..127e62328 --- /dev/null +++ b/tests/engine/portable_enemy_targets.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.targets").run(".", "gen2", version) +end +local function fresh(f) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local S, copy = require("src.battle.BattleState"), require("src.mods.Merge").deepCopy + b:placeBattler(2, S.makeBattler(f.data, copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, copy(b.enemy.mon), false)) + b:syncSides() + for _, actor in b:activeBattlers() do + actor.mon.hp, actor.abilityOverride, actor.mon.item = 400, false, nil + actor.curStats = { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + actor.mon.stats = actor.curStats + end + return b +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 1, "native") + for _, row in ipairs({ + { true }, + { false }, + { "P2" }, + { {} }, + { -1 }, + { 4 }, + { 1.5 }, + { math.huge }, + { 0 / 0 }, + { function() end }, + }) do + local b = fresh(f) + f.e.choose(function() + return { move = "TACKLE", target = row[1] } + end) + local ok, err = pcall(function() + b:executeAction(b.enemy, b.player, b:enemyAction()) + end) + T.check(ok, "malformed action target retains safe native fallback: " .. tostring(err)) + T.check(b.player.mon.hp < 400, "malformed action target still executes against ordinary foe") + T.eq(b:battlerAt(2).mon.hp, 400, "malformed action target cannot select arbitrary actor") + end + local b = fresh(f) + f.e.choose(function() + return { move = "TACKLE", target = b:battlerAt(2) } + end) + b:executeAction(b.enemy, b.player, b:enemyAction()) + T.check(b:battlerAt(2).mon.hp < 400, "legacy native target handle still selects its actual actor") + T.eq(b.player.mon.hp, 400, "legacy target handle preserves the other foe") + local runs = {} + for _, explicit in ipairs({ false, true }) do + local randomBattle = fresh(f) + randomBattle.data.moves[f.ids.TACKLE].target = 4 + local draws = {} + randomBattle.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return lo + end + f.e.choose(function() + return { move = "TACKLE", target = explicit and 2 or nil } + end) + randomBattle:executeAction(randomBattle.enemy, randomBattle.player, randomBattle:enemyAction()) + runs[#runs + 1] = { + draws = draws, + hp = { randomBattle.player.mon.hp, randomBattle:battlerAt(2).mon.hp }, + pp = randomBattle.enemy.curMoves[1].pp, + } + end + T.same(runs[1], runs[2], "random-target policy ignores a chosen hint without adding target RNG") + f.close() +end +T.finish("portable enemy targets") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index c72b94319..e34f49d0e 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 51666e0c2..a91e831b4 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "records" then +if mode == "targets" then + rows = dofile(root .. "/tests/enemy_actions/targets.lua").run(root, backend, version) +elseif mode == "records" then rows = dofile(root .. "/tests/enemy_actions/records.lua").run(root, backend, version) elseif mode == "arguments" then rows = dofile(root .. "/tests/enemy_actions/arguments.lua").run(root, backend, version) From c944bc0f05fb08556b6878936bff6ecaad63529b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:26:00 +0200 Subject: [PATCH 17/90] Implement native GBA Follow Me targeting and round lifetime --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gba-follow-me.md | 75 ++++ src/battle/BattleState.lua | 1 + src/battle/Gen3FollowMe.lua | 51 +++ src/battle/Gen3MoveEffects.lua | 17 +- src/battle/Targeting.lua | 6 + tests/enemy_actions/follow_me.lua | 362 +++++++++++++++++++ tests/engine/gen3_follow_me.lua | 18 + tests/follow_me/public_effect.lua | 57 +++ tests/goldens/fixture_fingerprint.txt | 2 +- tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 12 files changed, 586 insertions(+), 10 deletions(-) create mode 100644 docs/native-gba-follow-me.md create mode 100644 src/battle/Gen3FollowMe.lua create mode 100644 tests/enemy_actions/follow_me.lua create mode 100644 tests/engine/gen3_follow_me.lua create mode 100644 tests/follow_me/public_effect.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 9a73105d7..27a68aad7 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -9,6 +9,7 @@ on: - 'tests/engine/portable_enemy_action_*.lua' - 'tests/engine/portable_enemy_move_records.lua' - 'tests/engine/portable_enemy_targets.lua' + - 'tests/engine/gen3_follow_me.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gba-follow-me.md b/docs/native-gba-follow-me.md new file mode 100644 index 000000000..513355d2e --- /dev/null +++ b/docs/native-gba-follow-me.md @@ -0,0 +1,75 @@ +# Native GBA Follow Me + +FireRed/Emerald's imported effect 172 (`FOLLOW_ME_EFFECT`, move 266) now has +an ordinary native effect record. A successful move marks its user's side +with a round-local position. Selected opposing attacks resolve the current +living occupant of that position, including public API-3 `battle.enemy_action` +choices. Mod code does not need an engine-specific redirection hook. + +The position is shared by a side: the last successful caster wins. A slot +replacement inherits the position for the rest of the round. A fainted +occupant does not draw attacks, but its active timer still suppresses +Lightning Rod, matching the covered native reference behavior. Live Follow +Me takes precedence over Lightning Rod. Self and spread moves retain their +own target policy. The native effect consumes normal owned PP, executes at +its imported priority and remains subject to ordinary status interruption. +Substitute and Protect do not block the imported self effect. + +Both sides reset when the next turn is planned, before turn-started observers +and action execution. Residual collection does not prematurely clear the +flag. The producer and targeting state are limited to GBA; existing GB/DS +rules remain separate. + +## Validation + +```sh +luajit tests/engine/gen3_follow_me.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The 180 focused checks cover both GBA profiles, both sides/flanks, successive +casters, default and mod-selected targets, fainting and replacement, +Lightning Rod precedence, self/spread/single policies, sleep refusal, +Substitute/Protect, residual and next-turn lifetime, real priority queues, +PP and non-GBA guards. Actual move/damage events verify execution targets. +The shared fixtures use synthetic extractor-shaped move rows, not a fresh +cartridge import or full gameplay capture. + +The same API-3 consumer and fixture match unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: 24 engine workers and 354 paired +observations, including prior choice/argument/record/target contracts. +Running the new FireRed cases on unchanged #195 fails 29 assertions. + +This feature stacks on #195. The local quick run passes 82/91 engine suites, +3/3 SDK suites and B30, versus 80/90 on that prerequisite. Exact FAIL-line +Counters show no additions: only the two fingerprint failure lines disappear; +all 25 remaining lines across nine inherited suites are identical. Registering +only this new effect changes the prerequisite fingerprint from +`41e69caf4afa35ab` to `128fc071ae7249cd`; removing just that entry and invalidating +the fingerprint cache restores the former. The committed golden pins the new +native registry. Baseline repair PRs remain separate. + +## Canonical effect composition + +The canonical public effect adapter is a separate dependency (#121). When +both changes are integrated, public `effect = "FOLLOW_ME"` writes select the +real native handler automatically; no generated contract-table edit is needed. +An explicit composed proof loads a real API-3 mod, patches a move through +`mod.content.moves`, reads the canonical effect back, then casts it and checks +that a real mod-selected attack is redirected with owned PP: + +```sh +luajit tests/follow_me/public_effect.lua +``` + +That proof requires both features and deliberately fails if either is absent. +The standalone feature's normal suite does not pretend to include #121. +In the combined checkout the old missing-handler assertion in +`tests/engine/unified_move_effects.lua` must be updated to assert availability. + +Remaining native effect producers (Helping Hand, Magic Coat, Snatch, +Imprison and called/terrain effects), random-target/RNG parity, AI scoring, +link/called-move execution and full damage/gameplay parity are separate work. +No Gen1 source change or publication is required. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3ace455e5..af344478d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5509,6 +5509,7 @@ function BattleState:resolveTurn(playerAction) -- the bench to replace it; `actionFor` asks each surviving slot for its own local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) self.turnCount = (self.turnCount or 0) + 1 + if GameVersion.isGen3() then require("src.battle.Gen3FollowMe").clear(self) end -- BATTLE ARENA: arenaTurnCounter if self.arena then require("src.battle.Gen3Arena").turnStarted(self) end -- TURN-SCOPED FLAGS, cleared before anybody moves. REVENGE reads diff --git a/src/battle/Gen3FollowMe.lua b/src/battle/Gen3FollowMe.lua new file mode 100644 index 000000000..cc1c6481c --- /dev/null +++ b/src/battle/Gen3FollowMe.lua @@ -0,0 +1,51 @@ +-- Round-local GBA Follow Me state. The native flag names a position: a new +-- occupant of that slot inherits redirection until the next turn is planned. +local Version = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local F = {} +local function position(battle, user) + return user == battle.player and 0 or user == battle.enemy and 1 or user.position +end +function F.apply(battle, user) + if not Version.isGen3() then + return { Strings("But, it failed!") } + end + local id = position(battle, user) + local side = battle.sides and battle.sides[user.isPlayer and 1 or 2] + if not side or type(id) ~= "number" or id % 1 ~= 0 or id < 0 or id > 3 then + return { Strings("But, it failed!") } + end + side._gen3FollowMePosition = id + local name = user.isPlayer and user.name or ("Enemy " .. user.name) + return { battle:romText("STRINGID_PKMNCENTERATTENTION", "%s became the\ncenter of attention!", name) } +end +function F.center(battle, user) + if not Version.isGen3() then + return nil, false + end + local side = battle.sides and battle.sides[user.isPlayer and 2 or 1] + local id = side and side._gen3FollowMePosition + if id == nil then + return nil, false + end + local holder + if id == 0 then + holder = battle.player + elseif id == 1 then + holder = battle.enemy + else + holder = battle:battlerAt(id) + end + if holder and holder.mon and (holder.mon.hp or 0) > 0 and holder.isPlayer ~= user.isPlayer then + return holder, true + end + -- The native timer still suppresses Lightning Rod even if its holder has + -- fainted. Only the next turn's planning resets the flag. + return nil, true +end +function F.clear(battle) + for _, side in ipairs(battle.sides or {}) do + side._gen3FollowMePosition = nil + end +end +return F diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index c69724dce..11332ba97 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -24,11 +24,9 @@ -- The merge is non-destructive: a name MoveEffects already defines wins, so -- this file can never quietly redefine an effect that already worked. -- --- WHAT IS NOT HERE, and deliberately -- eight effects, and each for a --- reason rather than for want of time: +-- Remaining effects need separate native producers: -- --- FOLLOW ME and HELPING HAND do nothing outside a DOUBLE battle, and this --- engine fights one Pokemon a side. +-- HELPING HAND needs its own double-battle ally action modifier. -- -- MAGIC COAT, SNATCH and IMPRISON all reach INTO another battler's move -- as it is being resolved -- bouncing it back, stealing it, forbidding it @@ -39,9 +37,8 @@ -- effect is chosen the same way. The terrain half of that is real work -- in the world layer, not the battle one. -- --- Those eight still warn once and fall through to plain damage, which is --- the honest behaviour for an effect that is not implemented. Everything --- else Emerald has runs. +-- Unimplemented effects still warn once and fall through to plain damage. +-- FOLLOW ME is registered below with round-scoped native GBA targeting. local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -119,6 +116,12 @@ local function confuse(battle, target, pierceSub) return { Strings("%s\nbecame confused!", displayName(target)) } end +-- Imported GBA effect 172 is already named FOLLOW_ME_EFFECT. Register the +-- ordinary record so native imports and the public effect adapter agree. +P.FOLLOW_ME_EFFECT = function(battle, user) + return require("src.battle.Gen3FollowMe").apply(battle, user) +end + -- --------------------------------------------------------------------- -- STAT-STAGE MOVES: the whole reason a Hoenn team is built the way it is -- --------------------------------------------------------------------- diff --git a/src/battle/Targeting.lua b/src/battle/Targeting.lua index 166317b86..8e7f8933d 100644 --- a/src/battle/Targeting.lua +++ b/src/battle/Targeting.lua @@ -179,6 +179,10 @@ end -- battle passes the one foe it always passed and gets it straight back. function Targeting.resolve(battle, user, move, chosen) local k = kindOf(move) + if k == Targeting.SELECTED then + local center = require("src.battle.Gen3FollowMe").center(battle, user) + if center then return { center } end + end -- BLACK AND WHITE: FOLLOW ME / RAGE POWDER draw every single-target move -- from the other side to themselves this turn (src/battle/Gen5MoveRules.lua) @@ -307,11 +311,13 @@ end function Targeting.actionTargets(battle, user, move, chosen) local kind = kindOf(move) local abilities = require("src.battle.Abilities") + local _, followMeActive = require("src.battle.Gen3FollowMe").center(battle, user) -- Gen 3 Lightning Rod redirects Electric selected attacks; it does not -- grant the later-generation immunity or Special Attack boost. if (kind == Targeting.SELECTED or kind == Targeting.DEPENDS or kind == Targeting.USER_OR_SELECTED) and move.type == "ELECTRIC" + and not followMeActive and abilities.of(chosen) ~= "LIGHTNING_ROD" then local rank = {} diff --git a/tests/enemy_actions/follow_me.lua b/tests/enemy_actions/follow_me.lua new file mode 100644 index 000000000..6386e32fc --- /dev/null +++ b/tests/enemy_actions/follow_me.lua @@ -0,0 +1,362 @@ +local M = {} +function M.run(root, backend, version) + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ + { "FOLLOW_ME", 266, 172, 0, 16, 3 }, + { "THUNDERSHOCK", 84, 0, 40, 0, 0 }, + { "TARGET_BOTH", 600, 0, 40, 8, 0 }, + }) do + local name, number, effect, power, target, priority = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = effect, + power = power, + target = target, + priority = priority, + type = number == 84 and 13 or 0, + accuracy = 100, + pp = 20, + flags = 0, + secondaryChance = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + name = name, + index = number, + gen3Effect = effect, + protectAffected = false, + effect = number == 266 and "FOLLOW_ME_EFFECT" or "NO_ADDITIONAL_EFFECT", + power = power, + target = target, + priority = priority, + type = number == 84 and "ELECTRIC" or "NORMAL", + accuracy = 100, + accuracyRaw = 255, + pp = 20, + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, moves, hits = f.T, {}, {}, {} + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.target.mon.nickname + end) + local function at(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if backend == "gen1" then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, who) + if backend == "gen1" then + b.battlers[id] = who + else + b:placeBattler(id, who) + end + end + local function fresh(double) + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = double + if double then + put(b, 2, actor(copy(b.player.mon), 2)) + put(b, 3, actor(copy(b.enemy.mon), 3)) + end + if backend == "gen2" then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + if backend == "gen1" then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 266, 33, 84, 600 }, { 20, 10, 10, 10 }, { 20, 10, 10, 10 } + a.ability, a.mon.ability, a.mon.speed = 0, 0, 400 - id * 50 + else + a.curMoves = { + { id = f.ids.FOLLOW_ME, pp = 20 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.THUNDERSHOCK, pp = 10 }, + { id = f.ids.TARGET_BOTH, pp = 10 }, + } + a.mon.moves, a.abilityOverride = a.curMoves, false + a.curStats.speed = 400 - id * 50 + end + end + return b + end + local function pp(b, id, slot) + local a = at(b, id) + return backend == "gen1" and a.mon.pp[slot] or a.curMoves[slot].pp + end + local function rod(b, id) + local a = at(b, id) + if backend == "gen1" then + a.ability, a.mon.ability = 31, 31 + else + a.abilityOverride = "LIGHTNING_ROD" + end + end + local function cast(b, id) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 266, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, id), at(b, id), at(b, id).curMoves[1]) + end + end + local function attack(b, id, target, name) + name = name or "TACKLE" + local slot = name == "FOLLOW_ME" and 1 or name == "THUNDERSHOCK" and 3 or name == "TARGET_BOTH" and 4 or 2 + f.e.choose(function() + return { move = name, target = target } + end) + if backend == "gen1" then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slot, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + local action = id % 2 == 0 and at(b, id).curMoves[slot] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = target and at(b, target) } + end + b:executeAction(at(b, id), at(b, target or (id % 2 == 0 and 1 or 0)), action) + end + end + local specs = { + { name = "left", casts = { 0 }, user = 1, target = 2, expected = "P0" }, + { name = "right", casts = { 2 }, user = 1, target = 0, expected = "P2" }, + { name = "right-attacker", casts = { 0 }, user = 3, target = 2, expected = "P0" }, + { name = "default-target", casts = { 2 }, user = 1, expected = "P2" }, + { name = "last-caster", casts = { 0, 2 }, user = 1, target = 0, expected = "P2" }, + { name = "last-caster-reversed", casts = { 2, 0 }, user = 1, target = 2, expected = "P0" }, + { + name = "lightning-priority", + casts = { 0 }, + user = 1, + target = 2, + move = "THUNDERSHOCK", + rod = 2, + expected = "P0", + }, + { name = "enemy-center-left", casts = { 1 }, user = 0, target = 3, expected = "P1" }, + { name = "enemy-center-right", casts = { 3 }, user = 2, target = 1, expected = "P3" }, + { + name = "self-not-redirected", + casts = { 0 }, + user = 1, + target = 1, + move = "FOLLOW_ME", + expected = "P1", + status = true, + }, + { name = "both-sides", casts = { 0, 3 }, user = 1, target = 2, expected = "P0" }, + { name = "fainted-caster", casts = { 0 }, user = 1, target = 2, faint = 0, expected = "P2" }, + { + name = "fainted-timer-before-rod", + casts = { 0 }, + user = 1, + target = 3, + faint = 0, + move = "THUNDERSHOCK", + rod = 2, + expected = "P3", + }, + { name = "replaced-caster-slot", casts = { 0 }, user = 1, target = 2, replace = 0, expected = "P0_NEXT" }, + { + name = "spread-not-redirected", + casts = { 0 }, + user = 1, + target = 2, + move = "TARGET_BOTH", + expected = "P2", + spread = true, + }, + { name = "sleep-refusal", casts = { 0 }, user = 1, target = 2, expected = "P2", sleep = true }, + { name = "substitute-protect", casts = { 0 }, user = 1, target = 2, expected = "P0", protected = true }, + { name = "single", casts = { 0 }, user = 1, target = 0, expected = "P0", single = true }, + } + for _, spec in ipairs(specs) do + local b = fresh(not spec.single) + moves, hits = {}, {} + if spec.sleep then + b.player.mon.status, b.player.sleepTurns = "SLP", 5 + end + if spec.protected then + b.player.substituteHP, b.player.protecting = 40, true + b.enemy.protecting = true + end + for _, id in ipairs(spec.casts) do + cast(b, id) + T.eq( + pp(b, id, 1), + spec.sleep and 20 or 19, + backend .. "/" .. spec.name .. " actual Follow Me PP follows move execution" + ) + end + if spec.protected then + b.player.substituteHP, b.player.protecting, b.enemy.protecting = nil, nil, nil + end + if spec.rod then + rod(b, spec.rod) + end + if spec.faint then + at(b, spec.faint).mon.hp = 0 + if backend == "gen1" then + b.absent[spec.faint] = true + end + end + if spec.replace then + local mon = require("src.mods.Merge").deepCopy(at(b, spec.replace).mon) + mon.nickname = "P0_NEXT" + put(b, spec.replace, actor(mon, spec.replace)) + end + moves, hits = {}, {} + attack(b, spec.user, spec.target, spec.move) + local label = backend .. "/" .. version .. "/" .. spec.name + T.eq(moves[1] and moves[1].target, spec.expected, label .. " native target honors Follow Me") + T.same( + hits, + spec.status and {} or spec.spread and { "P2", "P0" } or { spec.expected }, + label .. " actual damage reaches native targets" + ) + local slot = spec.move == "FOLLOW_ME" and 1 or spec.move == "THUNDERSHOCK" and 3 or spec.spread and 4 or 2 + T.eq( + pp(b, spec.user, slot), + spec.move == "FOLLOW_ME" and 19 or 9, + label .. " actual attack spends own PP once" + ) + rows[#rows + 1] = { case = spec.name, moves = moves, hits = hits, pp = pp(b, spec.user, slot) } + end + for _, residual in ipairs({ false, true }) do + local b = fresh(true) + moves, hits = {}, {} + cast(b, 0) + if residual then + if backend == "gen1" then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + moves, hits = {}, {} + attack(b, 1, 2) + T.same(hits, { "P0" }, "Follow Me remains active through the current round's residuals") + local first = moves + if backend == "gen1" then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + ) + else + f.e.choose(function(_, _, id) + return { move = "TACKLE", target = id == 1 and 2 or 0 } + end) + b.queue, b.pendingActions = {}, { [2] = b:battlerAt(2).curMoves[2] } + b:resolveTurn(b.player.curMoves[2]) + end + moves, hits = {}, {} + attack(b, 1, 2) + T.same(hits, { "P2" }, "native next-turn planning clears the expired Follow Me position") + T.eq(pp(b, 1, 2), 8, "two real attacks consume two PP across round boundaries") + rows[#rows + 1] = { + case = residual and "residual-expiry" or "turn-expiry", + before = first, + after = moves, + hits = hits, + pp = pp(b, 1, 2), + } + end + + local b = fresh(true) + moves, hits = {}, {} + if backend == "gen1" then + b.player.mon.speed, b.enemy.mon.speed, at(b, 3).mon.speed = 50, 900, 800 + else + b.player.curStats.speed, b.enemy.curStats.speed, at(b, 3).curStats.speed = 50, 900, 800 + end + f.e.choose(function() + return { move = "TACKLE", target = 2 } + end) + moves, hits = {}, {} + if backend == "gen1" then + local C, E, A = + require("src.core.game3.battle.commands"), + require("src.core.game3.battle.engine"), + require("src.core.game3.battle.adapter") + local ad = A.new(b) + local actions = E.planTurnActions(b, ad, { + [0] = C.playerAction(b, 1, 1, 0, 0), + [2] = { kind = "watch" }, + [1] = C.enemyAction(b, 1), + [3] = C.enemyAction(b, 3), + }) + for _, a in ipairs(actions) do + if a.kind == "move" then + E.resolveMove(a.user, a.target, a.move, a.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = { special = "recharge" } } + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "real Follow Me turn queue completes") + end + T.same(hits, { "P0", "P0" }, "native Follow Me priority beats both faster foes") + T.eq(moves[1] and moves[1].move, "FOLLOW_ME", "native priority executes the real Follow Me producer first") + T.eq(pp(b, 0, 1), 19, "queued Follow Me spends one caster PP") + T.same({ pp(b, 1, 2), pp(b, 3, 2) }, { 9, 9 }, "both redirected queued attacks spend owned PP") + rows[#rows + 1] = + { case = "priority-queue", moves = moves, hits = hits, pp = { pp(b, 0, 1), pp(b, 1, 2), pp(b, 3, 2) } } + stopHit() + stopMove() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_follow_me.lua b/tests/engine/gen3_follow_me.lua new file mode 100644 index 000000000..eafe64a2e --- /dev/null +++ b/tests/engine/gen3_follow_me.lua @@ -0,0 +1,18 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.follow_me").run(".", "gen2", version) +end +local V = require("src.core.GameVersion") +local F = require("src.battle.Gen3FollowMe") +local previous = V.get() +for _, version in ipairs({ "red", "gold", "platinum", "black" }) do + V.set(version) + local user = { isPlayer = true } + local battle = { player = user, sides = { {}, { _gen3FollowMePosition = 1 } } } + T.eq(F.center(battle, user), nil, version .. " ignores GBA targeting state") + F.apply(battle, user) + T.eq(battle.sides[1]._gen3FollowMePosition, nil, version .. " does not produce GBA state") +end +V.set(previous) +T.finish("native GBA Follow Me") diff --git a/tests/follow_me/public_effect.lua b/tests/follow_me/public_effect.lua new file mode 100644 index 000000000..33b1a8588 --- /dev/null +++ b/tests/follow_me/public_effect.lua @@ -0,0 +1,57 @@ +-- Composed proof: requires the canonical effect adapter from PR #121. +-- Run explicitly after integrating both PRs; it must fail without either one. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") + f.result.release() + local files = { + ["mods/probe/manifest.json"] = '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"entry":"main.lua","games":["gen3"]}', + ["mods/probe/main.lua"] = [[return function(mod) + assert(mod.content.moves:patch("TACKLE", { + effect = "FOLLOW_ME", power = 0, target = 16, priority = 3 + })) + mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect + mod.exports.hits = {} + mod.hooks:wrap("battle.enemy_action", function() + return { move = "SCRATCH", target = 2 } + end) + mod.events:on("battle.damage_dealt", function(e) + mod.exports.hits[#mod.exports.hits + 1] = e.target.mon.nickname + end) + end]], + } + local result = T.sdk.loadMods({ "mods/probe" }, { + data = f.data, + fs = T.sdk.memfs(files), + generation = 3, + dev = false, + }) + T.eq(#result.errors, 0, version .. " real API-3 effect mod loads") + local e = assert(result.loader.exports.probe) + T.eq(e.effect, "FOLLOW_ME", "public canonical read matches authored effect") + T.eq(f.data.moves[f.ids.TACKLE].effect, "FOLLOW_ME_EFFECT", "canonical patch selects real native handler") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 172, "canonical patch preserves native semantic number") + result.loader.game = f.game + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local right = require("src.battle.BattleState").makeBattler( + f.data, + require("src.mods.Merge").deepCopy(b.player.mon), + true + ) + b:placeBattler(2, right) + b:syncSides() + b.player.mon.nickname, right.mon.nickname = "CENTER", "ORIGINAL" + b.player.curMoves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + b.player.mon.moves, b.enemy.mon.moves = b.player.curMoves, b.enemy.curMoves + b:executeAction(b.player, b.player, b.player.curMoves[1]) + b:executeAction(b.enemy, right, b:enemyAction(b.enemy)) + T.same(e.hits, { "CENTER" }, "public effect patch redirects a real mod-selected native attack") + T.eq(b.player.curMoves[1].pp, 9, "canonical producer spends owned PP") + T.eq(b.enemy.curMoves[1].pp, 9, "redirected action spends caller PP") + result.release() +end +T.finish("composed public Follow Me effect") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..cc5080320 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +128fc071ae7249cd diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index e34f49d0e..a7d5ba67c 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index a91e831b4..173ca5491 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "targets" then +if mode == "follow_me" then + rows = dofile(root .. "/tests/enemy_actions/follow_me.lua").run(root, backend, version) +elseif mode == "targets" then rows = dofile(root .. "/tests/enemy_actions/targets.lua").run(root, backend, version) elseif mode == "records" then rows = dofile(root .. "/tests/enemy_actions/records.lua").run(root, backend, version) From 3edf7766ead0bc9bbaf10ec6ceedf36f5e9c3937 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:27:04 +0200 Subject: [PATCH 18/90] Load composed effect probes in a single real mod session --- tests/enemy_actions/fixture.lua | 4 ++- tests/follow_me/public_effect.lua | 45 ++++++++++++------------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/tests/enemy_actions/fixture.lua b/tests/enemy_actions/fixture.lua index c14cb664f..378a5a1e8 100644 --- a/tests/enemy_actions/fixture.lua +++ b/tests/enemy_actions/fixture.lua @@ -1,5 +1,5 @@ local M = {} -function M.new(root, backend, version, api, format, configure) +function M.new(root, backend, version, api, format, configure, entryOverride) local consumer = "portable_enemy_action" local T = require("tests.modkit") local V = require("src.core.GameVersion") @@ -69,6 +69,8 @@ function M.new(root, backend, version, api, format, configure) files["mods/" .. consumer .. "/" .. name] = f:read("*a") f:close() end + -- Effect-composition probes must patch content during this same load. + if entryOverride then files["mods/" .. consumer .. "/main.lua"] = entryOverride end 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/" .. consumer .. "/manifest.json"] = Json.encode(manifest) diff --git a/tests/follow_me/public_effect.lua b/tests/follow_me/public_effect.lua index 33b1a8588..4e6c2e198 100644 --- a/tests/follow_me/public_effect.lua +++ b/tests/follow_me/public_effect.lua @@ -4,36 +4,25 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") require("src.mods.UnifiedEffects") for _, version in ipairs({ "firered", "emerald" }) do - local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") - f.result.release() - local files = { - ["mods/probe/manifest.json"] = '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"entry":"main.lua","games":["gen3"]}', - ["mods/probe/main.lua"] = [[return function(mod) - assert(mod.content.moves:patch("TACKLE", { - effect = "FOLLOW_ME", power = 0, target = 16, priority = 3 - })) - mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect - mod.exports.hits = {} - mod.hooks:wrap("battle.enemy_action", function() - return { move = "SCRATCH", target = 2 } - end) - mod.events:on("battle.damage_dealt", function(e) - mod.exports.hits[#mod.exports.hits + 1] = e.target.mon.nickname - end) - end]], - } - local result = T.sdk.loadMods({ "mods/probe" }, { - data = f.data, - fs = T.sdk.memfs(files), - generation = 3, - dev = false, - }) - T.eq(#result.errors, 0, version .. " real API-3 effect mod loads") - local e = assert(result.loader.exports.probe) + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE", { + effect = "FOLLOW_ME", power = 0, target = 16, priority = 3 + })) + mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect + mod.exports.hits = {} + mod.hooks:wrap("battle.enemy_action", function() + return { move = "SCRATCH", target = 2 } + end) + mod.events:on("battle.damage_dealt", function(e) + mod.exports.hits[#mod.exports.hits + 1] = e.target.mon.nickname + end) + end]] + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", nil, entry) + local e = f.e + T.eq(#f.result.errors, 0, version .. " real API-3 effect mod loads") T.eq(e.effect, "FOLLOW_ME", "public canonical read matches authored effect") T.eq(f.data.moves[f.ids.TACKLE].effect, "FOLLOW_ME_EFFECT", "canonical patch selects real native handler") T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 172, "canonical patch preserves native semantic number") - result.loader.game = f.game local b = f.fresh("TACKLE", { enemy = true }).battle b.double = true local right = require("src.battle.BattleState").makeBattler( @@ -52,6 +41,6 @@ for _, version in ipairs({ "firered", "emerald" }) do T.same(e.hits, { "CENTER" }, "public effect patch redirects a real mod-selected native attack") T.eq(b.player.curMoves[1].pp, 9, "canonical producer spends owned PP") T.eq(b.enemy.curMoves[1].pp, 9, "redirected action spends caller PP") - result.release() + f.close() end T.finish("composed public Follow Me effect") From 8052c7916b12d57c425ce2fc92e6aa2ca3f9acc4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:27:30 +0200 Subject: [PATCH 19/90] Keep the composed effect probe within public content fields --- tests/follow_me/public_effect.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/follow_me/public_effect.lua b/tests/follow_me/public_effect.lua index 4e6c2e198..2ca274189 100644 --- a/tests/follow_me/public_effect.lua +++ b/tests/follow_me/public_effect.lua @@ -6,7 +6,7 @@ require("src.mods.UnifiedEffects") for _, version in ipairs({ "firered", "emerald" }) do local entry = [[return function(mod) assert(mod.content.moves:patch("TACKLE", { - effect = "FOLLOW_ME", power = 0, target = 16, priority = 3 + effect = "FOLLOW_ME", power = 0 })) mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect mod.exports.hits = {} @@ -17,7 +17,11 @@ for _, version in ipairs({ "firered", "emerald" }) do mod.exports.hits[#mod.exports.hits + 1] = e.target.mon.nickname end) end]] - local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", nil, entry) + local function configure(data, _, ids) + local move = data.moves[ids.TACKLE] + move.target, move.priority, move.protectAffected = 16, 3, false + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) local e = f.e T.eq(#f.result.errors, 0, version .. " real API-3 effect mod loads") T.eq(e.effect, "FOLLOW_ME", "public canonical read matches authored effect") From 7f93af93c8d883dd9e4d883196813f966c274c75 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:40:12 +0200 Subject: [PATCH 20/90] Use native GBA base-damage rounding --- .../workflows/gba-damage-base-contract.yml | 34 +++++ docs/gba-base-damage.md | 50 +++++++ src/battle/Damage.lua | 42 +++++- src/battle/rulesets/gen3_emerald.lua | 3 + tests/engine/gba_damage_base.lua | 35 +++++ tests/gba_damage_base/cases.lua | 139 ++++++++++++++++++ tests/gba_damage_base/worker.lua | 5 + tools/gba-damage-base-contract-check/check.py | 56 +++++++ 8 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/gba-damage-base-contract.yml create mode 100644 docs/gba-base-damage.md create mode 100644 tests/engine/gba_damage_base.lua create mode 100644 tests/gba_damage_base/cases.lua create mode 100644 tests/gba_damage_base/worker.lua create mode 100644 tools/gba-damage-base-contract-check/check.py diff --git a/.github/workflows/gba-damage-base-contract.yml b/.github/workflows/gba-damage-base-contract.yml new file mode 100644 index 000000000..75fe6e0f1 --- /dev/null +++ b/.github/workflows/gba-damage-base-contract.yml @@ -0,0 +1,34 @@ +name: ordinary GBA base damage +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gba-damage-base-${{ 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 GBA damage and older-profile retention + run: luajit tests/engine/gba_damage_base.lua + - name: Compare actual ordinary damage calculators + run: | + mkdir -p /tmp/gba-damage-base + python3 tools/gba-damage-base-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gba-damage-base + path: /tmp/gba-damage-base + if-no-files-found: ignore diff --git a/docs/gba-base-damage.md b/docs/gba-base-damage.md new file mode 100644 index 000000000..170ae5b9f --- /dev/null +++ b/docs/gba-base-damage.md @@ -0,0 +1,50 @@ +# Ordinary GBA base-damage contract + +The shared damage calculator still applied earlier generations' base-damage +steps to the GBA profile. This could quarter wide stats, cap a large base at +997, halve the attack stat for burn, and apply weather after the final +2. +These steps produce different GBA results at rounding boundaries. + +The GBA profile now selects `baseDamageStyle = "gba"`. It uses wide stats +without the older base cap and applies burn, screens, spread and weather to +the truncated base before +2. Its physical zero base becomes one; the special +base can remain zero. Solar Beam's bad-weather penalty follows the weather +type modifier, and weather does not apply the older minimum-one clamp. +Profiles without this opt-in retain their existing calculations. + +For example, at level 1 with power 2, equal stats and STAB, an ordinary +physical move deals 4 damage at roll 100 rather than 3. At level 30, Fire +power 35, equal stats, STAB and rain, the base produces 9 damage rather than +7. These are noncritical calculations; critical/type rounding is addressed +separately by PR #175. + +## Shared calculator checks + +The same case file invokes Gen1Recomp's actual GBA `Damage.calc` and +Gen2Recomped's actual `Damage.compute`. It uses equal typed session stats, +move records, status/screen/weather flags and pinned noncritical rolls. No +copy of either damage formula supplies the expected result. + +The matrix covers FireRed and Emerald, 18 contexts, six levels, four powers, +four Attack/Defense pairs and rolls 85/100: **4 workers / 6,912 paired numeric +observations**. Contexts include STAB, no STAB, burn, single/double screens, +spread with screens, resistance, immunity, rain/sun, and Solar Beam in rain, +sand and hail. Both calculators preserve the supplied Pokemon HP. +Six additional host checks preserve the older profiles' minimum base, +paired stat scaling and base cap. The host suite has **20,742 checks**. + +```sh +luajit tests/engine/gba_damage_base.lua +python3 tools/gba-damage-base-contract-check/check.py --gen1 /path/to/gen1 +``` + +The reference must be clean at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Each worker loads its own actual +engine modules. The workflow retains its comparison report. No ROM or +cartridge-derived assets are needed or included. + +This contract covers ordinary noncritical base damage with the listed +inputs. It does not establish every move effect, ability, held-item rule, +critical rule, ordinary turn/controller, or a full graphical playthrough. +The unfinished enemy switch/item hook and AI simulation wiring are outside +this PR. diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 2664e4b17..378efa722 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -285,6 +285,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) end local special = categoryOf(move) == "special" + local gbaBase = ruleset.baseDamageStyle == "gba" + local burnAfterBase, gbaScreen -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. @@ -323,7 +325,11 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- penalty into a bonus: it ignores the drop AND hits half again as hard if penalty and penalty.stat == atkStat and not attacker.hazeStatReset and not Abilities.ignoresBurnDrop(attacker) then - atk = math.max(1, math.floor(atk / penalty.div)) + if gbaBase and record.id == "BRN" then + burnAfterBase = penalty.div + else + atk = math.max(1, math.floor(atk / penalty.div)) + end end -- screens double the effective defense (crits bypass them). The -- confusion self-hit is the quirk case: HandleSelfConfusionDamage @@ -337,7 +343,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if screens then local up = (special and screens.lightScreen) or (not special and screens.reflect) - if up then + if up and gbaBase then + gbaScreen = true + elseif up then -- A SCREEN IS WEAKER WHEN TWO ARE STANDING BEHIND IT. -- -- 0806_9B48 (REFLECT) and 0806_9C88 (LIGHT SCREEN) check @@ -391,7 +399,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) end -- GetDamageVars .scaleStats: when either stat no longer fits a byte, -- BOTH are quartered (losing low bits), each bumped to at least 1 - if atk > 255 or dfn > 255 then + if not gbaBase and (atk > 255 or dfn > 255) then atk = math.max(1, math.floor(atk / 4)) dfn = math.max(1, math.floor(dfn / 4)) end @@ -420,6 +428,13 @@ function Damage.compute(ruleset, attacker, defender, move, opts) end d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50) + if gbaBase then + if burnAfterBase then d = math.floor(d / burnAfterBase) end + if gbaScreen then + d = opts.doublesScreens and 2 * math.floor(d / 3) or math.floor(d / 2) + end + end + -- A MOVE THAT HITS BOTH OF THEM DOES HALF TO EACH. -- -- The cartridge applies this inside CalculateBaseDamage -- after the burn @@ -432,7 +447,24 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d * ruleset.spreadNum / ruleset.spreadDen) end - d = math.min(d, 997) + 2 + if gbaBase then + -- Physical CalculateBaseDamage raises a zero base to one. Special + -- damage has no equivalent minimum before the final +2. + if not special then d = math.max(1, d) end + if special and not opts.typeless and opts.weather then + -- GBA applies each weather step to the base without Gen-2's minimum + -- one/clamp. Solar Beam's penalty follows the type-weather step. + local modifier = Weather.modifier(opts.weather, move.type) + if modifier then d = math.floor(d * modifier / 10) end + if (opts.weather == "RAIN" or opts.weather == "SAND" or opts.weather == "SANDSTORM" or opts.weather == "HAIL") + and (move.gen3Effect == 151 or move.effect == "SOLARBEAM_EFFECT") then + d = math.floor(d / 2) + end + end + d = d + 2 + else + d = math.min(d, 997) + 2 + end -- Held type boosters live inside BattleCommand_DamageCalc, before weather, -- STAB and effectiveness. This placement matters because every stage floors @@ -447,7 +479,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- as damage * modifier / 10. The confusion self-hit reaches -- CalculateDamage directly and never runs `stab`, so the typeless path -- skips weather along with STAB and the type chart. - if not opts.typeless and opts.weather then + if not gbaBase and not opts.typeless and opts.weather then d = Weather.applyModifier(d, opts.weather, move.type, move.effect) end diff --git a/src/battle/rulesets/gen3_emerald.lua b/src/battle/rulesets/gen3_emerald.lua index 3a7fc6011..360ab76d8 100644 --- a/src/battle/rulesets/gen3_emerald.lua +++ b/src/battle/rulesets/gen3_emerald.lua @@ -13,6 +13,9 @@ return { name = "gen3_emerald", + -- Gen 3 uses wide stats and applies burn/screens/weather before the +2. + baseDamageStyle = "gba", + -- The 1/256 miss is GONE. Gen 1 rolled rand(0..255) against -- floor(acc * 255 / 100), so a 100%-accurate move still failed on a roll of -- 255. Gen 3 checks accuracy as a percentage out of 100 and a move at 100% diff --git a/tests/engine/gba_damage_base.lua b/tests/engine/gba_damage_base.lua new file mode 100644 index 000000000..e6e0a196d --- /dev/null +++ b/tests/engine/gba_damage_base.lua @@ -0,0 +1,35 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gba_damage_base/cases.lua").run("gen2", version) +end +-- Profiles without the GBA opt-in retain their existing arithmetic. +local T = require("tests.modkit") +local D = require("src.battle.Damage") +local rules = require("src.battle.rulesets.gen1_faithful") +for _, generation in ipairs({ 1, 2 }) do + local data = { + constants = { generation = generation }, + items = {}, + type_chart = { types = { NORMAL = { category = "physical" } }, matchups = {} }, + } + require("src.battle.TypeChart").load(data) + local function oldDamage(level, power, attack, defense) + return D.compute( + rules, + { mon = { level = level, hp = 400 }, curStats = { attack = attack }, curTypes = { "NORMAL" } }, + { mon = { hp = 400 }, curStats = { defense = defense }, curTypes = { "NORMAL" } }, + { id = "BASE_RETENTION", power = power, type = "NORMAL" }, + { + forceCrit = false, + data = data, + rng = function() + return 255 + end, + } + ) + end + T.eq(oldDamage(1, 2, 50, 50), 3, "older minimum-base arithmetic retained") + T.eq(oldDamage(30, 35, 300, 50), 94, "older paired stat scaling retained") + T.eq(oldDamage(30, 250, 500, 10), 1498, "older base damage cap retained") +end +T.finish("ordinary GBA base damage and older-profile retention") diff --git a/tests/gba_damage_base/cases.lua b/tests/gba_damage_base/cases.lua new file mode 100644 index 000000000..e2fdf1b9b --- /dev/null +++ b/tests/gba_damage_base/cases.lua @@ -0,0 +1,139 @@ +-- Compare the actual ordinary GBA damage calculators with equal session inputs. +local C = {} +function C.run(backend, version) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local native = backend == "gen1" + local D = require(native and "src.core.game3.battle.damage" or "src.battle.Damage") + local rules = not native and require("src.battle.rulesets.gen3_emerald") + local ids = { NORMAL = 0, GHOST = 7, STEEL = 8, FIRE = 10, WATER = 11, GRASS = 12 } + local data = { + items = {}, + constants = { generation = 3 }, + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + }, + }, + } + for name, id in pairs(ids) do + data.type_chart.types[name] = { id = name, index = id, category = id <= 8 and "physical" or "special" } + end + if not native then + require("src.battle.TypeChart").load(data) + end + local contexts = { + { name = "normal-stab" }, + { name = "no-stab", userType = "FIRE" }, + { name = "burn", status = "BRN" }, + { name = "reflect", reflect = true }, + { name = "double-reflect", reflect = true, double = true }, + { name = "spread", spread = true }, + { name = "spread-reflect", spread = true, reflect = true, double = true }, + { name = "resistance", targetType = "STEEL" }, + { name = "immunity", targetType = "GHOST" }, + { name = "rain-fire", moveType = "FIRE", weather = "RAIN" }, + { name = "sun-fire", moveType = "FIRE", weather = "SUN" }, + { name = "rain-water", moveType = "WATER", weather = "RAIN" }, + { name = "sun-water", moveType = "WATER", weather = "SUN" }, + { name = "light-screen", moveType = "FIRE", lightScreen = true }, + { name = "double-light-screen", moveType = "FIRE", lightScreen = true, double = true }, + { name = "rain-solarbeam", moveType = "GRASS", weather = "RAIN", solar = true }, + { name = "sand-solarbeam", moveType = "GRASS", weather = "SAND", solar = true }, + { name = "hail-solarbeam", moveType = "GRASS", weather = "HAIL", solar = true }, + } + local function battler(level, attack, defense, kind, status) + if native then + return { + mon = { + level = level, + hp = 6000, + maxHp = 6000, + attack = attack, + defense = defense, + spAtk = attack, + spDef = defense, + speed = 50, + ability = 0, + status = status, + }, + type1 = ids[kind], + type2 = ids[kind], + ability = 0, + stages = { attack = 0, defense = 0, spAtk = 0, spDef = 0 }, + } + end + return { + mon = { level = level, hp = 6000, status = status }, + curStats = { attack = attack, defense = defense, spatk = attack, spdef = defense, special = attack }, + curTypes = { kind }, + stages = { attack = 0, defense = 0, spatk = 0, spdef = 0 }, + } + end + local rows = {} + for _, ctx in ipairs(contexts) do + for _, level in ipairs({ 1, 5, 29, 30, 50, 100 }) do + for _, power in ipairs({ 2, 35, 120, 250 }) do + for _, stats in ipairs({ { 50, 50 }, { 255, 257 }, { 300, 50 }, { 500, 10 } }) do + for _, roll in ipairs({ 85, 100 }) do + local kind = ctx.moveType or "NORMAL" + local user = battler(level, stats[1], 50, ctx.userType or kind, ctx.status) + local target = battler(level, 50, stats[2], ctx.targetType or "NORMAL") + local result + if native then + result = D.calc(user, target, { + id = "PORTABLE_BASE", + numId = 33, + power = power, + type = ids[kind], + effect = ctx.solar and 151 or 0, + }, { + forceCrit = false, + forceRoll = roll, + weather = ctx.weather, + spread = ctx.spread, + reflect = ctx.reflect, + lightScreen = ctx.lightScreen, + doubleScreens = ctx.double, + }) + else + result = D.compute(rules, user, target, { + id = "PORTABLE_BASE", + power = power, + type = kind, + effect = ctx.solar and "SOLARBEAM_EFFECT" or "NO_ADDITIONAL_EFFECT", + gen3Effect = ctx.solar and 151 or 0, + }, { + forceCrit = false, + data = data, + rng = function() + return roll + end, + weather = ctx.weather, + spread = ctx.spread, + screens = { reflect = ctx.reflect, lightScreen = ctx.lightScreen }, + doublesScreens = ctx.double, + }) + end + T.check(type(result) == "number" and result >= 0, "ordinary damage is nonnegative") + T.eq(user.mon.hp, 6000, "calculation cannot spend attacker HP") + T.eq(target.mon.hp, 6000, "calculation cannot spend target HP") + rows[#rows + 1] = { + context = ctx.name, + level = level, + power = power, + attack = stats[1], + defense = stats[2], + roll = roll, + damage = result, + } + end + end + end + end + end + return rows +end +return C diff --git a/tests/gba_damage_base/worker.lua b/tests/gba_damage_base/worker.lua new file mode 100644 index 000000000..7a2fdc5d0 --- /dev/null +++ b/tests/gba_damage_base/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/gba_damage_base/cases.lua").run(backend, version) +print("GBA_DAMAGE_BASE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("ordinary GBA base damage") diff --git a/tools/gba-damage-base-contract-check/check.py b/tools/gba-damage-base-contract-check/check.py new file mode 100644 index 000000000..107efa6c0 --- /dev/null +++ b/tools/gba-damage-base-contract-check/check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Compare ordinary GBA damage through both actual engine calculators, without ROMs.""" +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(): + 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("--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) + runs, differences = [], [] + for version in ["firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", source), ("gen2", host)]: + run = subprocess.run([args.lua, str(host / "tests/gba_damage_base/worker.lua"), str(host), + backend, version], cwd=engine, capture_output=True, text=True, timeout=30) + prefix = "GBA_DAMAGE_BASE " + lines = [line.removeprefix(prefix) for line in run.stdout.splitlines() if line.startswith(prefix)] + 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, "version": version, "observations": value}) + if len(observations[0]) != len(observations[1]): + differences.append({"version": version, "error": "observation count"}) + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + differences.append({"version": version, "row": index, "reference": left, "host": right}) + print("PASS" if observations[0] == observations[1] else "FAIL", version) + assert not git(source, "status", "--porcelain"), "reference changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "runs": runs, + "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + raise SystemExit(f"{len(differences)} differing ordinary GBA damage observations") + + +if __name__ == "__main__": + main() From 45f5b4e3386f704ab011ca3b6690aaa79f0bc425 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 21/90] 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/charge_required/fixture.lua | 364 ++++++++++++++++++ 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 +++ 15 files changed, 855 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/charge_required/fixture.lua 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 52f592613..759a8abca 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -143,3 +143,6 @@ 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. + +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 378efa722..daf125cc1 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -483,6 +483,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 @@ -577,9 +581,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 @@ -840,4 +844,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 360ab76d8..3df351346 100644 --- a/src/battle/rulesets/gen3_emerald.lua +++ b/src/battle/rulesets/gen3_emerald.lua @@ -35,6 +35,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/charge_required/fixture.lua b/tests/charge_required/fixture.lua new file mode 100644 index 000000000..88b0687e5 --- /dev/null +++ b/tests/charge_required/fixture.lua @@ -0,0 +1,364 @@ +local M = {} +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") + 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 + 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")) + files["mods/" .. consumer .. "/" .. name] = f:read("*a") + f:close() + end + 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/" .. consumer .. "/manifest.json"] = Json.encode(manifest) + if failing then + 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 + local result = T.sdk.loadMods( + noMod and {} or { "mods/" .. consumer }, + { 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[consumer], + 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/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 8200b8d5c01b22fe999ce9478ff6c03cb4e1dc8a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:42:12 +0200 Subject: [PATCH 22/90] Implement native GBA Helping Hand and shared damage modifier --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gba-helping-hand.md | 79 ++++ src/battle/BattleState.lua | 1 + src/battle/Damage.lua | 5 + src/battle/Gen3HelpingHand.lua | 28 ++ src/battle/Gen3MoveEffects.lua | 8 +- tests/enemy_actions/helping_hand.lua | 440 +++++++++++++++++++ tests/engine/gen3_helping_hand.lua | 20 + tests/goldens/fixture_fingerprint.txt | 2 +- tests/helping_hand/public_effect.lua | 55 +++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 12 files changed, 639 insertions(+), 6 deletions(-) create mode 100644 docs/native-gba-helping-hand.md create mode 100644 src/battle/Gen3HelpingHand.lua create mode 100644 tests/enemy_actions/helping_hand.lua create mode 100644 tests/engine/gen3_helping_hand.lua create mode 100644 tests/helping_hand/public_effect.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 27a68aad7..51d0f4484 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -10,6 +10,7 @@ on: - 'tests/engine/portable_enemy_move_records.lua' - 'tests/engine/portable_enemy_targets.lua' - 'tests/engine/gen3_follow_me.lua' + - 'tests/engine/gen3_helping_hand.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gba-helping-hand.md b/docs/native-gba-helping-hand.md new file mode 100644 index 000000000..7ef90586d --- /dev/null +++ b/docs/native-gba-helping-hand.md @@ -0,0 +1,79 @@ +# Native GBA Helping Hand + +Imported GBA effect 176 (`HELPING_HAND_EFFECT`, move 270) now registers an +ordinary primary effect. A successful use supports the user's current living +partner for the round. It fails in singles, without a partner, or if either +the user or the partner is already supported. Repeating it does not stack. +Ordinary status interruption and imported Protect flags remain authoritative. + +The shared damage calculator applies the native 1.5 multiplier after the +critical multiplier and before STAB/type rounding. Fixed damage is unchanged; +confusion self-damage uses its unmodified base. The support persists across +actions and residual collection until the next native turn-planning boundary. +A replacement battler does not inherit it. Other generations ignore the new +state. No mod-specific damage hook or separate port is needed for these paths. + +For example, level-30 Tackle with equal Attack/Defense, normal STAB and roll +100 deals 16 without support and 24 with support. The actual turn queue runs +the slow helper's imported priority-five move before faster ordinary attacks. + +## Dependencies and scope + +The feature builds on #196's common enemy-action fixture and native turn +integration, plus the existing #178 base-damage and #175 critical-order +corrections. Those two published patches are reused as prerequisite commits; +review the new implementation relative to `45f5b4e3386f704ab011ca3b6690aaa79f0bc425`. +Its prerequisite fixture is taken from #175, without importing unrelated +production changes from that historical stack. + +Canonical public `effect = "HELPING_HAND"` writes additionally use #121's +existing effect adapter. Registration enables its binding automatically; no +generated effect table is rewritten. The explicit composed proof requires +both features, patches content during one real API-3 load, and executes a +publicly selected right-flank enemy move with the supported numeric damage. + +This verifies the native producer and the shared ordinary damage path, +including fixed-damage and confusion exclusions. Dedicated set-damage/delayed +move paths (Spit Up, Beat Up, Future Sight/Doom Desire), their other existing +semantic differences, full held-item/ability ordering, called/link moves, +AI scoring and full gameplay/RNG parity remain separately auditable surfaces. +The ordinary calculator's boost is not a proof of those special executors. + +## Validation + +```sh +luajit tests/engine/gen3_helping_hand.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +# With the canonical effect adapter integrated: +luajit tests/helping_hand/public_effect.lua +``` + +The focused host suite passes **5,322 checks**. Cases exercise both GBA +profiles, every helper flank, actual selected attacks and public move/damage +events, critical/noncritical hits, STAB, physical/special/spread moves, +resistance/immunity, variation 85/100, unchanged fixed damage and confusion, +status/refusal/repetition gates, both round boundaries, replacement, reused +support, actual priority queues, PP and non-GBA guards. + +The same public consumer and shared fixture match unchanged read-only Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` in **28 isolated engine workers / +1,916 paired observations**. These include the prior choice, selector, record, +target and Follow Me cases. Shared sessions use equal stats and synthetic +extractor-shaped move rows; they do not claim a fresh cartridge import. +The unchanged prerequisite fails 391 assertions in the initial new FireRed +matrix. The existing GBA base-damage and critical-order paired checks pass. + +The full feature quick run passes **86/95** engine suites versus **85/94** on +the clean prerequisite. Its exact Counter of all **25 FAIL lines across nine +inherited suites** matches, with no additions. SDK 3/3 and B30 pass. Their +baseline repairs are already present in the combined fork. + +Adding only `HELPING_HAND_EFFECT` changes the native fixture digest from +`128fc071ae7249cd` to `032184f68094e9ca`; removing that entry and invalidating +the fingerprint cache restores the former. The new golden records this +intentional registry change. The combined checkout must likewise audit its +section pin and replace the former missing-handler assertion. + +Gen1 is a read-only reference. No Gen1 code change or publication is required. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index af344478d..87269b966 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5520,6 +5520,7 @@ function BattleState:resolveTurn(playerAction) b.hurtThisTurn = nil b.specialDamageTaken, b.physicalDamageTaken = nil, nil b.protecting, b.enduring = nil, nil + if GameVersion.isGen3() then b._gen3HelpingHand = nil end b.turnsOut = (b.turnsOut or 0) + 1 end end diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index daf125cc1..90bab419a 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -487,6 +487,11 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d * ruleset.critMultiplier) end + -- Native Helping Hand scales the calculated base after the critical + -- multiplier, before STAB/type rounding. Fixed damage and confusion's + -- typeless base path do not pass through this modifier. + if not opts.typeless then d = require("src.battle.Gen3HelpingHand").damage(attacker, d) end + local mult = 10 if not opts.typeless then -- STAB diff --git a/src/battle/Gen3HelpingHand.lua b/src/battle/Gen3HelpingHand.lua new file mode 100644 index 000000000..b780626d6 --- /dev/null +++ b/src/battle/Gen3HelpingHand.lua @@ -0,0 +1,28 @@ +-- Native GBA Helping Hand is a battler-local, turn-scoped modifier. +local Version = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local H = {} +local function name(actor) + return actor.isPlayer and actor.name or ("Enemy " .. actor.name) +end +function H.apply(battle, user) + local partner = Version.isGen3() and battle:partnerOf(user) + if + not partner + or not partner.mon + or (partner.mon.hp or 0) <= 0 + or user._gen3HelpingHand + or partner._gen3HelpingHand + then + return { Strings("But, it failed!") } + end + partner._gen3HelpingHand = true + return { battle:romText("STRINGID_PKMNREADYTOHELP", "%s is ready to\nhelp %s!", name(user), name(partner)) } +end +function H.damage(user, amount) + if Version.isGen3() and user._gen3HelpingHand then + return math.floor(amount * 15 / 10) + end + return amount +end +return H diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 11332ba97..55bcf9448 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -26,8 +26,6 @@ -- -- Remaining effects need separate native producers: -- --- HELPING HAND needs its own double-battle ally action modifier. --- -- MAGIC COAT, SNATCH and IMPRISON all reach INTO another battler's move -- as it is being resolved -- bouncing it back, stealing it, forbidding it -- -- which needs a resolution step performMove does not have. @@ -38,7 +36,7 @@ -- in the world layer, not the battle one. -- -- Unimplemented effects still warn once and fall through to plain damage. --- FOLLOW ME is registered below with round-scoped native GBA targeting. +-- FOLLOW ME and HELPING HAND are registered below with native GBA turn state. local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -122,6 +120,10 @@ P.FOLLOW_ME_EFFECT = function(battle, user) return require("src.battle.Gen3FollowMe").apply(battle, user) end +P.HELPING_HAND_EFFECT = function(battle, user) + return require("src.battle.Gen3HelpingHand").apply(battle, user) +end + -- --------------------------------------------------------------------- -- STAT-STAGE MOVES: the whole reason a Hoenn team is built the way it is -- --------------------------------------------------------------------- diff --git a/tests/enemy_actions/helping_hand.lua b/tests/enemy_actions/helping_hand.lua new file mode 100644 index 000000000..68947106c --- /dev/null +++ b/tests/enemy_actions/helping_hand.lua @@ -0,0 +1,440 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local extra = { + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5, "NORMAL", 0 }, + { "THUNDERSHOCK", 84, 0, "NO_ADDITIONAL_EFFECT", 40, 0, 0, "ELECTRIC", 13 }, + { "TARGET_BOTH", 600, 0, "NO_ADDITIONAL_EFFECT", 35, 8, 0, "NORMAL", 0 }, + { "DRAGON_RAGE", 82, 41, "SPECIAL_DAMAGE_EFFECT", 1, 0, 0, "DRAGON", 16 }, + } + local function configure(data, engine, ids, rom, names) + for _, row in ipairs(extra) do + local name, number, effect, handler, power, target, priority, kind, typeId = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = effect, + power = power, + target = target, + priority = priority, + type = typeId, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 51, + secondaryChance = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + index = number, + name = name, + gen3Effect = effect, + effect = handler, + power = power, + target = target, + priority = priority, + type = kind, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + fixedDamage = name == "DRAGON_RAGE" and 40 or nil, + } + end + end + if engine == "gen2" then + data.type_chart.types = {} + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + for name, id in pairs({ NORMAL = 0, GHOST = 7, STEEL = 8, FIRE = 10, ELECTRIC = 13, DRAGON = 16 }) do + data.type_chart.types[name] = + { id = name, index = id, category = id <= 8 and "physical" or "special" } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, moves, hits, critical, roll = f.T, {}, {}, {}, false, 100 + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return critical + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { target = e.target.mon.nickname, amount = e.damage } + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, who) + if native then + b.battlers[id] = who + else + b:placeBattler(id, who) + end + end + local function fresh(double) + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = double ~= false + if b.double then + put(b, 2, actor(copy(b.player.mon), 2)) + put(b, 3, actor(copy(b.enemy.mon), 3)) + end + if not native then + b:syncSides() + end + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. id, 10000, 30 + if native then + a.mon.maxHp, a.mon.moves, a.mon.pp, a.mon.maxPp = + 10000, { 270, 33, 84, 600 }, { 20, 10, 10, 10 }, { 20, 10, 10, 10 } + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[key] = 50 + end + a.ability, a.mon.ability, a.type1, a.type2 = 0, 0, 0, 0 + else + a.curStats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { + { id = f.ids.HELPING_HAND, pp = 20 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.THUNDERSHOCK, pp = 10 }, + { id = f.ids.TARGET_BOTH, pp = 10 }, + } + a.mon.moves = a.curMoves + end + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return roll + end + return lo or 0 + end + moves, hits = {}, {} + return b + end + local function typed(a, kind) + if native then + a.type1, a.type2 = + ({ NORMAL = 0, STEEL = 8, GHOST = 7, FIRE = 10, ELECTRIC = 13 })[kind], + ({ NORMAL = 0, STEEL = 8, GHOST = 7, FIRE = 10, ELECTRIC = 13 })[kind] + else + a.curTypes = { kind } + end + end + local function pp(b, id, slot) + return native and at(b, id).mon.pp[slot] or at(b, id).curMoves[slot].pp + end + local function active(b, id) + return native and at(b, id).expHelpingHand == true or not native and at(b, id)._gen3HelpingHand == true + end + local function cast(b, id) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 270, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, id), at(b, id), at(b, id).curMoves[1]) + end + end + local slots, numbers = + { TACKLE = 2, THUNDERSHOCK = 3, TARGET_BOTH = 4, DRAGON_RAGE = 4 }, + { TACKLE = 33, THUNDERSHOCK = 84, TARGET_BOTH = 600, DRAGON_RAGE = 82 } + local function attack(b, id, target, name) + name = name or "TACKLE" + if name == "DRAGON_RAGE" then + if native then + at(b, id).mon.moves[4] = 82 + else + at(b, id).curMoves[4].id = f.ids.DRAGON_RAGE + end + end + f.e.choose(function() + return { move = name, target = target } + end) + if native then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slots[name], id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + local action = id % 2 == 0 and at(b, id).curMoves[slots[name]] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + b:executeAction(at(b, id), at(b, target), action) + end + end + for caster = 0, 3 do + local partner, target = (caster + 2) % 4, caster % 2 == 0 and 1 or 0 + for _, crit in ipairs({ false, true }) do + critical = crit + for _, stab in ipairs({ false, true }) do + for _, kind in ipairs({ "NORMAL", "STEEL", "GHOST" }) do + for _, variation in ipairs({ 85, 100 }) do + roll = variation + for _, name in ipairs({ "TACKLE", "THUNDERSHOCK", "TARGET_BOTH", "DRAGON_RAGE" }) do + local amounts = {} + for _, helped in ipairs({ false, true }) do + local b = fresh() + typed(at(b, partner), stab and (name == "THUNDERSHOCK" and "ELECTRIC" or "NORMAL") or "FIRE") + typed(at(b, target), kind) + if helped then + cast(b, caster) + end + T.eq(active(b, partner), helped, "actual native effect only supports the partner") + local before = at(b, target).mon.hp + moves, hits = {}, {} + attack(b, partner, target, name) + local amount = before - at(b, target).mon.hp + T.eq( + pp(b, partner, slots[name]), + 9, + "actual supported move spends owned PP once: " .. caster .. "/" .. name .. "/" .. kind + ) + T.eq(pp(b, caster, 1), helped and 19 or 20, "actual helper spends PP only when cast") + amounts[#amounts + 1] = amount + rows[#rows + 1] = { + case = "damage", + caster = caster, + critical = crit, + stab = stab, + kind = kind, + roll = variation, + move = name, + helped = helped, + amount = amount, + moves = moves, + hits = hits, + pp = pp(b, partner, slots[name]), + } + end + if name == "DRAGON_RAGE" then + T.same(amounts, { 40, 40 }, "fixed damage is never boosted") + elseif kind ~= "GHOST" or name == "THUNDERSHOCK" then + T.check(amounts[2] >= amounts[1], "Helping Hand never reduces an ordinary hit") + end + end + end + end + end + end + end + critical, roll = false, 100 + for _, spec in ipairs({ + { name = "single", single = true }, + { name = "absent", absent = true }, + { name = "fainted", fainted = true }, + { name = "sleep", sleep = true }, + { name = "protected-substitute", protected = true, supported = true }, + { name = "repeat", repeatCast = true, supported = true }, + { name = "reverse", reverse = true, supported = true }, + }) do + local b = fresh(not spec.single) + if spec.absent then + put(b, 2, nil) + if native then + b.absent[2] = true + end + end + if spec.fainted then + at(b, 2).mon.hp = 0 + if native then + b.absent[2] = true + end + end + if spec.sleep then + b.player.mon.status, b.player.sleepTurns = "SLP", 5 + end + if spec.protected then + b.player.protecting, b.player.substituteHP, at(b, 2).protecting, at(b, 2).substituteHP = + true, 40, true, 40 + end + cast(b, 0) + if spec.repeatCast then + cast(b, 0) + end + if spec.reverse then + cast(b, 2) + end + local flags = {} + for id = 0, b.double and 3 or 1 do + flags[#flags + 1] = at(b, id) and active(b, id) or false + end + T.eq(flags[3] or false, spec.supported or false, spec.name .. " native effect success gate") + T.eq(flags[1], false, spec.name .. " never boosts the caster") + T.eq( + pp(b, 0, 1), + spec.sleep and 20 or spec.repeatCast and 18 or 19, + spec.name .. " native PP on refusal or repetition" + ) + rows[#rows + 1] = { case = spec.name, flags = flags, helperPP = pp(b, 0, 1), moves = moves, hits = hits } + end + for _, residual in ipairs({ false, true }) do + local b = fresh() + cast(b, 0) + if residual then + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + T.eq(active(b, 2), true, "support remains through the round and residual collection") + if native then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + ) + else + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + b.queue, b.pendingActions = {}, { [2] = at(b, 2).curMoves[2] } + b:resolveTurn(b.player.curMoves[2]) + end + T.eq(active(b, 2), false, "next native planning clears support before execution") + moves, hits = {}, {} + attack(b, 2, 1) + rows[#rows + 1] = + { case = residual and "residual-expiry" or "turn-expiry", hits = hits, pp = pp(b, 2, 2) } + end + do + local b = fresh() + cast(b, 0) + if native then + at(b, 2).confusionTurns = 3 + else + at(b, 2).confusedTurns = 3 + end + local before = at(b, 2).mon.hp + moves, hits = {}, {} + attack(b, 2, 1) + local amount = before - at(b, 2).mon.hp + T.eq(amount, 13, "Helping Hand does not boost a native confusion self-hit") + T.eq(pp(b, 2, 2), 10, "confusion refusal spends no move PP") + rows[#rows + 1] = { case = "confusion", amount = amount, pp = pp(b, 2, 2), moves = moves, hits = hits } + end + do + local b = fresh() + cast(b, 0) + moves, hits = {}, {} + attack(b, 2, 1) + attack(b, 2, 1) + T.same( + hits, + { { target = "P1", amount = 24 }, { target = "P1", amount = 24 } }, + "support applies to every native attack until the next planning boundary" + ) + T.eq(pp(b, 2, 2), 8, "two supported actions spend two PP") + rows[#rows + 1] = { case = "two-actions", hits = hits, pp = pp(b, 2, 2) } + end + do + local b = fresh() + if native then + b.player.mon.speed, b.enemy.mon.speed, at(b, 3).mon.speed = 1, 900, 800 + else + b.player.curStats.speed, b.enemy.curStats.speed, at(b, 3).curStats.speed = 1, 900, 800 + end + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + moves, hits = {}, {} + if native then + local C, E, A = + require("src.core.game3.battle.commands"), + require("src.core.game3.battle.engine"), + require("src.core.game3.battle.adapter") + local ad = A.new(b) + local actions = E.planTurnActions(b, ad, { + [0] = C.playerAction(b, 1, 1, 0, 0), + [2] = C.playerAction(b, 1, 2, 2, 1), + [1] = C.enemyAction(b, 1), + [3] = C.enemyAction(b, 3), + }) + for _, a in ipairs(actions) do + if a.kind == "move" then + E.resolveMove(a.user, a.target, a.move, a.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = at(b, 2).curMoves[2] } + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native Helping Hand priority queue settles") + end + T.eq( + moves[1] and moves[1].move, + "HELPING_HAND", + "priority five supports the partner before faster opponents" + ) + T.same( + hits, + { { target = "P0", amount = 16 }, { target = "P0", amount = 16 }, { target = "P1", amount = 24 } }, + "real queue applies support exactly once at native damage calculation" + ) + rows[#rows + 1] = { case = "priority-queue", moves = moves, hits = hits, pp = pp(b, 2, 2) } + end + local b = fresh() + cast(b, 0) + local mon = require("src.mods.Merge").deepCopy(at(b, 2).mon) + put(b, 2, actor(mon, 2)) + T.eq(active(b, 2), false, "replacement battler does not inherit support") + rows[#rows + 1] = { case = "replacement", active = active(b, 2) } + stopHit() + stopMove() + stopCrit() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_helping_hand.lua b/tests/engine/gen3_helping_hand.lua new file mode 100644 index 000000000..f680de3b4 --- /dev/null +++ b/tests/engine/gen3_helping_hand.lua @@ -0,0 +1,20 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.helping_hand").run(".", "gen2", version) +end +local V, H = require("src.core.GameVersion"), require("src.battle.Gen3HelpingHand") +local previous = V.get() +for _, version in ipairs({ "red", "gold", "platinum", "black" }) do + V.set(version) + local partner, user = { mon = { hp = 100 } }, { _gen3HelpingHand = true } + H.apply({ + partnerOf = function() + return partner + end, + }, user) + T.eq(partner._gen3HelpingHand, nil, version .. " does not produce GBA support") + T.eq(H.damage(user, 11), 11, version .. " ignores GBA support state") +end +V.set(previous) +T.finish("native GBA Helping Hand") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index cc5080320..f2652c5fb 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -128fc071ae7249cd +032184f68094e9ca diff --git a/tests/helping_hand/public_effect.lua b/tests/helping_hand/public_effect.lua new file mode 100644 index 000000000..066c7d08b --- /dev/null +++ b/tests/helping_hand/public_effect.lua @@ -0,0 +1,55 @@ +-- Composed proof: requires the canonical public effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE", { effect = "HELPING_HAND", power = 0 })) + mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect + mod.exports.hits = {} + mod.hooks:wrap("battle.enemy_action", function() return { move = "SCRATCH", target = 0 } end) + mod.events:on("battle.damage_dealt", function(e) + mod.exports.hits[#mod.exports.hits+1] = { target = e.target.mon.nickname, amount = e.damage } + end) + end]] + local function configure(data, _, ids) + local move = data.moves[ids.TACKLE] + move.target, move.priority, move.protectAffected = 16, 5, false + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + T.eq(f.e.effect, "HELPING_HAND", "public read keeps canonical Helping Hand effect") + T.eq( + f.data.moves[f.ids.TACKLE].effect, + "HELPING_HAND_EFFECT", + "canonical effect selects real native handler" + ) + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 176, "canonical effect selects native semantic 176") + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local right = require("src.battle.BattleState").makeBattler( + f.data, + require("src.mods.Merge").deepCopy(b.enemy.mon), + false + ) + b:placeBattler(3, right) + b:syncSides() + for _, a in ipairs({ b.player, b.enemy, right }) do + a.curTypes, a.curStats = + { "NORMAL" }, { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.abilityOverride = a.curStats, false + end + b.player.mon.nickname = "TARGET" + right.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + right.mon.moves = right.curMoves + b:executeAction(b.enemy, b.enemy, b.enemy.curMoves[1]) + b:executeAction(right, b.player, b:enemyAction(right)) + T.same( + f.e.hits, + { { target = "TARGET", amount = 28 } }, + "canonical public patch boosts a real mod-selected partner attack" + ) + T.eq(b.enemy.curMoves[1].pp, 9, "helper consumes owned PP") + T.eq(right.curMoves[1].pp, 9, "supported action consumes caller PP") + f.close() +end +T.finish("composed public Helping Hand effect") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index a7d5ba67c..103c70598 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 173ca5491..037b40db1 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "follow_me" then +if mode == "helping_hand" then + rows = dofile(root .. "/tests/enemy_actions/helping_hand.lua").run(root, backend, version) +elseif mode == "follow_me" then rows = dofile(root .. "/tests/enemy_actions/follow_me.lua").run(root, backend, version) elseif mode == "targets" then rows = dofile(root .. "/tests/enemy_actions/targets.lua").run(root, backend, version) From f09b570eaaef1fb8bb48b58fe20f501869a5edd8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:42:36 +0200 Subject: [PATCH 23/90] Pin the composed effect proof's critical and variation inputs --- tests/helping_hand/public_effect.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/helping_hand/public_effect.lua b/tests/helping_hand/public_effect.lua index 066c7d08b..d0fa133e1 100644 --- a/tests/helping_hand/public_effect.lua +++ b/tests/helping_hand/public_effect.lua @@ -7,6 +7,7 @@ for _, version in ipairs({ "firered", "emerald" }) do assert(mod.content.moves:patch("TACKLE", { effect = "HELPING_HAND", power = 0 })) mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect mod.exports.hits = {} + mod.hooks:wrap("battle.crit", function() return false end) mod.hooks:wrap("battle.enemy_action", function() return { move = "SCRATCH", target = 0 } end) mod.events:on("battle.damage_dealt", function(e) mod.exports.hits[#mod.exports.hits+1] = { target = e.target.mon.nickname, amount = e.damage } @@ -26,6 +27,12 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 176, "canonical effect selects native semantic 176") local b = f.fresh("TACKLE", { enemy = true }).battle b.double = true + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return 100 + end + return lo or 0 + end local right = require("src.battle.BattleState").makeBattler( f.data, require("src.mods.Merge").deepCopy(b.enemy.mon), From 8c7df4d7dff206b8a88ba9a9a819c6128b311a91 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:09:50 +0200 Subject: [PATCH 24/90] Execute native GBA Spit Up with set-damage semantics --- .github/workflows/enemy-action-contract.yml | 13 +- docs/native-gba-spit-up.md | 90 ++++ src/battle/BattleState.lua | 3 +- src/battle/Damage.lua | 136 +++-- src/battle/EffectRegistry.lua | 6 +- src/battle/Gen3MoveEffects.lua | 25 +- tests/enemy_actions/spit_up.lua | 516 +++++++++++++++++++ tests/engine/gen3_spit_up.lua | 5 + tests/goldens/fixture_fingerprint.txt | 2 +- tests/spit_up/public_effect.lua | 71 +++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 12 files changed, 823 insertions(+), 50 deletions(-) create mode 100644 docs/native-gba-spit-up.md create mode 100644 tests/enemy_actions/spit_up.lua create mode 100644 tests/engine/gen3_spit_up.lua create mode 100644 tests/spit_up/public_effect.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 51d0f4484..dd1d9a731 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -11,6 +11,7 @@ on: - 'tests/engine/portable_enemy_targets.lua' - 'tests/engine/gen3_follow_me.lua' - 'tests/engine/gen3_helping_hand.lua' + - 'tests/engine/gen3_spit_up.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] @@ -31,12 +32,20 @@ jobs: repository: bryanthaboi/gen1recomp ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 path: gen1-reference - - name: Install LuaJIT - run: sudo apt-get update && sudo apt-get install -y luajit + # Pin the maintained VM: the local 2.1.1700008891 ARM64 runtime + # produces JIT-dependent Thick Fat results in unchanged reference code. + - uses: actions/checkout@v7 + with: + repository: LuaJIT/LuaJIT + ref: a4c064539ea931274d407e1249b4bb575424a0f4 + path: luajit-runtime + - name: Build pinned LuaJIT + run: make -C luajit-runtime -j2 - name: Actual public hook and native move execution run: >- python3 gen2/tools/enemy-action-contract-check/check.py --gen1 gen1-reference --gen2 gen2 + --lua "${{ github.workspace }}/luajit-runtime/src/luajit" --report "${{ runner.temp }}/enemy-action-report.json" - uses: actions/upload-artifact@v7 if: always() diff --git a/docs/native-gba-spit-up.md b/docs/native-gba-spit-up.md new file mode 100644 index 000000000..124f261b3 --- /dev/null +++ b/docs/native-gba-spit-up.md @@ -0,0 +1,90 @@ +# Native GBA Stockpile release + +Gen-3 Spit Up (effect 161) uses a native set-damage command. Its calculated +base, including the final +2, is multiplied by the operative Stockpile count +before Helping Hand, STAB and type rounding. Multiplying the move's power +inside ordinary damage calculation produces a different result: at level 30, +equal Attack/Defense and normal STAB, two stacks release 90 instead of 87. + +The native path bypasses ordinary `battle.damage` and `battle.crit` hooks and +has no damage-variation roll. The accuracy hook and ordinary status +interruption still apply. This matches the reference's dedicated command, +not a fabricated ordinary hit that happens to have the same amount. + +Successful release, Protect and type immunity consume the stored count; +normal misses, semi-invulnerability, sleep and absorption preserve it. An +empty use still spends ordinary PP. Four real Stockpile casts cap at three; +a second release cannot reuse spent energy. Swallow consumes the same count. +Helping Hand, exact stage ratios, pre-stage stat modifiers, screens, burn, +type rounding and clipped Substitute damage use the covered native order. +The native set-damage event retains its reference metadata (no critical and +neutral event multiplier), separately from the actual type-adjusted amount. + +This feature stacks on #198. The existing record receives `rev = 1`, so +builds with the changed native effect cannot silently share an older link +fingerprint. Earlier-generation fallback behavior remains separate. + +## Contract verification + +```sh +luajit tests/engine/gen3_spit_up.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +# With the canonical effect adapter (#121) integrated: +luajit tests/spit_up/public_effect.lua +``` + +The same API-3 consumer and fixture use the actual native planner/action +selection and move executors on unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The matrix includes all four user +positions and counts 0–3, physical and authored special types, STAB, single +and dual resistance, immunity/Foresight, Helping Hand, wide/low stats, +Protect, Substitute, Endure at one HP, miss/sleep/semi-invulnerability, +absorption, burn/screens, rain/sun, selected ability/item/stage combinations, +Stockpile's cap, repeated release and Swallow interaction. Actual move/hit +events, HP, operative counter, PP and public damage/critical callback counts +are compared. Native sessions use equal stats and synthetic imported-shaped +rows; this is not a fresh ROM import or full graphical playthrough. + +The focused suite passes **8,292 checks**. The full comparison passes +**32 isolated workers / 3,974 paired observations**, including the existing +enemy-choice, selector, record, target, Follow Me and Helping Hand contracts. +The unchanged #198 host fails **770 assertions** in the new FireRed cases. +The full quick feature run passes **87/96** engine suites versus **86/95** on +clean #198. Exact Counters of all **25 inherited FAIL lines across nine +suites** match, with no additions. SDK 3/3 and B30 pass. + +Counters compare the operative native operands: `expStockpile` on the +reference and `stockpile` on this host. The reference's legacy `stockpile` +alias remains stale after Protect; raw alias equality is not claimed. +Native set-damage event metadata is checked explicitly, not replaced by the +ordinary calculator's effectiveness metadata. + +## Pinned test runtime + +Both sides and the contract workflow use maintained LuaJIT v2.1 commit +[`a4c064539ea931274d407e1249b4bb575424a0f4`](https://github.com/LuaJIT/LuaJIT/commit/a4c064539ea931274d407e1249b4bb575424a0f4), +reporting `LuaJIT 2.1.1792588164`, with JIT enabled. It is built from the +official mirror, not patched engine code. LuaJIT's +[release policy](https://luajit.org/status.html) uses a maintained rolling +branch; the workflow pins the exact source revision for reproducibility. + +The former local ARM64 runtime `2.1.1700008891` produced JIT-dependent Thick +Fat results in unchanged reference code (30 rather than 33 in one covered +case). Disabling JIT on that old VM removes the discrepancy; the maintained +VM passes the unfiltered comparison with JIT enabled. The original failing +report is retained separately under ignored QA output. This is not a claim +of parity on that older VM, and no source-reference patch or row/value filter +is used to make the comparison pass. + +Only the reviewed Spit Up record revision changes the fixture fingerprint: +`032184f68094e9ca` becomes `397df21576af62f1`, and the `move_effects` section +changes from `52cc8398513ed12c` to `c62807cde682a669`. All six other populated +sections remain identical. Removing only that revision restores the former +full digest. The combined checkout must update its audited section pin. + +Full ability/item/badge coverage, ordinary damage's remaining ordering gaps, +other set/delayed executors, RNG traces outside these recorded operations, +called/link controller execution, animation and full gameplay remain separate +surfaces. Gen1 is a read-only reference; no Gen1 publication is required. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 87269b966..0e7da978d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5227,6 +5227,7 @@ end -- Damage.compute, hooked as battle.damage; the ctx table is only built -- when a chain is installed, so the no-mod path allocates nothing function BattleState:computeDamage(user, target, move, opts) + if opts and opts.gbaSetDamage then opts.battle = self end -- Damage's Gen II held-item path needs the imported item records alongside -- the ordinary formula options. Keep Gen I's no-mod fast path allocation-free. if self.data and self.data.constants and self.data.constants.generation == 2 then @@ -5313,7 +5314,7 @@ function BattleState:computeDamage(user, target, move, opts) opts = opts or {} opts.bwBattle = self end - if Runtime.wantsHook("battle.damage") then + if Runtime.wantsHook("battle.damage") and not (opts and opts.gbaSetDamage) then return Runtime.call("battle.damage", function(c) return Damage.compute(c.ruleset, c.user, c.target, c.move, c.opts) end, { battle = self, ruleset = self.ruleset, user = user, diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 90bab419a..851c641d9 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -294,15 +294,51 @@ function Damage.compute(ruleset, attacker, defender, move, opts) local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" + -- GBA set-damage effects use the native base command without a critical + -- roll, variance or battle.damage hook. Its stat modifiers precede stages. + local rawAtk, rawDef = attacker.curStats[atkStat], defender.curStats[defStat] + if opts.gbaSetDamage then + local ability = Abilities.of(attacker) + if not special and (ability == "HUGE_POWER" or ability == "PURE_POWER") then rawAtk = rawAtk * 2 end + local tn, td = HoldItems.damageMultiplier(attacker, move) + rawAtk = math.floor(rawAtk * tn / td) + local an, ad = HoldItems.attackMultiplier(attacker, move, special) + rawAtk = math.floor(rawAtk * an / ad) + local dn, dd = HoldItems.defenceMultiplier(defender, move, special) + rawDef = math.floor(rawDef * dn / dd) + local kind = Abilities.normalizeType(move.type) + if special and Abilities.of(defender) == "THICK_FAT" and (kind == "FIRE" or kind == "ICE") then + rawAtk = math.floor(rawAtk / 2) + end + if special and (ability == "PLUS" or ability == "MINUS") and opts.battle then + local wanted = ability == "PLUS" and "MINUS" or "PLUS" + for _, other in opts.battle:activeBattlers() do + if Abilities.of(other) == wanted then rawAtk = math.floor(rawAtk * 3 / 2); break end + end + end + if not special and (ability == "HUSTLE" or ability == "GUTS" and attacker.mon.status) then + rawAtk = math.floor(rawAtk * 3 / 2) + end + if not special and Abilities.of(defender) == "MARVEL_SCALE" and defender.mon.status then + rawDef = math.floor(rawDef * 3 / 2) + end + end + + local applyStage = Stats.applyStage + if opts.gbaSetDamage then + applyStage = function(value, stage) + stage = math.max(-6, math.min(6, stage or 0)) + if stage >= 0 then return math.floor(value * (2 + stage) / 2) end + return math.floor(value * 2 / (2 - stage)) + end + end local atk, dfn if crit and ruleset.critIgnoresStages then atk = attacker.curStats[atkStat] dfn = defender.curStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], - stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], - stageOf(defender, defStat)) + atk = applyStage(rawAtk, stageOf(attacker, atkStat)) + dfn = applyStage(rawDef, stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special @@ -380,7 +416,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- the defence and THICK FAT halves what a FIRE or ICE move gets to work -- with. A Gen 1 or Gen 2 Pokemon has no ability at all and every one of -- these answers 1/1, so nothing below this line changes for them. - do + if not opts.gbaSetDamage then local an, ad = Abilities.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = Abilities.defenceMultiplier(defender, move, special) @@ -391,7 +427,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- species-locked doublings -- THICK CLUB, LIGHT BALL, METAL POWDER, the -- two DEEP SEA halves and SOUL DEW. A battler with no `items` view (every -- Gen 1 and Gen 2 one) answers 1/1 here too. - do + if not opts.gbaSetDamage then local an, ad = HoldItems.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = HoldItems.defenceMultiplier(defender, move, special) @@ -426,6 +462,10 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if opts.powerMultiplier and opts.powerMultiplier ~= 1 then power = math.max(1, math.floor(power * opts.powerMultiplier)) end + if opts.gbaSetDamage then + local pn, pd = Abilities.damageMultiplier(attacker, move) + power = math.floor(power * pn / pd) + end d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50) if gbaBase then @@ -461,16 +501,22 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d / 2) end end + if opts.gbaSetDamage and special then + local fn, fd = Abilities.flashFireBoost(attacker, move) + d = math.floor(d * fn / fd) + end d = d + 2 else d = math.min(d, 997) + 2 end + if opts.gbaSetDamage then d = d * opts.gbaSetDamage end + -- Held type boosters live inside BattleCommand_DamageCalc, before weather, -- STAB and effectiveness. This placement matters because every stage floors -- independently. HeldItems applies the recomp's intentional Gen II cleanup -- for the retail Dragon Fang/Dragon Scale held-effect data bug. - if not opts.typeless then + if not opts.typeless and not opts.gbaSetDamage then d = HeldItems.applyTypeBoost(opts.data, attacker, move.type, d) end @@ -531,7 +577,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- WONDER GUARD, which is the one immunity that needs the chart's answer -- first: anything that is not super-effective does nothing at all. It -- is SHEDINJA's whole reason to exist. - if Abilities.blocks(defender, move, mult) + if not opts.gbaSetDamage and Abilities.blocks(defender, move, mult) and not (bwLevitate and Abilities.of(defender) == "LEVITATE") then return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } @@ -539,9 +585,12 @@ function Damage.compute(ruleset, attacker, defender, move, opts) for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies - if not (foresight and m == 0) then d = math.floor(d * m / 10) end + if not (foresight and m == 0) then + d = math.floor(d * m / 10) + if opts.gbaSetDamage and m > 0 and d == 0 then d = 1 end + end end - if d == 0 then + if d == 0 and not opts.gbaSetDamage then -- a 2-3 damage hit at 0.25x floors to zero: the original flags -- the move as missed rather than dealing a minimum 1 return 0, { crit = false, typeMult = mult, missed = true } @@ -550,36 +599,38 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- a Pokemon's own type hits half again as hard. Applied here because -- the cartridge applies it to the finished damage rather than the stat, -- which is not the same number once the floors are counted. - local pn, pd = Abilities.damageMultiplier(attacker, move) - if pn ~= 1 or pd ~= 1 then d = math.floor(d * pn / pd) end - -- FLASH FIRE, once something has lit it: every FIRE move this Pokemon - -- makes is half again as strong for the rest of the battle. The flag - -- rides the battler, so it leaves when the Pokemon does. - local fn, fd = Abilities.flashFireBoost(attacker, move) - if fn ~= 1 or fd ~= 1 then d = math.floor(d * fn / fd) end - -- ...and the sixteen type trinkets, each by its own parameter: 10 - -- percent for a CHARCOAL or a MAGNET, 5 for the two INCENSEs. Reading - -- the number off the item rather than assuming ten is the difference - -- between a SEA INCENSE that works and one that is a MYSTIC WATER. - local tn, td = HoldItems.damageMultiplier(attacker, move) - if tn ~= 1 or td ~= 1 then d = math.floor(d * tn / td) end - -- BLACK AND WHITE's held items on the finished damage: type items, - -- Life Orb, Expert Belt, gems, resist berries (src/battle/Gen5HoldItems.lua) - if attacker.items or defender.items then - d = require("src.battle.Gen5HoldItems").modifyDamage(attacker, defender, move, mult, d, special) - end - -- CHARGE: the user's NEXT Electric move is worth double. The flag is - -- spent here and cleared by the turn loop once the move has resolved, - -- so it survives a miss the way the cartridge's does. - local moveType = Abilities.normalizeType(move.type) - if attacker.charged and moveType == "ELECTRIC" then - d = d * 2 - end - -- MUD SPORT and WATER SPORT halve one type for the rest of the battle, - -- for BOTH sides -- they are a field effect, not a buff. opts.sports - -- carries them because Damage.compute never sees the field. - for _, muted in pairs(opts.sports or {}) do - if muted == moveType then d = math.floor(d / 2) end + if not opts.gbaSetDamage then + local pn, pd = Abilities.damageMultiplier(attacker, move) + if pn ~= 1 or pd ~= 1 then d = math.floor(d * pn / pd) end + -- FLASH FIRE, once something has lit it: every FIRE move this Pokemon + -- makes is half again as strong for the rest of the battle. The flag + -- rides the battler, so it leaves when the Pokemon does. + local fn, fd = Abilities.flashFireBoost(attacker, move) + if fn ~= 1 or fd ~= 1 then d = math.floor(d * fn / fd) end + -- ...and the sixteen type trinkets, each by its own parameter: 10 + -- percent for a CHARCOAL or a MAGNET, 5 for the two INCENSEs. Reading + -- the number off the item rather than assuming ten is the difference + -- between a SEA INCENSE that works and one that is a MYSTIC WATER. + local tn, td = HoldItems.damageMultiplier(attacker, move) + if tn ~= 1 or td ~= 1 then d = math.floor(d * tn / td) end + -- BLACK AND WHITE's held items on the finished damage: type items, + -- Life Orb, Expert Belt, gems, resist berries (src/battle/Gen5HoldItems.lua) + if attacker.items or defender.items then + d = require("src.battle.Gen5HoldItems").modifyDamage(attacker, defender, move, mult, d, special) + end + -- CHARGE: the user's NEXT Electric move is worth double. The flag is + -- spent here and cleared by the turn loop once the move has resolved, + -- so it survives a miss the way the cartridge's does. + local moveType = Abilities.normalizeType(move.type) + if attacker.charged and moveType == "ELECTRIC" then + d = d * 2 + end + -- MUD SPORT and WATER SPORT halve one type for the rest of the battle, + -- for BOTH sides -- they are a field effect, not a buff. opts.sports + -- carries them because Damage.compute never sees the field. + for _, muted in pairs(opts.sports or {}) do + if muted == moveType then d = math.floor(d / 2) end + end end end @@ -591,13 +642,16 @@ function Damage.compute(ruleset, attacker, defender, move, opts) 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 + if d > 1 and not opts.typeless and not opts.gbaSetDamage then local r = rng(ruleset.randMin, ruleset.randMax) -- randDiv defaults to 255 because that is Gen 1 and Gen 2's denominator -- (r runs 217..255). Gen 3 rolls 85..100 out of 100; dividing that by -- 255 would cut every hit to roughly a third. d = math.floor(d * r / (ruleset.randDiv or 255)) end + if opts.gbaSetDamage then + return math.max(d, 0), { crit = false, typeMult = mult, eventTypeMult = 10, gbaSetDamage = true } + end return math.max(d, 1), { crit = crit, typeMult = mult } end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index c56d4524b..186595dac 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -122,6 +122,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- 1 and Gen 2 one) is treated as affected, which is that generation's rule. if target.protecting and move.protectAffected ~= false then battle:cancelMoveAnim() + if record and record.onProtect and record.onProtect(ctx) then return end battle:sayNext(Strings("%s\nprotected itself!", displayName(target))) return end @@ -316,7 +317,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) battle.lastDamage = hitDamage -- ...and the one applyDamage a Gen 3 FOCUS BAND answers: a move landing -- on someone + local substituteBefore = target.substituteHP local dealt = battle:applyDamage(target, hitDamage, true) + if info.gbaSetDamage and substituteBefore then dealt = math.min(dealt, substituteBefore) end totalDealt = totalDealt + dealt -- MIRROR COAT is COUNTER's special twin and needs the SPECIAL half of -- the damage kept separately; battle.lastDamage is shared by both and @@ -354,7 +357,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) or (record and record.chooseDamage) or not require("src.core.GameVersion").isGen3()) then Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, - damage = dealt, crit = info.crit, typeMult = info.typeMult, + damage = dealt, crit = info.crit, typeMult = info.eventTypeMult or info.typeMult, + substitute = info.gbaSetDamage and hadSub or nil, }) end if hadSub and not target.substituteHP then diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 55bcf9448..f6f6e6693 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -749,10 +749,31 @@ P.SWALLOW_EFFECT = function(battle, user) return { Strings("%s\nregained health!", displayName(user)) } end --- SPIT UP is a damaging move whose power IS the counter: 100, 200, 300. +-- GBA Spit Up scales the calculated base (including +2), rather than power. +-- The earlier-generation fallback retains the existing power-based path. F.SPIT_UP_EFFECT = { - gate = function(ctx) + rev = 1, -- Native set-damage semantics change link compatibility. + onProtect = function(ctx) + if not require("src.core.GameVersion").isGen3() then return false end if (ctx.user.stockpile or 0) <= 0 then + ctx.say(Strings("But, it failed!")) + return true + end + ctx.user.stockpile = 0 + return false + end, + chooseDamage = function(ctx) + if not require("src.core.GameVersion").isGen3() then + return ctx.computeDamage({ rng = ctx.rng, powerMultiplier = math.max(1, ctx.user.stockpile or 1) }) + end + if ctx.battle.abilityAbsorb and ctx.battle:abilityAbsorb(ctx.target, ctx.move) then return nil end + local n = ctx.user.stockpile or 0 + if n <= 0 then return nil, Strings("But, it failed!") end + ctx.user.stockpile = 0 + return ctx.computeDamage({ rng = ctx.rng, forceCrit = false, gbaSetDamage = n }) + end, + gate = function(ctx) + if not require("src.core.GameVersion").isGen3() and (ctx.user.stockpile or 0) <= 0 then return false, Strings("But, it failed!") end return true diff --git a/tests/enemy_actions/spit_up.lua b/tests/enemy_actions/spit_up.lua new file mode 100644 index 000000000..e7c8d2dea --- /dev/null +++ b/tests/enemy_actions/spit_up.lua @@ -0,0 +1,516 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local definitions = { + { "STOCKPILE", 254, 160, "STOCKPILE_EFFECT", 0, 16, 0 }, + { "SPIT_UP", 255, 161, "SPIT_UP_EFFECT", 100, 0, 0 }, + { "SWALLOW", 256, 162, "SWALLOW_EFFECT", 0, 16, 0 }, + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5 }, + } + local function configure(data, engine, ids, rom, names) + for _, row in ipairs(definitions) do + local name, num, effect, handler, power, target, priority = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(num) + ids[name], names[num] = key, name + if engine == "gen1" then + local d = { + effect = effect, + power = power, + type = 0, + target = target, + priority = priority, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 51, + secondaryChance = 0, + } + data.gen3Moves._rom[num], rom[num], data.gen3Pokemon._moveNames[num] = d, d, name + else + data.moves[key] = { + id = key, + index = num, + name = name, + effect = handler, + gen3Effect = effect, + power = power, + type = "NORMAL", + target = target, + priority = priority, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + } + end + end + if engine == "gen2" then + data.type_chart.types = {} + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + for name, index in pairs({ + NORMAL = 0, + ROCK = 5, + GHOST = 7, + STEEL = 8, + FIRE = 10, + WATER = 11, + GRASS = 12, + ELECTRIC = 13, + }) do + data.type_chart.types[name] = + { id = name, index = index, category = index < 10 and "physical" or "special" } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, hits, moves = f.T, {}, {}, {} + local calls, rolls = { crit = 0, damage = 0, variation = 0 }, 100 + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + calls.crit = calls.crit + 1 + return true + end) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + amount = e.damage, + crit = e.crit, + typeMult = e.typeMult, + substitute = e.substitute or false, + } + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function make(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local function fresh(spec, userId) + moves, hits = {}, {} + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = true + put(b, 2, make(copy(b.player.mon), 2)) + put(b, 3, make(copy(b.enemy.mon), 3)) + if not native then + b:syncSides() + end + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. id, 10000, spec.level or 30 + if native then + a.mon.maxHp, a.ability, a.mon.ability = 10000, 0, 0 + a.mon.moves, a.mon.pp, a.mon.maxPp = { 254, 255, 256, 270 }, { 20, 10, 10, 20 }, { 20, 10, 10, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = spec.stat or 50 + end + a.type1, a.type2 = 0, 0 + else + a.curStats = { + hp = 10000, + attack = spec.stat or 50, + defense = spec.stat or 50, + speed = 50, + special = spec.stat or 50, + spatk = spec.stat or 50, + spdef = spec.stat or 50, + } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { + { id = f.ids.STOCKPILE, pp = 20 }, + { id = f.ids.SPIT_UP, pp = 10 }, + { id = f.ids.SWALLOW, pp = 10 }, + { id = f.ids.HELPING_HAND, pp = 20 }, + } + a.mon.moves = a.curMoves + end + end + local user, target = at(b, userId), at(b, userId % 2 == 0 and 1 or 0) + local kind = spec.moveType or "NORMAL" + local typeIds = { NORMAL = 0, FIRE = 10, WATER = 11, GRASS = 12, ELECTRIC = 13 } + local def = native and require("src.core.game3.battle.moves")._rom[255] or f.data.moves[f.ids.SPIT_UP] + def.type = native and typeIds[kind] or kind + if spec.stab ~= false then + if native then + user.type1, user.type2 = typeIds[kind], typeIds[kind] + else + user.curTypes = { kind } + end + end + if spec.ability then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.ability) + user.ability, user.mon.ability = ab, ab + else + user.abilityOverride = spec.ability + end + end + if spec.defAbility then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.defAbility) + target.ability, target.mon.ability = ab, ab + else + target.abilityOverride = spec.defAbility + end + end + user.stages.attack, user.stages.spAtk, user.stages.spatk = + spec.attackStage or 0, spec.attackStage or 0, spec.attackStage or 0 + target.stages.defense, target.stages.spDef, target.stages.spdef = + spec.defenseStage or 0, spec.defenseStage or 0, spec.defenseStage or 0 + if spec.item then + local item = ({ NORMAL_POWER = 217, CHOICE_BAND = 186 })[spec.item] + if native then + user.item, user.mon.item = item, item + else + user.items.PROBE = + { holdEffect = spec.item, holdEffectParam = spec.item == "NORMAL_POWER" and 10 or 0 } + user.mon.item = "PROBE" + end + end + if spec.partnerAbility then + local partner = at(b, (userId + 2) % 4) + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.partnerAbility) + partner.ability, partner.mon.ability = ab, ab + else + partner.abilityOverride = spec.partnerAbility + end + end + if spec.pinch then + user.mon.hp = 3000 + end + if spec.sport then + if native then + b.expWaterSport = true + else + b.field.waterSport = "FIRE" + end + end + if spec.defAbility == "VOLT_ABSORB" then + target.mon.hp = 5000 + end + if spec.identified then + if native then + target.expIdentified = true + else + target.identified = true + end + end + if spec.defAbility == "MARVEL_SCALE" then + target.mon.status = "PSN" + end + if spec.endure then + target.mon.hp = 1 + if native then + target.expEnduring = true + else + target.enduring = true + end + end + if spec.weather then + if native then + b.weather = spec.weather + else + b.field.weather = spec.weather + end + end + if spec.stab == false then + if native then + user.type1, user.type2 = 10, 10 + else + user.curTypes = { "FIRE" } + end + end + local types = spec.types or { "NORMAL" } + if native then + local ids = { NORMAL = 0, ROCK = 5, GHOST = 7, STEEL = 8 } + target.type1, target.type2 = ids[types[1]], ids[types[2] or types[1]] + else + target.curTypes = types + end + if spec.defense then + if native then + target.mon.defense = spec.defense + else + target.curStats.defense = spec.defense + end + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + calls.variation = calls.variation + 1 + return rolls + end + if spec.miss and lo == 1 and hi == 100 then + return 100 + end + if spec.miss and lo == 0 and hi == 99 then + return 99 + end + if spec.miss and lo == 0 and hi == 255 then + return 255 + end + return lo or 0 + end + return b, user, target + end + local function count(a) + return native and (a.expStockpile or 0) or (a.stockpile or 0) + end + local function pp(a, slot) + return native and a.mon.pp[slot] or a.curMoves[slot].pp + end + local function action(b, id, slot, target) + if native then + local C = require("src.core.game3.battle.commands") + f.e.choose(function() + return { move = definitions[slot][1], target = target } + end) + local a = id % 2 == 0 and C.playerAction(b, 1, slot, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + a.target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + f.e.choose(function() + return { move = definitions[slot][1], target = target } + end) + local a = id % 2 == 0 and at(b, id).curMoves[slot] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + b:executeAction(at(b, id), at(b, target), a) + end + end + local specs = { + { name = "normal" }, + { name = "no-stab", stab = false }, + { name = "resist", types = { "STEEL" } }, + { name = "dual-resist", types = { "ROCK", "STEEL" } }, + { name = "immune", types = { "GHOST" } }, + { name = "low", level = 1, stat = 1, defense = 500 }, + { name = "wide", stat = 300, defense = 50 }, + { name = "helped", helped = true }, + { name = "protected", protected = true }, + { name = "substitute", substitute = 30 }, + { name = "sleep", sleep = true }, + { name = "hidden", hidden = true }, + { name = "miss", miss = true }, + { name = "burn", burn = true }, + { name = "reflect", reflect = true }, + { name = "huge-stage", stat = 55, ability = "HUGE_POWER", attackStage = 1 }, + { name = "hustle-stage", stat = 55, ability = "HUSTLE", attackStage = -1 }, + { name = "guts-burn", stat = 55, ability = "GUTS", burn = true, attackStage = 1 }, + { name = "marvel-scale", stat = 55, defAbility = "MARVEL_SCALE", defenseStage = 1 }, + { name = "scarf-stage", stat = 55, item = "NORMAL_POWER", attackStage = 1 }, + { name = "huge-band", stat = 55, ability = "HUGE_POWER", item = "CHOICE_BAND", attackStage = -1 }, + { name = "endure-zero", endure = true }, + { name = "fire-rain", moveType = "FIRE", weather = "RAIN" }, + { name = "fire-sun", moveType = "FIRE", weather = "SUN" }, + { name = "thick-fat", moveType = "FIRE", defAbility = "THICK_FAT", stat = 55, attackStage = 1 }, + { + name = "low-dual-no-stab", + level = 1, + stat = 1, + defense = 500, + stab = false, + types = { "ROCK", "STEEL" }, + }, + { name = "identified-ghost", types = { "GHOST" }, identified = true }, + { + name = "plus-minus", + moveType = "ELECTRIC", + ability = "PLUS", + partnerAbility = "MINUS", + stat = 55, + attackStage = 1, + }, + { name = "plus-alone", moveType = "ELECTRIC", ability = "PLUS", stat = 55, attackStage = 1 }, + { + name = "blaze-sport", + moveType = "FIRE", + ability = "BLAZE", + pinch = true, + sport = true, + stat = 55, + attackStage = 1, + }, + { name = "absorbed", moveType = "ELECTRIC", defAbility = "VOLT_ABSORB" }, + { name = "wonder-guard", defAbility = "WONDER_GUARD" }, + } + for id = 0, 3 do + for n = 0, 3 do + for _, spec in ipairs(specs) do + for _, variation in ipairs({ 85, 100 }) do + rolls = variation + local b, user, target = fresh(spec, id) + for i = 1, n do + action(b, id, 1, id) + end + T.eq(count(user), n, "real Stockpile producer records native count") + if spec.helped then + action(b, (id + 2) % 4, 4, (id + 2) % 4) + end + if spec.protected then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if spec.substitute then + target.substituteHP = spec.substitute + end + if spec.sleep then + user.mon.status, user.sleepTurns = "SLP", 5 + end + if spec.burn then + user.mon.status = "BRN" + end + if spec.hidden then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable = true + end + end + if spec.reflect then + if native then + (target.side == "player" and b.playerSide or b.enemySide).expReflectTurns = 5 + else + target.reflect = true + end + end + if spec.miss then + local move = native and require("src.core.game3.battle.moves")._rom[255] + or f.data.moves[f.ids.SPIT_UP] + move.accuracy, move.accuracyRaw = 1, 1 + end + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + local before = target.mon.hp + action(b, id, 2, target == b.player and 0 or target.position or target.id) + if spec.name == "normal" then + T.eq( + before - target.mon.hp, + 45 * n, + "Spit Up multiplies the computed base including its final +2" + ) + end + local expected = spec.sleep or spec.hidden or spec.miss or spec.defAbility == "VOLT_ABSORB" + T.eq(count(user), expected and n or 0, "native count consumption follows execution outcome") + T.eq(pp(user, 2), spec.sleep and 10 or 9, "Spit Up uses owned PP after ordinary interruption") + T.same( + calls, + { crit = 0, damage = 0, variation = 0 }, + "set damage bypasses ordinary damage/critical hooks and variance" + ) + rows[#rows + 1] = { + case = spec.name, + id = id, + count = n, + roll = variation, + amount = before - target.mon.hp, + stockpile = count(user), + pp = pp(user, 2), + hits = hits, + moves = moves, + calls = calls, + substitute = target.substituteHP or 0, + } + if spec.miss then + local move = native and require("src.core.game3.battle.moves")._rom[255] + or f.data.moves[f.ids.SPIT_UP] + move.accuracy, move.accuracyRaw = 100, 255 + end + end + end + end + end + for _, id in ipairs({ 0, 3 }) do + local b, user, target = fresh({}, id) + for i = 1, 4 do + action(b, id, 1, id) + end + T.eq(count(user), 3, "Stockpile caps at three after four actual casts") + T.eq(pp(user, 1), 16, "the capped fourth cast still spends PP") + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + local before = target.mon.hp + local targetId = id % 2 == 0 and 1 or 0 + action(b, id, 2, targetId) + T.eq(before - target.mon.hp, 135, "capped Stockpile releases its native three-stack base") + action(b, id, 2, targetId) + T.eq(count(user), 0, "spent Stockpile cannot be reused") + T.eq(pp(user, 2), 8, "a second empty Spit Up still consumes its own PP") + T.eq(#hits, 1, "the empty second use emits no damaging hit") + rows[#rows + 1] = { + case = "cap-and-reuse", + id = id, + hits = hits, + moves = moves, + stockpile = count(user), + pp = pp(user, 2), + stockpilePP = pp(user, 1), + } + end + for n = 1, 3 do + local b, user, target = fresh({}, 0) + for i = 1, n do + action(b, 0, 1, 0) + end + user.mon.hp = 1000 + action(b, 0, 3, 0) + T.eq(count(user), 0, "Swallow consumes the same native Stockpile counter") + local healed = user.mon.hp + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + action(b, 0, 2, 1) + T.eq(#hits, 0, "Swallow leaves no stored damage to release") + rows[#rows + 1] = { + case = "swallow-consumption", + count = n, + healed = healed, + pp = pp(user, 2), + stockpile = count(user), + moves = moves, + hits = hits, + } + end + stopMove() + stopHit() + stopDamage() + stopCrit() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_spit_up.lua b/tests/engine/gen3_spit_up.lua new file mode 100644 index 000000000..f1e340235 --- /dev/null +++ b/tests/engine/gen3_spit_up.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.spit_up").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Spit Up") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index f2652c5fb..6e4ef3566 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -032184f68094e9ca +397df21576af62f1 diff --git a/tests/spit_up/public_effect.lua b/tests/spit_up/public_effect.lua new file mode 100644 index 000000000..84d309bfa --- /dev/null +++ b/tests/spit_up/public_effect.lua @@ -0,0 +1,71 @@ +-- Composed proof: also requires the canonical effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE",{effect="SPIT_UP",power=100})) + mod.exports.effect=assert(mod.content.moves:get("TACKLE")).effect + local selected="STOCKPILE" + mod.exports.release=function() selected="TACKLE" end + mod.exports.hits,mod.exports.calls={},{crit=0,damage=0} + mod.hooks:wrap("battle.enemy_action",function() return {move=selected,target=0} end) + mod.hooks:wrap("battle.crit",function() mod.exports.calls.crit=mod.exports.calls.crit+1;return true end) + mod.hooks:wrap("battle.damage",function(nextDamage,...) + mod.exports.calls.damage=mod.exports.calls.damage+1;return nextDamage(...) + end) + mod.events:on("battle.damage_dealt",function(e) + mod.exports.hits[#mod.exports.hits+1]={amount=e.damage,crit=e.crit,target=e.target.mon.nickname} + end) + end]] + local function configure(data, _, ids) + ids.STOCKPILE = "MOVE_254" + data.moves.MOVE_254 = { + id = "MOVE_254", + index = 254, + name = "STOCKPILE", + type = "NORMAL", + power = 0, + pp = 20, + accuracy = 100, + accuracyRaw = 255, + effect = "STOCKPILE_EFFECT", + gen3Effect = 160, + target = 16, + protectAffected = false, + } + data.moves[ids.TACKLE].target = 0 + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + local b = f.fresh("TACKLE", { enemy = true }).battle + T.eq(f.e.effect, "SPIT_UP", "canonical effect roundtrips through the real loader") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 161, "canonical effect selects native semantic 161") + for _, a in ipairs({ b.player, b.enemy }) do + a.curTypes, a.abilityOverride = { "NORMAL" }, false + a.curStats = { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.mon.hp = a.curStats, 10000 + end + b.player.mon.nickname = "TARGET" + b.enemy.curMoves = { { id = f.ids.STOCKPILE, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + for i = 1, 2 do + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + end + f.e.release() + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + T.same( + f.e.hits, + { { amount = 90, crit = false, target = "TARGET" } }, + "canonical patch releases the real two-stack base" + ) + T.same( + f.e.calls, + { crit = 0, damage = 0 }, + "native set damage does not enter ordinary mod damage or critical hooks" + ) + T.eq(b.enemy.stockpile, 0, "release consumes the counter") + T.eq(b.enemy.curMoves[1].pp, 18, "real Stockpile casts consume their owned PP") + T.eq(b.enemy.curMoves[2].pp, 9, "real release consumes its owned PP") + f.close() +end +T.finish("composed public Spit Up effect") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 103c70598..418bc1905 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 037b40db1..aa591c2e6 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "helping_hand" then +if mode == "spit_up" then + rows = dofile(root .. "/tests/enemy_actions/spit_up.lua").run(root, backend, version) +elseif mode == "helping_hand" then rows = dofile(root .. "/tests/enemy_actions/helping_hand.lua").run(root, backend, version) elseif mode == "follow_me" then rows = dofile(root .. "/tests/enemy_actions/follow_me.lua").run(root, backend, version) From ade8abdf95d36953cb782ae01df8248ea4e0351b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:44:07 +0200 Subject: [PATCH 25/90] Execute native GBA Beat Up with live party damage --- .github/workflows/enemy-action-contract.yml | 1 + docs/known-differences.md | 8 + docs/native-gba-beat-up.md | 69 +++ docs/new-features.md | 8 + src/battle/EffectRegistry.lua | 24 +- src/battle/Gen3BeatUp.lua | 87 +++ src/battle/Gen3MoveEffects.lua | 13 + tests/beat_up/public_effect.lua | 52 ++ tests/enemy_actions/beat_up.lua | 535 +++++++++++++++++++ tests/engine/gen3_beat_up.lua | 6 + tests/goldens/fixture_fingerprint.txt | 2 +- tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 6 +- 13 files changed, 804 insertions(+), 9 deletions(-) create mode 100644 docs/native-gba-beat-up.md create mode 100644 src/battle/Gen3BeatUp.lua create mode 100644 tests/beat_up/public_effect.lua create mode 100644 tests/enemy_actions/beat_up.lua create mode 100644 tests/engine/gen3_beat_up.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index dd1d9a731..e10557165 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -12,6 +12,7 @@ on: - 'tests/engine/gen3_follow_me.lua' - 'tests/engine/gen3_helping_hand.lua' - 'tests/engine/gen3_spit_up.lua' + - 'tests/engine/gen3_beat_up.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/known-differences.md b/docs/known-differences.md index f0f1b78e9..e67c899a4 100644 --- a/docs/known-differences.md +++ b/docs/known-differences.md @@ -40,3 +40,11 @@ functional in the original game. The door layout lives in `tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies it straight through on ROM import. + +## Native GBA Beat Up party damage + +Beat Up now reads each healthy party member's level/species base Attack +and target species base Defense, with per-hit native critical/variation +rolls, live callback eligibility, Substitute continuation and last-strike +special counter memory. The effect revision intentionally changes the link +fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). diff --git a/docs/native-gba-beat-up.md b/docs/native-gba-beat-up.md new file mode 100644 index 000000000..bae2a8a11 --- /dev/null +++ b/docs/native-gba-beat-up.md @@ -0,0 +1,69 @@ +# Native GBA Beat Up party strikes + +Beat Up (Gen-3 effect 154) calculates a separate hit for each healthy, +non-egg party member. It reads that member's level and species base Attack, +and the target species' base Defense; the active battler's ordinary combat +stats, STAB and type effectiveness do not substitute for those values. +The ordinary multi-hit calculator previously reused one ordinary damage +result for the entire party. + +The native path reads the party after accuracy and rechecks eligibility +between hits. Mod callbacks can wake, poison, faint or change the species of +upcoming participants. The current user's live HP/status takes precedence +for its party slot. Each hit applies Helping Hand before its own native +critical and variation rolls. Ordinary `battle.damage` and `battle.crit` +callbacks do not run for this dedicated command. Accuracy and interruption +hooks remain active, and owned PP is consumed once. + +Remaining participants continue after breaking a Substitute and stop when +the target faints. Substitute event amounts are clipped to its remaining +HP. The source's scripted event metadata is retained: special damage, +neutral multiplier and `crit=false`, including when the actual hit was a +critical. Special counter memory keeps the last non-Substitute strike, +including zero HP loss under Endure, rather than summing the entire move. +Earlier-generation fallback behavior remains separate. + +## Reproduction + +```sh +luajit tests/engine/gen3_beat_up.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +# With the canonical public effect adapter (#121) integrated: +luajit tests/beat_up/public_effect.lua +``` + +The shared API-3 consumer is loaded through each engine's real mod SDK. +FireRed and Emerald comparisons use unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, all four user positions and +six mixed party members. Cases include filtering, empty/blocked parties, +per-member base/level rounding, independent critical rolls, low variation, +Helping Hand, type/battle-stat independence, Protect, sleep, hidden targets, +misses, Substitute, K. o., Endure and changes inside public callbacks. +A second shared entry patches MEW's base Attack through the public content +API and verifies that the actual native executor consumes that patch. + +The focused suite passes **1,234 checks**. The full shared comparison passes +**40 isolated workers / 4,454 paired observations**, including the existing +action, target, Follow Me, Helping Hand and Spit Up contracts. +The full local quick run passes **88/97 engine suites** versus **87/96** on +clean #203; exact Counters of all **25 inherited FAIL lines across nine +suites** match with no additions. SDK 3/3 and B30 pass. The unchanged #203 host fails 152 +assertions in the new FireRed cases. Both sides use JIT-enabled LuaJIT v2.1 +commit `a4c064539ea931274d407e1249b4bb575424a0f4`, as pinned in the contract +workflow. RNG observations normalize native critical/variation operands; +they do not claim a complete raw engine RNG trace. + +This change stacks on #203. `BEAT_UP_EFFECT.rev = 1` intentionally changes +the fixture fingerprint from `397df21576af62f1` to `d518411055f17444`, and +only its `move_effects` section from `c62807cde682a669` to +`f45a80ce5e45ef42`. Removing only that revision restores the prior digest; +all six other populated sections remain unchanged. + +The native adjustment schedule for Focus Band, joined multi-trainer/link +parties, intro/tutorial controllers, animation fidelity and full gameplay +are separate remaining acceptance surfaces. Synthetic imported-shaped rows +and ordinary side parties are used here; a complete ROM playthrough or all +held-item/controller parity is not claimed. Gen1 remains read-only and +requires no source change or publication. diff --git a/docs/new-features.md b/docs/new-features.md index 532cbf3ae..591d00660 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -508,3 +508,11 @@ file pickers are ordinary desktop dialogs and still appear normally, and a run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt out. + +## Native GBA Beat Up party damage + +Beat Up now reads each healthy party member's level/species base Attack +and target species base Defense, with per-hit native critical/variation +rolls, live callback eligibility, Substitute continuation and last-strike +special counter memory. The effect revision intentionally changes the link +fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 186595dac..944cd89b1 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -278,6 +278,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end + if record and record.perHitDamage then + local nextDamage, nextInfo = record.perHitDamage(ctx, h, dmg, info) + if nextDamage == nil then break end + dmg, info = nextDamage, nextInfo or info + end local hitRow if h == 1 then hitRow = battle.moveAnimRow @@ -319,12 +324,16 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- on someone local substituteBefore = target.substituteHP local dealt = battle:applyDamage(target, hitDamage, true) - if info.gbaSetDamage and substituteBefore then dealt = math.min(dealt, substituteBefore) end + if (info.gbaSetDamage or info.gbaBeatUp) and substituteBefore then dealt = math.min(dealt, substituteBefore) end totalDealt = totalDealt + dealt -- MIRROR COAT is COUNTER's special twin and needs the SPECIAL half of -- the damage kept separately; battle.lastDamage is shared by both and -- cannot answer for it. Cleared at the head of every turn. - if dealt > 0 then + if info.gbaBeatUp then + -- Scripted Beat Up records each strike, including Endure's zero HP + -- loss; Substitute strikes leave the previous counter memory alone. + if not hadSub then target.specialDamageTaken = dealt end + elseif dealt > 0 then if require("src.battle.Damage").isSpecial(move.type) then target.specialDamageTaken = (target.specialDamageTaken or 0) + dealt else @@ -355,20 +364,23 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Keep explicit custom/fixed damage and the earlier generations' events. if Runtime.wants("battle.damage_dealt") and (dealt > 0 or move.power ~= 0 or (record and record.chooseDamage) or not require("src.core.GameVersion").isGen3()) then + local eventCritical = info.crit + if info.gbaBeatUp then eventCritical = false end Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, - damage = dealt, crit = info.crit, typeMult = info.eventTypeMult or info.typeMult, - substitute = info.gbaSetDamage and hadSub or nil, + damage = dealt, crit = eventCritical, typeMult = info.eventTypeMult or info.typeMult, + substitute = (info.gbaSetDamage or info.gbaBeatUp) and hadSub or nil, + kind = info.gbaBeatUp and "special" or nil, }) end - if hadSub and not target.substituteHP then + if hadSub and not target.substituteHP and not info.gbaBeatUp then -- AttackSubstitute: breaking the substitute ends a multi-hit move brokeSub = true break end end hits = landed > 0 and landed or hits - if hits > 1 then + if hits > 1 and not info.gbaBeatUp then -- player: _MultiHitText; enemy: _HitXTimesText (always plural) if user.isPlayer then battle:sayNext(Strings("Hit the enemy\n%d times!", hits)) diff --git a/src/battle/Gen3BeatUp.lua b/src/battle/Gen3BeatUp.lua new file mode 100644 index 000000000..b5a067d9c --- /dev/null +++ b/src/battle/Gen3BeatUp.lua @@ -0,0 +1,87 @@ +-- Gen-3 Beat Up uses party species/level, not the user's battle stats. +local Damage = require("src.battle.Damage") +local HelpingHand = require("src.battle.Gen3HelpingHand") +local B = {} +local function partyFor(ctx) + local battle, user = ctx.battle, ctx.user + if user.isPlayer then + if battle.playerParty then + return battle.playerParty + end + if battle.ally and user.position == 2 then + return battle.ally.party or {} + end + return battle.game and battle.game.save and battle.game.save.party or {} + end + if user.position == 3 and battle.enemyPartyB then + return battle.enemyPartyB + end + return battle.enemyParty or { user.mon } +end +local function userIndex(ctx, party) + for i, mon in ipairs(party) do + if mon == ctx.user.mon then + return i + end + end + return ctx.user.partyIndex +end +local function eligible(ctx, mon, index) + local hp, status = mon.hp, mon.status + if index == ctx._gen3BeatUpUserIndex then + hp, status = ctx.user.mon.hp, ctx.user.mon.status + end + return (tonumber(hp) or 0) > 0 + and mon.species ~= nil + and mon.species ~= 0 + and not mon.isEgg + and (status == nil or status == 0) +end +local function base(ctx, species, stat) + local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[species] + return def and def.baseStats and def.baseStats[stat] or 50 +end +function B.prepare(ctx) + local party = partyFor(ctx) + ctx._gen3BeatUpParty, ctx._gen3BeatUpCursor = party, 0 + ctx._gen3BeatUpUserIndex = userIndex(ctx, party) + for i, mon in ipairs(party) do + if eligible(ctx, mon, i) then + return true + end + end + return false +end +function B.nextDamage(ctx) + local party = ctx._gen3BeatUpParty + while true do + local index = ctx._gen3BeatUpCursor + 1 + ctx._gen3BeatUpCursor = index + local mon = party[index] + if not mon then + return nil + end + if eligible(ctx, mon, index) then + local amount = Damage.base( + tonumber(mon.level) or 1, + ctx.move.power or 10, + base(ctx, mon.species, "attack"), + base(ctx, ctx.target.mon.species, "defense") + ) + 2 + amount = HelpingHand.damage(ctx.user, amount) + local def = ctx.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or tostring(mon.species) + if not ctx.user.isPlayer then + name = "Enemy " .. name + end + ctx.say(ctx.battle:romText("STRINGID_PKMNATTACK", "%s's attack!", name)) + local critical = Damage.critRoll(ctx.ruleset, ctx.user, ctx.move.id, ctx.rng, false, ctx.data) + if critical then + amount = amount * 2 + end + amount = math.max(1, math.floor(amount * ctx.rng(85, 100) / 100)) + return amount, { crit = critical, typeMult = 10, eventTypeMult = 10, gbaBeatUp = true } + end + end +end +return B diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index f6f6e6693..2bee59976 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -1030,7 +1030,20 @@ F.FUTURE_SIGHT_EFFECT = { -- BEAT UP strikes once per healthy party member. F.BEAT_UP_EFFECT = { + rev = 1, + chooseDamage = function(ctx) + if not require("src.core.GameVersion").isGen3() then return ctx.computeDamage({ rng = ctx.rng }) end + if not require("src.battle.Gen3BeatUp").prepare(ctx) then return nil, Strings("But, it failed!") end + return 0, { crit = false, typeMult = 10, gbaBeatUp = true } + end, + perHitDamage = function(ctx, _, damage, info) + if not require("src.core.GameVersion").isGen3() then return damage, info end + return require("src.battle.Gen3BeatUp").nextDamage(ctx) + end, hitCount = function(ctx) + -- The party is read after accuracy, and eligibility can change in a mod's + -- hit callback. Six is the valid party bound; perHitDamage stops early. + if require("src.core.GameVersion").isGen3() then return 6 end local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save and ctx.battle.game.save.party if not party then return 1 end diff --git a/tests/beat_up/public_effect.lua b/tests/beat_up/public_effect.lua new file mode 100644 index 000000000..06011f185 --- /dev/null +++ b/tests/beat_up/public_effect.lua @@ -0,0 +1,52 @@ +-- Composed proof: requires the public canonical effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE",{effect="BEAT_UP",power=10})) + assert(mod.content.pokemon:patch("CHARMANDER",{baseStats={attack=133,defense=40}})) + mod.exports.effect=assert(mod.content.moves:get("TACKLE")).effect + mod.exports.attack=assert(mod.content.pokemon:get("CHARMANDER")).baseStats.attack + mod.exports.hits,mod.exports.calls={},{crit=0,damage=0} + mod.hooks:wrap("battle.enemy_action",function() return {move="TACKLE",target=0} end) + mod.hooks:wrap("battle.crit",function() mod.exports.calls.crit=mod.exports.calls.crit+1;return true end) + mod.hooks:wrap("battle.damage",function(nextDamage,...) + mod.exports.calls.damage=mod.exports.calls.damage+1;return nextDamage(...) + end) + mod.events:on("battle.damage_dealt",function(e) + mod.exports.hits[#mod.exports.hits+1]={amount=e.damage,crit=e.crit,kind=e.kind} + end) + end]] + local function configure(data, _, ids) + data.moves[ids.TACKLE].target = 0 + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + local b = f.fresh("TACKLE", { enemy = true }).battle + T.eq(f.e.effect, "BEAT_UP", "canonical effect roundtrips through the real loader") + T.eq(f.e.attack, 133, "public species base patch loads") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 154, "canonical effect selects native semantic 154") + local second = require("src.mods.Merge").deepCopy(b.enemy.mon) + b.enemy.mon.level, second.level = 10, 30 + b.enemyParty = { b.enemy.mon, second } + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return 100 + end + if lo == 1 and hi == 16 then + return 16 + end + return lo or 0 + end + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + T.same( + f.e.hits, + { { amount = 5, crit = false, kind = "special" }, { amount = 11, crit = false, kind = "special" } }, + "the patched native effect reads each party level and the public species base" + ) + T.same(f.e.calls, { crit = 0, damage = 0 }, "scripted strikes bypass ordinary damage and critical hooks") + T.eq(b.enemy.curMoves[1].pp, 9, "the whole sequence consumes owned PP once") + T.eq(b.player.specialDamageTaken, 11, "counter memory keeps the final strike") + f.close() +end +T.finish("composed public Beat Up effect") diff --git a/tests/enemy_actions/beat_up.lua b/tests/enemy_actions/beat_up.lua new file mode 100644 index 000000000..db1c72c38 --- /dev/null +++ b/tests/enemy_actions/beat_up.lua @@ -0,0 +1,535 @@ +local M = {} +function M.run(root, backend, version, patchStats) + local native = backend == "gen1" + local species = { + { "CHARMANDER", 4, 60, 40 }, + { "CHARMELEON", 5, 90, 70 }, + { "PIDGEY", 16, 110, 30 }, + { "NIDORAN_F", 29, 45, 50 }, + { "MEW", 151, 100, 100 }, + } + local function configure(data, engine, ids, rom, names) + for _, r in ipairs({ + { "BEAT_UP", 251, 154, "BEAT_UP_EFFECT", 10, 0, 0 }, + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5 }, + }) do + local name, num, effect, handler, power, target, priority = unpack(r) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(num) + ids[name], names[num] = key, name + if engine == "gen1" then + local d = { + effect = effect, + power = power, + type = 17, + target = target, + priority = priority, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 2, + secondaryChance = 0, + } + data.gen3Moves._rom[num], rom[num], data.gen3Pokemon._moveNames[num] = d, d, name + else + data.moves[key] = { + id = key, + index = num, + name = name, + gen3Effect = effect, + effect = handler, + power = power, + type = "DARK", + target = target, + priority = priority, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + makesContact = false, + } + end + end + for _, r in ipairs(species) do + local name, num, attack, defense = unpack(r) + if engine == "gen1" then + data.gen3Pokemon._stats[num] = { hp = 50, atk = attack, def = defense, spe = 50, spa = 50, spd = 50 } + data.gen3Pokemon._types[num] = { 0, 0 } + else + local d = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + d.id, d.name, d.index, d.dex, d.evolutions, d.types = name, name, num, num, {}, { "NORMAL" } + d.baseStats = + { hp = 50, attack = attack, defense = defense, speed = 50, special = 50, spatk = 50, spdef = 50 } + d.evYield = { attack = 1 } + data.pokemon[name] = d + end + end + if engine == "gen2" then + data.type_chart.types = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + DARK = { id = "DARK", index = 17, category = "special" }, + GHOST = { id = "GHOST", index = 7, category = "physical" }, + FIGHTING = { id = "FIGHTING", index = 1, category = "physical" }, + } + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "DARK", defender = "FIGHTING", multiplier = 5 }, + { attacker = "DARK", defender = "GHOST", multiplier = 20 }, + } + end + end + local entry + if patchStats then + entry = [[return function(mod) + assert(mod.content.pokemon:patch("MEW", {baseStats={attack=133}})) + mod.exports.baseAttack=assert(mod.content.pokemon:get("MEW")).baseStats.attack + local choice + mod.hooks:wrap("battle.enemy_action",function(nextAction,...) + if choice then return choice(nextAction,...) end + return nextAction(...) + end) + mod.exports.choose=function(callback) choice=callback end + mod.exports.api=mod + end]] + end + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + configure, + entry + ) + local T, rows, moves, hits, calls, trace = f.T, {}, {}, {}, { crit = 0, damage = 0 }, {} + if patchStats then + T.eq(f.e.baseAttack, 133, "the identical API-3 species patch loads through both real SDKs") + end + local mutate + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + calls.crit = calls.crit + 1 + return true + end) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + amount = e.damage, + crit = e.crit, + typeMult = e.typeMult, + kind = e.kind, + substitute = e.substitute or false, + } + if mutate then + mutate(#hits) + end + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + if id == 0 then + b.player = a + elseif id == 1 then + b.enemy = a + end + else + b:placeBattler(id, a) + end + end + local function make(mon, id, index) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = index } + ) + end + local a = require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + a.partyIndex = index + return a + end + local function fresh(spec, userId) + moves, hits, calls, trace, mutate = {}, {}, { crit = 0, damage = 0 }, {}, nil + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + local template = copy(b.player.mon) + local function party(prefix) + local p = {} + for i = 1, 6 do + local mon = copy(template) + local r = species[(i - 1) % #species + 1] + mon.species, mon.level, mon.hp, mon.nickname = native and r[2] or r[1], 5 + i * 5, 10000, prefix .. i + if native then + mon.maxHp, mon.ability, mon.moves, mon.pp, mon.maxPp = + 10000, 0, { 251, 270 }, { 20, 20 }, { 20, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + mon[k] = 50 + end + else + mon.moves = { { id = f.ids.BEAT_UP, pp = 20 }, { id = f.ids.HELPING_HAND, pp = 20 } } + mon.stats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + end + if spec.filter and i >= 3 then + if i == 3 then + mon.status = "PSN" + elseif i == 4 then + mon.hp = 0 + elseif i == 5 then + mon.isEgg = true + else + mon.status = 0 + end + end + if spec.allBlocked then + mon.status = "PSN" + end + if spec.onlyOne and i > 1 then + mon.status = "PSN" + end + p[i] = mon + end + return p + end + local player, enemy = party("A"), party("B") + b.double = true + if native then + b.playerParty, b.foeParty = player, enemy + else + f.game.save.party, b.enemyParty = player, enemy + end + for id = 0, 3 do + local p = id % 2 == 0 and player or enemy + local index = id < 2 and 1 or 2 + put(b, id, make(p[index], id, index)) + local a = at(b, id) + if native then + a.ability, a.mon.ability = 0, 0 + else + a.abilityOverride = false + end + end + if not native then + b:syncSides() + end + local user = at(b, userId) + local target = at(b, userId % 2 == 0 and 1 or 0) + local p = userId % 2 == 0 and player or enemy + if spec.empty then + if native then + if userId % 2 == 0 then + b.playerParty = {} + else + b.foeParty = {} + end + else + if userId % 2 == 0 then + f.game.save.party = {} + else + b.enemyParty = {} + end + end + end + if spec.typeName then + local ids = { NORMAL = 0, DARK = 17, GHOST = 7, FIGHTING = 1 } + if native then + target.type1, target.type2 = ids[spec.typeName], ids[spec.typeName] + else + target.curTypes = { spec.typeName } + end + end + if spec.normalMove then + if native then + require("src.core.game3.battle.moves")._rom[251].type = 0 + else + f.data.moves[f.ids.BEAT_UP].type = "NORMAL" + end + else + if native then + require("src.core.game3.battle.moves")._rom[251].type = 17 + else + f.data.moves[f.ids.BEAT_UP].type = "DARK" + end + end + if spec.battleStats then + if native then + user.mon.attack, target.mon.defense = 999, 1 + else + user.curStats.attack, target.curStats.defense = 999, 1 + end + user.stages.attack, target.stages.defense = 6, -6 + end + if spec.battleParty and not native and user.isPlayer then + b.playerParty = player + f.game.save.party = {} + end + if spec.defAbility then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.defAbility) + target.ability, target.mon.ability = ab, ab + else + target.abilityOverride = spec.defAbility + end + end + if spec.dynamic == "wake" then + p[3].status = "PSN" + end + if spec.sleep then + user.mon.status, user.sleepTurns = "SLP", 5 + end + if spec.protected then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if spec.hidden then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable = true + end + end + if spec.substitute then + target.substituteHP = spec.substitute + end + if spec.endure then + target.mon.hp = 4 + if native then + target.expEnduring = true + else + target.enduring = true + end + end + if spec.lowHP then + target.mon.hp = spec.lowHP + end + local phase, number = "critical", 0 + b.rng = function(lo, hi) + if lo == 1 and hi == 100 then + return spec.miss and 100 or 1 + end + if lo == 0 and hi == 255 then + return spec.miss and 255 or 0 + end + if + phase == "critical" + and ( + (lo == 0 and (hi == 15 or hi == 7 or hi == 3 or hi == 2 or hi == 1)) + or (lo == 1 and (hi == 16 or hi == 8 or hi == 4 or hi == 3 or hi == 2)) + ) + then + number = number + 1 + local choices = spec.critical or { false } + local critical = choices[(number - 1) % #choices + 1] + phase = "variation" + trace[#trace + 1] = { kind = "critical", value = critical } + return critical and lo or hi + end + if (lo == 85 and hi == 100) or (phase == "variation" and lo == 0 and hi == 15) then + local variation = spec.variation or 100 + phase = "critical" + trace[#trace + 1] = { kind = "variation", value = variation } + return lo == 85 and variation or 100 - variation + end + return lo or 0 + end + return b, user, target, p + end + local function pp(a) + return native and a.mon.pp[1] or a.curMoves[1].pp + end + local function use(b, id) + local target = id % 2 == 0 and 1 or 0 + f.e.choose(function() + return { move = "BEAT_UP", target = target } + end) + if native then + local C = require("src.core.game3.battle.commands") + local a = id % 2 == 0 and C.playerAction(b, 1, 1, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + a.target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + local a = id % 2 == 0 and at(b, id).curMoves[1] or b:enemyAction(at(b, id)) + b:executeAction(at(b, id), at(b, target), a) + end + end + local specs = { + { name = "mixed-team" }, + { name = "filtered", filter = true }, + { name = "all-blocked", allBlocked = true }, + { name = "one", onlyOne = true }, + { name = "empty", empty = true }, + { name = "resist", typeName = "FIGHTING" }, + { name = "super", typeName = "GHOST" }, + { name = "authored-normal-ghost", normalMove = true, typeName = "GHOST" }, + { name = "ignores-battle-stats", battleStats = true }, + { name = "critical-each", critical = { true } }, + { name = "critical-mixed", critical = { false, true, false, true } }, + { name = "variation", variation = 85 }, + { name = "protected", protected = true }, + { name = "sleep", sleep = true }, + { name = "hidden", hidden = true }, + { name = "sub-break", substitute = 1 }, + { name = "sub-survive", substitute = 1000 }, + { name = "ko-stop", lowHP = 1 }, + { name = "endure", endure = true }, + { name = "miss", miss = true }, + { name = "helped", helped = true }, + { name = "helped-critical", helped = true, critical = { true } }, + { name = "armor-critical", defAbility = "BATTLE_ARMOR", critical = { true } }, + { name = "battle-scoped-party", battleParty = true }, + { name = "callback-wake", dynamic = "wake" }, + { name = "callback-block", dynamic = "block" }, + { name = "callback-faint", dynamic = "faint" }, + { name = "callback-species", dynamic = "species" }, + { name = "callback-user-hp", dynamic = "userHP" }, + { name = "accuracy-wake", allBlocked = true, dynamic = "accuracy" }, + } + for id = 0, 3 do + for _, spec in ipairs(specs) do + local b, user, target, party = fresh(spec, id) + if spec.miss then + local m = native and require("src.core.game3.battle.moves")._rom[251] or f.data.moves[f.ids.BEAT_UP] + m.accuracy, m.accuracyRaw = 1, 1 + end + if spec.helped then + local helperId = (id + 2) % 4 + if native then + require("src.core.game3.battle.engine").resolveMove( + helperId, + helperId, + 270, + 2, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, helperId), at(b, helperId), at(b, helperId).curMoves[2]) + end + moves, hits, calls, trace = {}, {}, { crit = 0, damage = 0 }, {} + end + if spec.dynamic then + mutate = function(number) + if number ~= 1 then + return + end + if spec.dynamic == "wake" then + party[3].status = nil + elseif spec.dynamic == "block" then + party[3].status = "PSN" + elseif spec.dynamic == "faint" then + party[3].hp = 0 + elseif spec.dynamic == "species" then + party[2].species = native and 151 or "MEW" + elseif spec.dynamic == "userHP" then + user.mon.hp = 0 + end + end + end + local stopAccuracy + if spec.dynamic == "accuracy" then + stopAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) + party[3].status = nil + return nextAccuracy(...) + end) + end + local before = target.mon.hp + use(b, id) + if stopAccuracy then + stopAccuracy() + end + T.eq(pp(user), spec.sleep and 20 or 19, "native Beat Up spends PP once, not per participant") + T.same( + calls, + { crit = 0, damage = 0 }, + "native Beat Up bypasses ordinary damage and public critical callbacks" + ) + if spec.name == "mixed-team" then + T.eq(#hits, 6, "six healthy members each contribute a real strike") + end + if spec.name == "mixed-team" or spec.name == "critical-each" or spec.name == "variation" then + local amounts = {} + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.amount + end + local expected = spec.name == "critical-each" and { 6, 10, 14, 8, patchStats and 22 or 18, 12 } + or spec.name == "variation" and { 2, 4, 5, 3, patchStats and 9 or 7, 5 } + or { 3, 5, 7, 4, patchStats and 11 or 9, 6 } + T.same(amounts, expected, "native per-member level/base/critical/variation rounding") + end + if spec.dynamic == "accuracy" then + T.eq(#hits, 1, "eligibility is read after the public accuracy callback") + end + if spec.dynamic == "wake" then + T.eq(#hits, 6, "later participants can become eligible after a hit") + end + if spec.dynamic == "block" or spec.dynamic == "faint" then + T.eq(#hits, 5, "later participant conditions are read live") + end + if spec.name == "filtered" then + T.eq(#hits, 3, "poisoned, fainted and egg entries are excluded, numeric-zero status is healthy") + end + if spec.name == "all-blocked" or spec.name == "empty" then + T.eq(#hits, 0, "no eligible members produces no hit") + end + if spec.name == "sub-break" then + T.eq(#hits, 6, "later members continue after the Substitute breaks") + end + if spec.name == "ko-stop" then + T.eq(#hits, 1, "remaining strikes stop when the target faints") + end + local memory = native and (target.lastSpecialDamageTaken or 0) or (target.specialDamageTaken or 0) + if spec.endure then + T.eq(target.mon.hp, 1, "Endure retains one HP through the remaining strikes") + T.eq(#hits, 6, "zero-damage Endure strikes still execute") + T.eq(memory, 0, "the final zero-damage strike replaces earlier special counter memory") + end + if spec.name == "mixed-team" then + T.eq(memory, 6, "special counter memory keeps the final strike, not their sum") + end + rows[#rows + 1] = { + case = spec.name, + id = id, + hits = hits, + moves = moves, + trace = trace, + calls = calls, + amount = before - target.mon.hp, + pp = pp(user), + specialMemory = memory, + substitute = target.substituteHP or 0, + } + end + end + stopHit() + stopMove() + stopDamage() + stopCrit() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_beat_up.lua b/tests/engine/gen3_beat_up.lua new file mode 100644 index 000000000..56f89101c --- /dev/null +++ b/tests/engine/gen3_beat_up.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version) + require("tests.enemy_actions.beat_up").run(".", "gen2", version, true) +end +require("tests.modkit").finish("native GBA Beat Up") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 6e4ef3566..7d21d8663 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -397df21576af62f1 +d518411055f17444 diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 418bc1905..a49ccd4c1 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index aa591c2e6..3cad8e3e0 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,11 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "spit_up" then +if mode == "beat_up_content" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version, true) +elseif mode == "beat_up" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version) +elseif mode == "spit_up" then rows = dofile(root .. "/tests/enemy_actions/spit_up.lua").run(root, backend, version) elseif mode == "helping_hand" then rows = dofile(root .. "/tests/enemy_actions/helping_hand.lua").run(root, backend, version) From 5538fa421e2b5e9e570805731dafaeeee727e6e5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:44:42 +0200 Subject: [PATCH 26/90] Use the shared canonical Beat Up effect identifier --- docs/native-gba-beat-up.md | 3 +++ tests/beat_up/public_effect.lua | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/native-gba-beat-up.md b/docs/native-gba-beat-up.md index bae2a8a11..edb4bda4f 100644 --- a/docs/native-gba-beat-up.md +++ b/docs/native-gba-beat-up.md @@ -34,6 +34,9 @@ python3 tools/enemy-action-contract-check/check.py \ luajit tests/beat_up/public_effect.lua ``` +The composed public patch uses canonical `EFFECT_BEAT_UP` (semantic 154); +`BEAT_UP` is a native move name, not that public effect ID. + The shared API-3 consumer is loaded through each engine's real mod SDK. FireRed and Emerald comparisons use unchanged read-only Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, all four user positions and diff --git a/tests/beat_up/public_effect.lua b/tests/beat_up/public_effect.lua index 06011f185..710779ac2 100644 --- a/tests/beat_up/public_effect.lua +++ b/tests/beat_up/public_effect.lua @@ -4,7 +4,7 @@ local T = require("tests.modkit") require("src.mods.UnifiedEffects") for _, version in ipairs({ "firered", "emerald" }) do local entry = [[return function(mod) - assert(mod.content.moves:patch("TACKLE",{effect="BEAT_UP",power=10})) + assert(mod.content.moves:patch("TACKLE",{effect="EFFECT_BEAT_UP",power=10})) assert(mod.content.pokemon:patch("CHARMANDER",{baseStats={attack=133,defense=40}})) mod.exports.effect=assert(mod.content.moves:get("TACKLE")).effect mod.exports.attack=assert(mod.content.pokemon:get("CHARMANDER")).baseStats.attack @@ -23,7 +23,7 @@ for _, version in ipairs({ "firered", "emerald" }) do end local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) local b = f.fresh("TACKLE", { enemy = true }).battle - T.eq(f.e.effect, "BEAT_UP", "canonical effect roundtrips through the real loader") + T.eq(f.e.effect, "EFFECT_BEAT_UP", "canonical effect roundtrips through the real loader") T.eq(f.e.attack, 133, "public species base patch loads") T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 154, "canonical effect selects native semantic 154") local second = require("src.mods.Merge").deepCopy(b.enemy.mon) From 92ade2b2cf72238e3594cf46f5c670ef857ac2a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:47:01 +0200 Subject: [PATCH 27/90] Read the active target species for native Beat Up defense --- docs/native-gba-beat-up.md | 10 +++++++--- src/battle/Gen3BeatUp.lua | 2 +- tests/enemy_actions/beat_up.lua | 19 +++++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/native-gba-beat-up.md b/docs/native-gba-beat-up.md index edb4bda4f..aeed10844 100644 --- a/docs/native-gba-beat-up.md +++ b/docs/native-gba-beat-up.md @@ -44,15 +44,19 @@ six mixed party members. Cases include filtering, empty/blocked parties, per-member base/level rounding, independent critical rolls, low variation, Helping Hand, type/battle-stat independence, Protect, sleep, hidden targets, misses, Substitute, K. o., Endure and changes inside public callbacks. +The active target species is read separately from its retained party species, +while an attacker's transformed species does not replace the party-member +Attack operand. These cases seed the native battler fields; they do not claim +the entire Transform animation/controller path. A second shared entry patches MEW's base Attack through the public content API and verifies that the actual native executor consumes that patch. -The focused suite passes **1,234 checks**. The full shared comparison passes -**40 isolated workers / 4,454 paired observations**, including the existing +The focused suite passes **1,330 checks**. The full shared comparison passes +**40 isolated workers / 4,486 paired observations**, including the existing action, target, Follow Me, Helping Hand and Spit Up contracts. The full local quick run passes **88/97 engine suites** versus **87/96** on clean #203; exact Counters of all **25 inherited FAIL lines across nine -suites** match with no additions. SDK 3/3 and B30 pass. The unchanged #203 host fails 152 +suites** match with no additions. SDK 3/3 and B30 pass. The unchanged #203 host fails 168 assertions in the new FireRed cases. Both sides use JIT-enabled LuaJIT v2.1 commit `a4c064539ea931274d407e1249b4bb575424a0f4`, as pinned in the contract workflow. RNG observations normalize native critical/variation operands; diff --git a/src/battle/Gen3BeatUp.lua b/src/battle/Gen3BeatUp.lua index b5a067d9c..922d330ab 100644 --- a/src/battle/Gen3BeatUp.lua +++ b/src/battle/Gen3BeatUp.lua @@ -66,7 +66,7 @@ function B.nextDamage(ctx) tonumber(mon.level) or 1, ctx.move.power or 10, base(ctx, mon.species, "attack"), - base(ctx, ctx.target.mon.species, "defense") + base(ctx, ctx.target.species or ctx.target.mon.species, "defense") ) + 2 amount = HelpingHand.damage(ctx.user, amount) local def = ctx.data.pokemon[mon.species] diff --git a/tests/enemy_actions/beat_up.lua b/tests/enemy_actions/beat_up.lua index db1c72c38..ae818b98d 100644 --- a/tests/enemy_actions/beat_up.lua +++ b/tests/enemy_actions/beat_up.lua @@ -262,6 +262,12 @@ function M.run(root, backend, version, patchStats) f.data.moves[f.ids.BEAT_UP].type = "DARK" end end + if spec.targetSpecies then + target.species = native and 151 or "MEW" + end + if spec.userSpecies then + user.species = native and 151 or "MEW" + end if spec.battleStats then if native then user.mon.attack, target.mon.defense = 999, 1 @@ -386,6 +392,8 @@ function M.run(root, backend, version, patchStats) { name = "super", typeName = "GHOST" }, { name = "authored-normal-ghost", normalMove = true, typeName = "GHOST" }, { name = "ignores-battle-stats", battleStats = true }, + { name = "target-active-species", targetSpecies = true }, + { name = "user-active-species", userSpecies = true }, { name = "critical-each", critical = { true } }, { name = "critical-mixed", critical = { false, true, false, true } }, { name = "variation", variation = 85 }, @@ -471,12 +479,19 @@ function M.run(root, backend, version, patchStats) if spec.name == "mixed-team" then T.eq(#hits, 6, "six healthy members each contribute a real strike") end - if spec.name == "mixed-team" or spec.name == "critical-each" or spec.name == "variation" then + if + spec.name == "mixed-team" + or spec.name == "critical-each" + or spec.name == "variation" + or spec.name == "target-active-species" + or spec.name == "user-active-species" + then local amounts = {} for _, h in ipairs(hits) do amounts[#amounts + 1] = h.amount end - local expected = spec.name == "critical-each" and { 6, 10, 14, 8, patchStats and 22 or 18, 12 } + local expected = spec.name == "target-active-species" and { 2, 3, 4, 3, patchStats and 5 or 4, 3 } + or spec.name == "critical-each" and { 6, 10, 14, 8, patchStats and 22 or 18, 12 } or spec.name == "variation" and { 2, 4, 5, 3, patchStats and 9 or 7, 5 } or { 3, 5, 7, 4, patchStats and 11 or 9, 6 } T.same(amounts, expected, "native per-member level/base/critical/variation rounding") From 45220e4413ecbf8a70eb2021f5810ad4fca6f04b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:06:38 +0200 Subject: [PATCH 28/90] Align native GBA AI held-item knowledge and observation producers --- .../gen3-ai-item-knowledge-contract.yml | 34 + docs/gen3-ai-item-knowledge.md | 88 +++ src/battle/BattleState.lua | 56 +- src/battle/EffectRegistry.lua | 16 +- src/battle/Gen3AI.lua | 2 + src/battle/Gen3AIItemKnowledge.lua | 111 +++ src/battle/Gen3OHKO.lua | 74 ++ src/battle/Status.lua | 8 +- tests/engine/gen3_ai_item_knowledge.lua | 39 ++ tests/gen3_ai_item_knowledge/cases.lua | 634 ++++++++++++++++++ tests/gen3_ai_item_knowledge/worker.lua | 5 + .../check.py | 58 ++ 12 files changed, 1105 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/gen3-ai-item-knowledge-contract.yml create mode 100644 docs/gen3-ai-item-knowledge.md create mode 100644 src/battle/Gen3AIItemKnowledge.lua create mode 100644 src/battle/Gen3OHKO.lua create mode 100644 tests/engine/gen3_ai_item_knowledge.lua create mode 100644 tests/gen3_ai_item_knowledge/cases.lua create mode 100644 tests/gen3_ai_item_knowledge/worker.lua create mode 100644 tools/gen3-ai-item-knowledge-contract-check/check.py diff --git a/.github/workflows/gen3-ai-item-knowledge-contract.yml b/.github/workflows/gen3-ai-item-knowledge-contract.yml new file mode 100644 index 000000000..95c783b40 --- /dev/null +++ b/.github/workflows/gen3-ai-item-knowledge-contract.yml @@ -0,0 +1,34 @@ +name: native GBA AI item knowledge +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen3-ai-item-knowledge-${{ 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: Roxas2712/gen1recomp-Mac-fix + ref: 12c9e5b5a2325470d6bb266c9a1f37b7954b698e + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native item queries and observation producers + run: luajit tests/engine/gen3_ai_item_knowledge.lua + - name: Compare actual native AI and effect paths + run: | + mkdir -p /tmp/gen3-ai-item-knowledge + python3 tools/gen3-ai-item-knowledge-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gen3-ai-item-knowledge/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gen3-ai-item-knowledge + path: /tmp/gen3-ai-item-knowledge + if-no-files-found: ignore diff --git a/docs/gen3-ai-item-knowledge.md b/docs/gen3-ai-item-knowledge.md new file mode 100644 index 000000000..e00dd24ae --- /dev/null +++ b/docs/gen3-ai-item-knowledge.md @@ -0,0 +1,88 @@ +# Native GBA AI item knowledge + +Native AI scripts can now use `get_hold_effect` and `if_holds_item` through +Gen2Recomped's actual GBA interpreter. Both commands share a battle-local +observed-effect cache populated by actual effect producers. Querying it does +not consume RNG, mutate history, call a public item-substitution hook or +reveal a hidden current opponent item. + +The cartridge profiles intentionally have different rules: + +- FireRed records player-side observations in one shared side byte; enemy + observations do not populate it. Its getter reads that byte for player + battlers and actual item metadata for enemy battlers. +- Emerald keeps slot bytes. The current AI battler reads its actual item; + another battler passes its observed EFFECT byte through the ITEM metadata + reader. This native second lookup is preserved, including live metadata. +- Membership checks own-side physical item IDs and opposing observed bytes. + The two operand bytes are OR-ed as in retail; they are not silently decoded + as a corrected little-endian word. + +The cache lasts for the battle and preserves unrelated history families. +It records successful Focus Band rolls and actual Leftovers healing. Failed +rolls, full-HP Leftovers, pure reads, unrelated HP loss and berry consumption +do not invent item knowledge. Loaded metadata supports explicit NONE and +zero-percent effects. + +Producing the same observations requires the same damage-adjustment order. +GBA Focus Band rolls on nonlethal hits, through Substitute and before Endure. +GBA confusion uses the native two-value decision and 85–100 damage variance; +self-hits can trigger the band. GBA OHKO effects roll before Sturdy, +level/chance failure and preserve native Lock-On behavior. The separate +`Gen3OHKO` policy applies to the default record only; explicit custom gates, +damage or accuracy policies retain their behavior. Other generations retain +their existing confusion and OHKO policies. + +This is native battle behavior, not a new public inventory API. Other missing +AI commands remain gated rather than being declared supported. + +Primary references: +[FireRed AI commands](https://github.com/pret/pokefirered/blob/master/src/battle_ai_script_commands.c), +[Emerald AI commands](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_ai_script_commands.c), +[Emerald battle commands](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_script_commands.c), +[Emerald item reader](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/item.c). + +## Reproducible comparison + +```sh +luajit tests/engine/gen3_ai_item_knowledge.lua +python3 tools/gen3-ai-item-knowledge-contract-check/check.py --gen1 /path/to/fixed/gen1 +``` + +The checker requires the clean counterpart +`Roxas2712/gen1recomp-Mac-fix@12c9e5b5a2325470d6bb266c9a1f37b7954b698e`, +based on upstream dev `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +Gen1Recomp also needed its placeholder getter and item-history rules fixed; +the workflow explicitly checks out that counterpart. This is not an upstream +merge claim or agreement with the original placeholder implementation. + +One shared case file exercises actual VMs and actual direct-damage, +attack-canceller and OHKO paths. Only source ROM text presentation is +substituted; cartridge-shaped item and move records are input fixtures. +Coverage includes all 67 effect values, single/double selectors, physical +IDs, byte folding, live metadata, cache lifetime, no-leak reads and complete +RNG traces. It checks Focus Band success/failure, zero-percent, nonlethal and +lethal hits, Substitute, Endure, confusion, Sturdy, OHKO chance boundaries, +Protect, invulnerability and Lock-On. Host OHKO cases start at the damaging +effect stage after PP; they do not establish the whole move controller. + +## Local validation + +- Host focused checks: **1,491/1,491**; counterpart: **1,240/1,240**. +- Actual-engine comparison: **4 workers / 364 paired observations**, no + differences across FireRed and Emerald. +- Negative controls fail on unchanged upstream revisions. Gen1 dev reports + 116/113 failed checks and 117/127 differing observations (FR/EM); Gen2 main + reports 310/320 failed checks and 157/161 differing observations. +- Full host run: **65/75** engine suites, SDK **3/3**, B30 passes. The exact + **27 FAIL lines in ten suites** match clean main, which passes **64/74**. +- The source standard run passes all selected tiers, including **1,116/1,116** + engine suites and SDK **79/79**; game3 reports **425/425**, including **261 + self-skips** without cartridge caches. Expanded focused cases pass after + that run with unchanged source production code. +- Shared fixture formatting and workflow lint pass. + +Reports remain in ignored `tmp/ai-item-knowledge-qa/` and the external QA +folder of the same name. These are bounded CPU VM/effect checks, without +ROMs or generated assets in the commit. Full AI, graphical gameplay and +hosted CI approval remain separate validation work. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0e7da978d..1993688ec 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5125,8 +5125,11 @@ function BattleState:effectRecord(effect) return require("src.battle.Gen5MoveEffects").record(self, effect) end local effects = self.data.move_effects - if effects then return effects[effect] end - return MoveEffects.RECORDS[effect] + local record = effects and effects[effect] or not effects and MoveEffects.RECORDS[effect] + if GameVersion.isGen3() and effect == "OHKO_EFFECT" then + return require("src.battle.Gen3OHKO").record(record, MoveEffects.RECORDS.OHKO_EFFECT) + end + return record end -- the merged ball record (Catching.attempt handles the unknown-id default) @@ -6165,6 +6168,14 @@ function BattleState:focusBandHolds(battler, incoming) if battler.mon.hp <= 0 or incoming < battler.mon.hp then return false end return require("src.battle.Gen5HoldItems").focusBand(self, battler, incoming) end + if GameVersion.isGen3() then + if battler.mon.hp <= 0 then return false end + local effect, chance = HoldItems.effectOf(battler, "endure") + if effect ~= "FOCUS_BAND" then return false end + if (self.rng or love.math.random)(0, 99) >= chance then return false end + require("src.battle.Gen3AIItemKnowledge").record(self, battler, 39) + return incoming >= battler.mon.hp + end -- only a hit that would actually finish it, and only from full-ish health -- is NOT a condition: the cartridge lets a band save a Pokemon on 1 HP too if battler.mon.hp <= 0 or incoming < battler.mon.hp then return false end @@ -6228,6 +6239,7 @@ function BattleState:holdItemTrigger(battler, when) battler.mon.hp = math.min(max, battler.mon.hp + act.amount) self:drainNext(battler, battler.mon.hp) if act.effect == "LEFTOVERS" then + require("src.battle.Gen3AIItemKnowledge").record(self, battler, 43) self:sayNext(Strings("%s restored a little\nHP using its\n%s!", who, label)) else @@ -7805,10 +7817,13 @@ function BattleState:statusInterrupt(user, target) local dmg = self:computeDamage(user, user, { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, { rng = self.rng, forceCrit = false, typeless = true, - screens = target }) + screens = GameVersion.isGen3() and user or target }) + if GameVersion.isGen3() then + dmg = math.max(1, math.floor(dmg * self.rng(85, 100) / 100)) + end self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!")) self:clearVolatiles(user, true) - self:applyDamage(user, dmg) + self:applyDamage(user, dmg, false, GameVersion.isGen3() and { confusion = true } or nil) if user.mon.hp <= 0 then self:onFaint(user) end return true end @@ -8264,8 +8279,16 @@ end -- caller that cares about: the band saves a Pokemon from an attack and not -- from a sandstorm, its own recoil or a Substitute's upkeep, and applyDamage -- is the one door all of those come through. -function BattleState:applyDamage(target, dmg, fromMove) - if target.substituteHP then +function BattleState:applyDamage(target, dmg, fromMove, adjustment) + -- GBA adjusts every direct hit before Substitute/Endure, including a + -- successful Focus Band roll on a nonlethal hit and a zero-percent item. + local gba = GameVersion.isGen3() + adjustment = type(adjustment) == "table" and adjustment or nil + local prepared = gba and adjustment and adjustment.bandRolled == true + local selfHit = gba and adjustment and adjustment.confusion == true + local gbaBand = not prepared and gba and (fromMove == true or selfHit) and self:focusBandHolds(target, dmg) + if target.substituteHP and not selfHit then + local absorbed = math.min(math.max(0, dmg), target.substituteHP) target.substituteHP = target.substituteHP - dmg if target.substituteHP <= 0 then target.substituteHP = nil @@ -8273,7 +8296,12 @@ function BattleState:applyDamage(target, dmg, fromMove) else self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) end - return dmg + if prepared and adjustment.endured then + self:sayNext(Strings("%s endured\nthe hit!", displayName(target))) + elseif prepared and adjustment.hung then + self:sayNext(Strings("%s hung on\nusing its FOCUS BAND!", displayName(target))) + end + return gba and absorbed or dmg end -- FOCUS BAND, asked before the HP is taken: a one-in-ten hold leaves the -- Pokemon standing on a single HP instead of fainting. Asked here rather @@ -8282,11 +8310,17 @@ function BattleState:applyDamage(target, dmg, fromMove) -- what was actually dealt. -- ENDURE first: it is a certainty where the band is a roll, so a Pokemon -- that used ENDURE this turn never needs the band at all. - local endured = fromMove == true and target.enduring == true + local mayEndure = fromMove == true or selfHit + if selfHit and (target.substituteHP or 0) > 0 then mayEndure, gbaBand = false, false end + local endured = mayEndure and target.enduring == true and target.mon.hp > 0 and dmg >= target.mon.hp - local band = not endured and fromMove == true - and self:focusBandHolds(target, dmg) - if endured or band then dmg = math.max(0, target.mon.hp - 1) end + local band = gba and gbaBand or (not gba and not endured and fromMove == true + and self:focusBandHolds(target, dmg)) + if prepared then + endured, band = adjustment.endured, adjustment.hung + elseif endured or band then + dmg = math.max(0, target.mon.hp - 1) + end local dealt = math.min(dmg, target.mon.hp) if fromMove and dealt > 0 then target.hurtThisTurn = true end target.mon.hp = target.mon.hp - dealt diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 944cd89b1..463ec3280 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -120,7 +120,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- cartridge marks PROTECT-affected is stopped, which is the second use the -- extracted flags byte is put to; a move with no flags recorded (every Gen -- 1 and Gen 2 one) is treated as affected, which is that generation's rule. - if target.protecting and move.protectAffected ~= false then + local lockedOHKO = record and record.gbaOHKO and user.lockedOn == target + if target.protecting and move.protectAffected ~= false and not lockedOHKO then battle:cancelMoveAnim() if record and record.onProtect and record.onProtect(ctx) then return end battle:sayNext(Strings("%s\nprotected itself!", displayName(target))) @@ -132,7 +133,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- which reaches a target in the middle of FLY without being a never-miss -- move in any other respect -- hence the second, narrower field. if target.invulnerable and not neverMiss - and not (record and record.neverMissInvulnerable) then + and not (record and record.neverMissInvulnerable) and not lockedOHKO then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -152,6 +153,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not ok then battle:cancelMoveAnim() if failMsg then battle:sayNext(failMsg) end + if ctx.gbaOHKOMiss and record.onMiss then record.onMiss(ctx, "accuracy") end return end end @@ -249,9 +251,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.onMiss then record.onMiss(ctx, "floored") end return end - -- Focus Band belongs to the direct-damage seam below. It is evaluated per - -- damaging strike so a later hit of a multi-hit move can save a battler once - -- that particular strike becomes lethal; Substitute never consumes its RNG. + -- Focus Band belongs to the damage-adjustment seam below. GBA rolls on + -- every direct strike, including nonlethal/Substitute hits; older rules + -- keep their own eligibility. OHKO supplies its already evaluated result. battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) -- the hit blink + damage sound ride each animation row, placed BEFORE @@ -323,7 +325,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- ...and the one applyDamage a Gen 3 FOCUS BAND answers: a move landing -- on someone local substituteBefore = target.substituteHP - local dealt = battle:applyDamage(target, hitDamage, true) + local dealt = battle:applyDamage(target, hitDamage, true, info.damageAdjustment) if (info.gbaSetDamage or info.gbaBeatUp) and substituteBefore then dealt = math.min(dealt, substituteBefore) end totalDealt = totalDealt + dealt -- MIRROR COAT is COUNTER's special twin and needs the SPECIAL half of @@ -333,7 +335,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Scripted Beat Up records each strike, including Endure's zero HP -- loss; Substitute strikes leave the previous counter memory alone. if not hadSub then target.specialDamageTaken = dealt end - elseif dealt > 0 then + elseif dealt > 0 and not (hadSub and require("src.core.GameVersion").isGen3()) then if require("src.battle.Damage").isSpecial(move.type) then target.specialDamageTaken = (target.specialDamageTaken or 0) + dealt else diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index 42fe0fd4c..a9ae8df58 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -679,6 +679,8 @@ end -- Running one script over one move -- --------------------------------------------------------------------------- +require("src.battle.Gen3AIItemKnowledge").install(Gen3AI) + -- Every opcode a script can reach, followed through its branches. Memoised -- on the program table: the answer depends only on the cartridge. function Gen3AI.reachable(program, entry) diff --git a/src/battle/Gen3AIItemKnowledge.lua b/src/battle/Gen3AIItemKnowledge.lua new file mode 100644 index 000000000..d9bc816f5 --- /dev/null +++ b/src/battle/Gen3AIItemKnowledge.lua @@ -0,0 +1,111 @@ +-- Native GBA AI item reads and battle-long observed-effect bytes. +local K = {} +local function emerald() + return require("src.core.GameVersion").get() == "emerald" +end +local EFFECTS = {} +local i = 0 +for name in + ([[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]]):gmatch("%S+") +do + i = i + 1 + EFFECTS[name] = i +end +local function defFor(battle, item) + local defs = battle.data and battle.data.items or {} + if defs[item] then + return defs[item] + end + local numeric = tonumber(item) + if not numeric then + return nil + end + for _, def in pairs(defs) do + if type(def) == "table" and tonumber(def.index) == numeric then + return def + end + end +end +function K.item(battle, b) + local value = b and b.mon and (b.mon.item or b.mon.heldItem) + local def = value and defFor(battle, value) + return tonumber(def and def.index) or tonumber(value) or 0 +end +function K.raw(battle, item) + if item == 0 then + return 0 + end + local def = defFor(battle, item) + if not def then + return 0 + end + return tonumber(def.gen3HoldEffect) or tonumber(def.holdEffect) or EFFECTS[def.holdEffect] or 0 +end +function K.record(battle, b, effect) + if not require("src.core.GameVersion").isGen3() then + return + end + local id = b and b.position + if id == nil then + return + end + if not emerald() then + if id % 2 ~= 0 then + return + end + id = 0 + end + battle._aiHistory = battle._aiHistory or {} + local h = battle._aiHistory + h.itemEffects = h.itemEffects or {} + h.itemEffects[id] = effect +end +function K.observed(battle, b) + local h = battle._aiHistory + return tonumber(h and h.itemEffects and b and h.itemEffects[b.position]) or 0 +end +function K.get(vm, which) + local b = which == 1 and vm.user or vm.target + if not b then + return 0 + end + if emerald() then + local item = b.position == vm.user.position and K.item(vm.battle, b) or K.observed(vm.battle, b) + return K.raw(vm.battle, item) + end + if b.position and b.position % 2 == 0 then + local h = vm.battle._aiHistory + return tonumber(h and h.itemEffects and h.itemEffects[0]) or 0 + end + return K.raw(vm.battle, K.item(vm.battle, b)) +end +function K.install(ai) + ai.OPS.get_hold_effect = function(vm, args) + vm.result = K.get(vm, args[1]) + end + ai.OPS.if_holds_item = function(vm, args) + local b = ai.who(vm, args[1]) + local item = 0 + if b and b.position % 2 == vm.user.position % 2 then + item = K.item(vm.battle, b) + else + item = K.observed(vm.battle, b) + end + -- Native retail byte OR, retained instead of inventing a corrected word. + local value = tonumber(args[2]) or 0 + local bit = require("bit") + if bit.bor(value % 256, math.floor(value / 256) % 256) == item then + return args[#args] + end + end +end +return K diff --git a/src/battle/Gen3OHKO.lua b/src/battle/Gen3OHKO.lua new file mode 100644 index 000000000..5c72bd62c --- /dev/null +++ b/src/battle/Gen3OHKO.lua @@ -0,0 +1,74 @@ +-- GBA's OHKO command adjusts held-item damage before Sturdy/level/chance. +local M = {} +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +local function gate(ctx) + local battle, user, target = ctx.battle, ctx.user, ctx.target + local Abilities = require("src.battle.Abilities") + local kind = Abilities.normalizeType(ctx.move.type) + if + require("src.battle.TypeChart").effectiveness(ctx.move.type, target.curTypes or target.mon.types or {}) + == 0 + or kind == "GROUND" and Abilities.of(target) == "LEVITATE" + then + return false, Strings("It doesn't affect\n%s!", ctx.displayName(target)) + end + ctx.gbaFocusBand = battle:focusBandHolds(target, target.mon.hp) + if Abilities.refusesOHKO(target) then + return false, Strings("%s's STURDY\nmade the move fail!", ctx.displayName(target)) + end + local userLevel, targetLevel = tonumber(user.mon.level) or 1, tonumber(target.mon.level) or 1 + if user.lockedOn == target and userLevel >= targetLevel then + return true + end + local chance = (tonumber(ctx.move.accuracy) or 30) + userLevel - targetLevel + if ctx.rng(1, 100) < chance and userLevel >= targetLevel then + return true + end + ctx.gbaOHKOMiss = true + return false, + userLevel < targetLevel and Strings("%s is unaffected!", ctx.displayName(target)) or Strings( + "%s's\nattack missed!", + ctx.displayName(user) + ) +end +local function chooseDamage(ctx) + local target = ctx.target + local endured = target.enduring == true + local hung = not endured and ctx.gbaFocusBand == true + local damage = target.mon.hp + if endured or hung then + damage = math.max(0, damage - 1) + end + return damage, + { + crit = false, + typeMult = 10, + ohko = not (endured or hung), + damageAdjustment = { bandRolled = true, endured = endured, hung = hung }, + } +end +function M.record(base, vanilla) + -- Explicit custom gate/damage/accuracy policies retain their own contract. + if + not base + or base.gate ~= vanilla.gate + or base.chooseDamage ~= vanilla.chooseDamage + or base.accuracyRaw ~= vanilla.accuracyRaw + or base.alwaysHits ~= vanilla.alwaysHits + then + return base + end + if cache[base] then + return cache[base] + end + local record = {} + for key, value in pairs(base) do + record[key] = value + end + record.gate, record.chooseDamage, record.alwaysHits = gate, chooseDamage, true + record.gbaOHKO = true + cache[base] = record + return record +end +return M diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..ee183de9e 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -291,8 +291,12 @@ function Status.beforeMove(battler, rng, battle) table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) else table.insert(msgs, Strings("%s\nis confused!", name(battler))) - -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) - if rng(0, 255) < 128 then + -- GBA's attack canceller flips a two-value roll. Older games keep + -- their byte-range convention; shared callbacks see the native ABI. + local selfHit + if require("src.core.GameVersion").isGen3() then selfHit = rng(0, 1) == 0 + else selfHit = rng(0, 255) < 128 end + if selfHit then return false, msgs, true -- hurt itself end end diff --git a/tests/engine/gen3_ai_item_knowledge.lua b/tests/engine/gen3_ai_item_knowledge.lua new file mode 100644 index 000000000..a88e6e02d --- /dev/null +++ b/tests/engine/gen3_ai_item_knowledge.lua @@ -0,0 +1,39 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gen3_ai_item_knowledge/cases.lua").run("gen2", version) +end +local T = require("tests.modkit") +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local draws = {} + local actor = { name = "TEST", isPlayer = true, mon = {}, confusedTurns = 3 } + local battle = { data = { constants = { generation = require("src.core.GameVersion").generation() } } } + local canMove, _, selfHit = require("src.battle.Status").beforeMove(actor, function(lo, hi) + draws[#draws + 1] = { lo, hi } + return 0 + end, battle) + T.eq(canMove, false, version .. " keeps its existing confusion decision") + T.eq(selfHit, true, version .. " keeps its existing self-hit decision") + T.same(draws, { { 0, 255 } }, version .. " keeps byte-range confusion RNG") + local proto = { data = {} } + setmetatable(proto, { __index = require("src.battle.BattleState") }) + local vanilla = require("src.battle.MoveEffects").RECORDS.OHKO_EFFECT + T.eq(proto:effectRecord("OHKO_EFFECT"), vanilla, version .. " keeps its existing OHKO policy") +end +require("src.core.GameVersion").set("emerald") +do + local vanilla = require("src.battle.MoveEffects").RECORDS.OHKO_EFFECT + local custom = {} + for k, v in pairs(vanilla) do + custom[k] = v + end + custom.gate = function() + return false, "custom" + end + local proto = setmetatable( + { data = { move_effects = { OHKO_EFFECT = custom } } }, + { __index = require("src.battle.BattleState") } + ) + T.eq(proto:effectRecord("OHKO_EFFECT"), custom, "explicit mod OHKO gate is not replaced") +end +require("tests.modkit").finish("native GBA AI item knowledge") diff --git a/tests/gen3_ai_item_knowledge/cases.lua b/tests/gen3_ai_item_knowledge/cases.lua new file mode 100644 index 000000000..7984cf713 --- /dev/null +++ b/tests/gen3_ai_item_knowledge/cases.lua @@ -0,0 +1,634 @@ +-- Real GBA interpreters and native held-effect/damage producers. +local C = {} +function C.run(backend, version) + local T, source = require("tests.modkit"), backend == "gen1" + require("src.core.GameVersion").set(version) + local VM = source and require("src.core.game3.battle.ai_vm") + local AI = not source and require("src.battle.Gen3AI") + local HostState = not source and require("src.battle.BattleState") + local Adapter = source and require("src.core.game3.battle.adapter") + local NativeHeld = source and require("src.core.game3.battle.held_items") + local restores, defs, raw = {}, {}, {} + local function patch(t, key, value) + local old = t[key] + t[key] = value + restores[#restores + 1] = function() + t[key] = old + end + end + local function addItem(id, effect, parameter, name) + local effectName = ({ + [0] = "NONE", + [1] = "RESTORE_HP", + [22] = "EVASION_UP", + [39] = "FOCUS_BAND", + [43] = "LEFTOVERS", + })[effect] + raw[id] = { + name = name or "ITEM " .. id, + pocket = "ITEMS", + holdEffect = effect, + holdEffectParam = parameter or 0, + } + defs["ITEM_" .. id] = { + index = id, + name = raw[id].name, + gen3HoldEffect = effect, + holdEffect = effectName or effect, + holdEffectParam = parameter or 0, + } + end + for i = 0, 66 do + addItem(1000 + i, i) + end + addItem(39, 0) + addItem(43, 0) + addItem(54, 0) + addItem(139, 1, 10, "ORAN BERRY") + addItem(196, 39, 10, "FOCUS BAND") + addItem(200, 43, 10, "LEFTOVERS") + addItem(0x1234, 43) + if source then + local Items = require("src.core.game3.items_data") + patch(Items, "_byId", raw) + patch(Items, "_byName", {}) + patch(Items, "_pack", { items = raw }) + patch(Adapter, "textSink", function(id) + return id + end) + local Moves, Pokemon = require("src.core.game3.battle.moves"), require("src.core.game3.pokemon") + local move = { + [32] = { effect = 38, power = 0, type = 0, accuracy = 30, pp = 5, target = 0, priority = 0, flags = 2 }, + [33] = { + effect = 0, + power = 35, + type = 0, + accuracy = 100, + pp = 35, + target = 0, + priority = 0, + flags = 0, + }, + } + patch(Moves, "_rom", move) + patch(Moves, "_romLoaded", true) + patch(Moves, "_linkRows", nil) + patch(Moves, "BY_NUM", { [32] = "HORN_DRILL", [33] = "TACKLE" }) + patch(Moves, "_numByName", { HORN_DRILL = 32, TACKLE = 33 }) + patch(Pokemon, "_moveNames", { [32] = "HORN DRILL", [33] = "TACKLE" }) + patch(Pokemon, "_romMoveNames", Pokemon._moveNames) + patch(Pokemon, "_battleMoves", move) + end + local function fresh(roll) + local b = { + battlers = {}, + double = true, + absent = {}, + over = false, + turn = 1, + playerSide = { hazards = {} }, + enemySide = { hazards = {} }, + trainer = { aiFlags = 1 }, + data = { items = defs, constants = {}, moves = { TEST = { index = 33, power = 35, type = "NORMAL" } } }, + queue = {}, + game = { save = { options = { animations = false } } }, + } + for id = 0, 3 do + b.battlers[id] = { + id = id, + position = id, + side = id % 2 == 0 and "player" or "enemy", + isPlayer = id % 2 == 0, + name = "TEST", + ability = 0, + abilityOverride = false, + type1 = 0, + type2 = 0, + curTypes = { "NORMAL" }, + stages = {}, + shownHP = 100, + items = defs, + item = 0, + mon = { + nickname = "TEST", + hp = 100, + maxHp = 100, + level = 30, + speed = 100 - id * 10, + item = 0, + ability = 0, + moves = { 33 }, + pp = { 10 }, + stats = { hp = 100, speed = 100 - id * 10 }, + }, + } + end + b.player, b.enemy = b.battlers[0], b.battlers[1] + b.playerParty, b.foeParty = + { b.battlers[0].mon, b.battlers[2].mon }, { b.battlers[1].mon, b.battlers[3].mon } + b.sides = + { { battlers = { b.battlers[0], b.battlers[2] } }, { battlers = { b.battlers[1], b.battlers[3] } } } + local draws = {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return roll or 0 + end + if not source then + setmetatable(b, { __index = HostState }) + end + return b, draws + end + local function item(b, id, value) + local who = b.battlers[id] + if source then + who.item, who.mon.item = value, value + else + who.mon.item = value ~= 0 and "ITEM_" .. value or 0 + end + end + local function history(b) + local h = b._aiHistory and b._aiHistory.itemEffects or {} + return { h[0] or 0, h[1] or 0, h[2] or 0, h[3] or 0 } + end + local function heal(b, id, moveTurn) + if source then + return NativeHeld.normal(Adapter.new(b), b.battlers[id], moveTurn) + end + return b:holdItemTrigger(b.battlers[id], moveTurn and "action" or "turn") + end + local function hit(b, id, damage, substitute, endure) + local target, user = b.battlers[id], b.battlers[id % 2 == 0 and 1 or 0] + target.substituteHP = substitute + if source then + target.expEnduring = endure + local M = require("src.core.game3.battle.engine").newContext( + user, + target, + 33, + 1, + Adapter.new(b), + b, + {}, + { hits = {} }, + {} + ) + require("src.core.game3.battle.effects.hit").applySetDamage(M, damage) + else + target.enduring = endure + b:applyDamage(target, damage, true) + end + end + local function query(b, which, value, getter, expected, label, targetId, userId) + local user, target = b.battlers[userId or 1], b.battlers[targetId or 0] + local scores = { 100 } + if source then + local ops = getter + and { + { op = "get_hold_effect", battler = which }, + { op = "if_equal", value = value, target = "bonus" }, + { op = "end" }, + } + or { { op = "if_holds_item", battler = which, item = value, target = "bonus" }, { op = "end" } } + local pack = { scripts = { main = ops, bonus = { { op = "score", delta = 5 }, { op = "end" } } } } + VM.run( + VM.new({ + pack = pack, + st = b, + user = user, + target = target, + scores = scores, + movesetIndex = 1, + rng = b.rng, + }), + "main" + ) + else + local code = getter + and { + { name = "get_hold_effect", args = { which }, next = 2 }, + { name = "if_equal", args = { value, 4 }, next = 3 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 5 }, + { name = "end", args = {} }, + } + or { + { name = "if_holds_item", args = { which, value, 3 }, next = 2 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 4 }, + { name = "end", args = {} }, + } + local program = { entries = { [0] = 1 }, code = code } + b.data.constants.gen3BattleAI = program + T.eq(AI.runs(program, 1), true, label .. " whole program is supported") + scores = AI.scoreMoves(b, user, target, { { id = "TEST", pp = 10 } }) + end + T.eq(scores and scores[1], expected and 105 or 100, label .. " branch result") + return scores and scores[1] or false + end + local rows = {} + for effect = 0, 66 do + local b, draws = fresh() + item(b, 1, 1000 + effect) + local result = query(b, 1, effect, true, true, "declared native effect " .. effect) + T.eq(#draws, 0, "metadata query does not roll") + T.eq(b._aiHistory, nil, "metadata query cannot reveal an item") + rows[#rows + 1] = { name = "own-effect-" .. effect, score = result, rng = draws } + end + for _, userId in ipairs({ 1, 3 }) do + for _, targetId in ipairs({ 0, 2 }) do + for _, seen in ipairs({ false, true }) do + for which = 0, 3 do + local b, draws = fresh() + for id = 0, 3 do + item(b, id, 200) + end + if seen then + b.battlers[targetId].mon.hp = 90 + T.eq(heal(b, targetId), true, "real Leftovers activates") + T.eq(b.battlers[targetId].mon.hp, 96, "real Leftovers heals max HP / 16") + end + local own = which == 1 + local expected = own and 43 or seen and version == "firered" and 43 or 0 + local label = "roles/" .. userId .. "/" .. targetId .. "/" .. which .. "/" .. tostring(seen) + local before = history(b) + local score = query(b, which, expected, true, true, label, targetId, userId) + T.same(history(b), before, "getter cannot learn/clear history") + T.eq(#draws, 0, "getter and Leftovers consume no RNG") + rows[#rows + 1] = { name = label, score = score, expected = expected, history = before } + end + end + end + end + for _, spec in ipairs({ + { name = "leftovers-active", item = 200, hp = 90, active = true, known = 43, hpAfter = 96 }, + { name = "leftovers-at-full", item = 200, hp = 100, active = false, known = 0, hpAfter = 100 }, + { + name = "leftovers-between-actions", + item = 200, + hp = 90, + moveTurn = true, + active = false, + known = 0, + hpAfter = 90, + }, + { name = "visible-berry-is-not-recorded", item = 139, hp = 50, active = true, known = 0, hpAfter = 60 }, + }) do + for id = 0, 3 do + local b, draws = fresh() + item(b, id, spec.item) + b.battlers[id].mon.hp = spec.hp + T.eq(heal(b, id, spec.moveTurn), spec.active, spec.name .. " actual activation") + T.eq(b.battlers[id].mon.hp, spec.hpAfter, spec.name .. " HP") + local h = history(b) + local slot = version == "firered" and 1 or id + 1 + local known = version == "firered" and id % 2 == 1 and 0 or spec.known + T.eq(h[slot], known, spec.name .. " profile history") + if known == 0 then + T.eq(b._aiHistory, nil, "non-recording events do not create history") + end + T.eq(#draws, 0, "normal item phase adds no random calls") + rows[#rows + 1] = { name = spec.name .. "/" .. id, hp = b.battlers[id].mon.hp, history = h } + end + end + for _, spec in ipairs({ + { name = "lethal-success", damage = 120, roll = 0, hp = 1, known = 39 }, + { name = "lethal-failure", damage = 120, roll = 10, hp = 0, known = 0 }, + { name = "nonlethal-success", damage = 10, roll = 0, hp = 90, known = 39 }, + { name = "nonlethal-failure", damage = 10, roll = 99, hp = 90, known = 0 }, + { name = "substitute-success", damage = 120, roll = 0, sub = 25, hp = 100, known = 39 }, + { name = "endure-success", damage = 120, roll = 0, endure = true, hp = 1, known = 39 }, + { name = "endure-failure", damage = 120, roll = 99, endure = true, hp = 1, known = 0 }, + }) do + for id = 0, 3 do + local b, draws = fresh(spec.roll) + item(b, id, 196) + hit(b, id, spec.damage, spec.sub, spec.endure) + local h = history(b) + local slot = version == "firered" and 1 or id + 1 + local known = version == "firered" and id % 2 == 1 and 0 or spec.known + T.eq(b.battlers[id].mon.hp, spec.hp, spec.name .. " actual adjusted HP") + T.eq(h[slot], known, spec.name .. " actual Focus Band observation") + T.same(draws, { { 0, 99 } }, spec.name .. " native adjustment roll even without lethal damage") + rows[#rows + 1] = { + name = spec.name .. "/" .. id, + hp = b.battlers[id].mon.hp, + sub = b.battlers[id].substituteHP or 0, + history = h, + rng = draws, + } + end + end + -- Real attack-canceller paths must record self-hit procs as well. + for _, spec in ipairs({ + { name = "self-hit-low-variance", confusion = 0, variance = 85, band = 0, hp = 89, known = 39 }, + { name = "self-hit-high-variance", confusion = 0, variance = 100, band = 0, hp = 87, known = 39 }, + { name = "self-hit-failed-band", confusion = 0, variance = 100, band = 99, hp = 87, known = 0 }, + { name = "self-hit-substitute", confusion = 0, variance = 100, band = 0, sub = 25, hp = 87, known = 39 }, + { name = "self-hit-lethal-band", confusion = 0, variance = 100, band = 0, start = 1, hp = 1, known = 39 }, + { name = "confused-but-moves", confusion = 1, hp = 100, known = 0 }, + }) do + local b, draws = fresh() + local user, target = b.player, b.enemy + item(b, 0, 196) + user.mon.hp = spec.start or 100 + user.substituteHP = spec.sub + user.mon.attack, user.mon.defense, user.mon.spAtk, user.mon.spDef = 50, 50, 50, 50 + user.mon.stats.attack, user.mon.stats.defense, user.mon.stats.special = 50, 50, 50 + user.mon.stats.spatk, user.mon.stats.spdef = 50, 50 + user.curStats, user.curTypes, user.abilityOverride = user.mon.stats, { "NORMAL" }, false + user.def = { types = { "NORMAL" }, baseStats = { speed = 100 } } + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + if lo == 0 and hi == 1 then + return spec.confusion + end + if lo == 85 and hi == 100 then + return spec.variance + end + if lo == 0 and hi == 99 then + return spec.band + end + return lo -- unexpected ranges remain visible in the complete trace + end + local ok, spent = pcall(function() + if source then + user.confusionTurns = 3 + local Engine = require("src.core.game3.battle.engine") + local M = Engine.newContext(user, target, 33, 1, Adapter.new(b), b, {}, { hits = {} }, {}) + return Engine.canceller(M) == false + end + user.confusedTurns = 3 + b.ruleset = require("src.battle.rulesets.gen3_emerald") + return b:statusInterrupt(user, target) + end) + T.eq(ok, true, spec.name .. " real canceller completes") + if not ok then + spent = false + end + local h = history(b) + T.eq(spent, spec.confusion == 0, spec.name .. " real canceller result") + T.eq(user.mon.hp, spec.hp, spec.name .. " real self-hit HP") + T.eq(user.substituteHP or 0, spec.sub or 0, spec.name .. " self-hit bypasses Substitute") + T.eq(h[1], spec.known, spec.name .. " held proc observation") + local expectedDraws = spec.confusion == 0 and { { 0, 1 }, { 85, 100 }, { 0, 99 } } or { { 0, 1 } } + T.same(draws, expectedDraws, spec.name .. " complete canceller RNG") + rows[#rows + 1] = { + name = spec.name, + hp = user.mon.hp, + sub = user.substituteHP or 0, + spent = spent, + history = h, + rng = draws, + } + end + do + local b, draws = fresh() + item(b, 0, 196) + if source then + Adapter.new(b):applyHpLoss(b.player, 10) + else + b:applyDamage(b.player, 10, false) + end + T.eq(b.player.mon.hp, 90, "non-move HP loss remains ordinary damage") + T.eq(b._aiHistory, nil, "non-move HP loss cannot reveal Focus Band") + T.eq(#draws, 0, "non-move HP loss does not roll Focus Band") + rows[#rows + 1] = { name = "non-move-damage", hp = b.player.mon.hp, history = history(b), rng = draws } + end + -- The real OHKO effect path must observe its roll before Sturdy and + -- chance/level failure, and must not roll a second time on a landed hit. + if not source then + require("src.battle.TypeChart").load({ + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "GROUND", defender = "FLYING", multiplier = 0 }, + }, + }, + }) + end + for _, spec in ipairs({ + { name = "ohko-landed-band", hp = 1, known = 39 }, + { name = "ohko-faster-target", speed = 200, hp = 1, known = 39 }, + { name = "ohko-band-before-sturdy", ability = "STURDY", hp = 100, known = 39, noHitRoll = true }, + { name = "ohko-band-before-level", level = 20, hp = 100, known = 39 }, + { name = "ohko-band-before-miss", hitRoll = 30, hp = 100, known = 39 }, + { name = "ohko-failed-band-miss", band = 99, hitRoll = 99, hp = 100, known = 0 }, + { name = "ohko-endure-failed-band", band = 99, endure = true, hp = 1, known = 0 }, + { name = "ohko-substitute-band", sub = 25, hp = 100, known = 39 }, + { name = "ohko-immune-before-band", ghost = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-protect-before-band", protect = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-semi-invulnerable", hidden = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-lock-on", hidden = true, lock = true, hp = 1, known = 39, noHitRoll = true }, + }) do + local b, draws = fresh() + local user, target = b.enemy, b.player + item(b, 0, 196) + user.mon.level = spec.level or 30 + target.mon.speed, target.mon.stats.speed = spec.speed or 90, spec.speed or 90 + target.substituteHP = spec.sub + target.expProtected, target.protecting = spec.protect, spec.protect + target.expEnduring, target.enduring = spec.endure, spec.endure + target.semiInvulnerable, target.invulnerable = spec.hidden and "air" or nil, spec.hidden + if spec.ghost then + target.type1, target.type2, target.curTypes = 7, 7, { "GHOST" } + end + if spec.ability then + target.ability, target.abilityOverride = 5, spec.ability + end + if spec.lock then + target.expLockedOn, target.expLockedOnById = 1, 1 + user.lockedOn = target + end + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + if lo == 0 and hi == 99 then + return spec.band or 0 + end + if lo == 1 and hi == 100 then + return spec.hitRoll or 1 + end + return lo + end + local ok, err = pcall(function() + if source then + user.mon.moves[1] = 32 + require("src.core.game3.battle.engine").resolveMove(user, target, 32, 1, Adapter.new(b), b, {}, {}) + else + b.ruleset = require("src.battle.rulesets.gen3_emerald") + local move = { + id = "OHKO", + index = 32, + power = 0, + type = "NORMAL", + accuracy = 30, + effect = "OHKO_EFFECT", + gen3Effect = 38, + protectAffected = true, + } + b.data.moves.OHKO = move + local Registry = require("src.battle.EffectRegistry") + local ctx = Registry.makeCtx(b, user, target, move, { id = "OHKO", pp = 5 }, false) + Registry.runDamaging(b, ctx, b:effectRecord("OHKO_EFFECT")) + end + end) + T.eq(ok, true, spec.name .. " actual effect path completes") + if not ok then + print("OHKO_ERROR " .. tostring(err)) + end + local expected = {} + if not spec.noRoll then + expected[1] = { 0, 99 } + if not spec.noHitRoll then + expected[2] = { 1, 100 } + end + end + T.eq(target.mon.hp, spec.hp, spec.name .. " HP") + T.eq(history(b)[1], spec.known, spec.name .. " observation") + T.same(draws, expected, spec.name .. " full effect RNG") + rows[#rows + 1] = { + name = spec.name, + ok = ok, + hp = target.mon.hp, + sub = target.substituteHP or 0, + history = history(b), + rng = draws, + } + end + -- A zero-percent Focus Band still spends the native adjustment draw. + raw[196].holdEffectParam, defs.ITEM_196.holdEffectParam = 0, 0 + do + local b, draws = fresh() + item(b, 0, 196) + hit(b, 0, 10) + T.eq(b.battlers[0].mon.hp, 90, "zero-percent item cannot endure") + T.eq(b._aiHistory, nil, "failed zero-percent item cannot reveal") + T.same(draws, { { 0, 99 } }, "zero-percent native item spends its roll") + rows[#rows + 1] = { name = "zero-percent-band", history = history(b), rng = draws } + end + raw[196].holdEffectParam, defs.ITEM_196.holdEffectParam = 10, 10 + for _, spec in ipairs({ + { name = "own-physical-item", which = 1, value = 200, expected = true }, + { name = "own-partner-item", which = 3, value = 200, expected = true }, + { name = "unseen-target-item", which = 0, value = 200, expected = false }, + { name = "unseen-target-partner", which = 2, value = 200, expected = false }, + { name = "unseen-target-zero", which = 0, value = 0, expected = true }, + { name = "observed-byte-not-item", which = 0, value = 200, expected = false, seen = true }, + { name = "observed-byte-compares", which = 0, value = 43, expected = true, seen = true }, + { name = "own-word-fold", which = 1, value = 0x1234, own = 54, expected = true }, + { name = "own-unfolded-word", which = 1, value = 0x1234, own = 0x1234, expected = false }, + }) do + local b, draws = fresh() + for id = 0, 3 do + item(b, id, 200) + end + if spec.own then + item(b, 1, spec.own) + end + if spec.seen then + b.player.mon.hp = 90 + heal(b, 0) + end + local score = query(b, spec.which, spec.value, false, spec.expected, spec.name) + T.eq(#draws, 0, "membership query adds no RNG") + rows[#rows + 1] = { name = spec.name, score = score, history = history(b) } + end + -- Named legacy metadata and live writes must agree with the numeric ABI. + for _, row in ipairs({ + { "RESTORE_HP", 1 }, + { "EVASION_UP", 22 }, + { "FOCUS_BAND", 39 }, + { "LEFTOVERS", 43 }, + }) do + local id, name, effect = 1200 + row[2], row[1], row[2] + addItem(id, effect) + defs["ITEM_" .. id].gen3HoldEffect = nil + defs["ITEM_" .. id].holdEffect = name + local b, draws = fresh() + item(b, 1, id) + local score = query(b, 1, effect, true, true, "named-metadata-" .. name) + T.eq(#draws, 0, "named metadata query adds no random calls") + rows[#rows + 1] = { name = "named-metadata-" .. name, score = score } + end + -- Clearing a physical item's live metadata must defeat the legacy table. + raw[200].holdEffect, defs.ITEM_200.gen3HoldEffect, defs.ITEM_200.holdEffect = 0, 0, "NONE" + do + local b = fresh() + item(b, 0, 200) + item(b, 1, 200) + b.player.mon.hp = 90 + T.eq(heal(b, 0), false, "explicit NONE metadata disables the native trigger") + local score = query(b, 1, 0, true, true, "live-metadata-cleared") + T.eq(b._aiHistory, nil, "cleared effect cannot reveal an item") + rows[#rows + 1] = { name = "live-metadata-cleared", score = score, hp = b.player.mon.hp } + end + raw[200].holdEffect, defs.ITEM_200.gen3HoldEffect, defs.ITEM_200.holdEffect = 43, 43, "LEFTOVERS" + do + local b, draws = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + item(b, 0, 196) -- A changed current item is not a new observation. + local expected = version == "firered" and 43 or 0 + local score = query(b, 0, expected, true, true, "cached-effect-does-not-peek") + local freshBattle = fresh() + local unknown = query(freshBattle, 0, 0, true, true, "fresh-battle-isolation") + T.eq(#draws, 0, "cached query is read-only") + rows[#rows + 1] = + { name = "cached-effect-does-not-peek", score = score, fresh = unknown, history = history(b) } + end + -- FireRed shares the player-side byte; Emerald's slots remain separate. + do + local b, draws = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + item(b, 2, 196) + hit(b, 2, 10) + local h = history(b) + T.same( + h, + version == "firered" and { 39, 0, 0, 0 } or { 43, 0, 39, 0 }, + "native double-slot history ownership" + ) + T.same(draws, { { 0, 99 } }, "only the actual Focus Band trigger rolls") + rows[#rows + 1] = { name = "double-history-ownership", history = h, rng = draws } + end + do + local b, draws = fresh() + item(b, 0, 196) + if source then + NativeHeld.of(b.player, "check", b) + else + require("src.battle.HoldItems").effectOf(b.player, "check") + end + T.eq(b._aiHistory, nil, "pure held-item reads do not reveal effects") + T.eq(#draws, 0, "pure held-item reads do not roll") + rows[#rows + 1] = { name = "pure-held-read", history = history(b), rng = draws } + end + do + local b, draws = fresh() + b.battlers[3], b.sides[2].battlers[2] = nil, nil + local score = query(b, 3, 0, false, true, "absent-partner-zero") + T.eq(b._aiHistory, nil, "absent partner cannot create knowledge") + T.eq(#draws, 0, "absent partner adds no RNG") + rows[#rows + 1] = { name = "absent-partner-zero", score = score } + end + -- Emerald's second ITEM lookup must read current metadata, not fake zero. + raw[43].holdEffect, defs.ITEM_43.gen3HoldEffect, defs.ITEM_43.holdEffect = 22, 22, "EVASION_UP" + do + local b = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + local expected = version == "emerald" and 22 or 43 + local score = query(b, 0, expected, true, true, "cached-byte-item-lookup") + rows[#rows + 1] = { name = "cached-byte-item-lookup", score = score, expected = expected } + end + raw[43].holdEffect, defs.ITEM_43.gen3HoldEffect, defs.ITEM_43.holdEffect = 0, 0, "NONE" + for i = #restores, 1, -1 do + restores[i]() + end + return rows +end +return C diff --git a/tests/gen3_ai_item_knowledge/worker.lua b/tests/gen3_ai_item_knowledge/worker.lua new file mode 100644 index 000000000..37a63bc70 --- /dev/null +++ b/tests/gen3_ai_item_knowledge/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/gen3_ai_item_knowledge/cases.lua").run(backend, version) +print("GEN3_AI_ITEM_KNOWLEDGE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA AI item knowledge") diff --git a/tools/gen3-ai-item-knowledge-contract-check/check.py b/tools/gen3-ai-item-knowledge-contract-check/check.py new file mode 100644 index 000000000..64046b1b1 --- /dev/null +++ b/tools/gen3-ai-item-knowledge-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Compare native GBA AI item queries and actual observation producers through both engines.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "12c9e5b5a2325470d6bb266c9a1f37b7954b698e" + + +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("--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) + runs, differences = [], [] + for version in ["firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", source), ("gen2", host)]: + run = subprocess.run([args.lua, str(host / "tests/gen3_ai_item_knowledge/worker.lua"), str(host), + backend, version], cwd=engine, capture_output=True, text=True, timeout=30) + prefix = "GEN3_AI_ITEM_KNOWLEDGE " + lines = [line.removeprefix(prefix) for line in run.stdout.splitlines() if line.startswith(prefix)] + if run.returncode or len(lines) != 1: + detail = "\n".join(line for line in run.stdout.splitlines() if not line.startswith(prefix)) + raise RuntimeError(f"{backend}/{version}: {detail[:3000]}{run.stderr[:3000]}") + value = json.loads(lines[0]) + observations.append(value) + runs.append({"backend": backend, "version": version, "observations": value}) + if len(observations[0]) != len(observations[1]): + differences.append({"version": version, "error": "observation count"}) + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + differences.append({"version": version, "row": index, "reference": left, "host": right}) + print("PASS" if observations[0] == observations[1] else "FAIL", version) + assert not git(source, "status", "--porcelain"), "reference changed" + report = {"reference_ref": REFERENCE, "reference_repository": "Roxas2712/gen1recomp-Mac-fix", + "reference_base_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "runs": runs, + "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + raise SystemExit(f"{len(differences)} differing GBA AI item observations") + + +if __name__ == "__main__": + main() From 03d68a41d2d25f3b51920655fa29c9602c3f11ca Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:57:12 +0200 Subject: [PATCH 29/90] Verify native Beat Up held-item adjustment across engines --- .github/workflows/enemy-action-contract.yml | 1 + docs/beat-up-held-item-contracts.md | 57 ++++++++ tests/enemy_actions/beat_up.lua | 143 ++++++++++++++++++- tests/engine/gen3_beat_up_adjustment.lua | 5 + tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 10 +- 6 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 docs/beat-up-held-item-contracts.md create mode 100644 tests/engine/gen3_beat_up_adjustment.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index e10557165..e97359f09 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -13,6 +13,7 @@ on: - 'tests/engine/gen3_helping_hand.lua' - 'tests/engine/gen3_spit_up.lua' - 'tests/engine/gen3_beat_up.lua' + - 'tests/engine/gen3_beat_up_adjustment.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/beat-up-held-item-contracts.md b/docs/beat-up-held-item-contracts.md new file mode 100644 index 000000000..b666f552d --- /dev/null +++ b/docs/beat-up-held-item-contracts.md @@ -0,0 +1,57 @@ +# Beat Up held-item adjustment contracts + +These contracts verify the composition of #206's native Beat Up executor +and #182's native held-item damage adjustment. They add no new runtime +behavior: both existing changes must reach the same build to match the +unchanged Gen1 reference. + +FireRed and Emerald query Focus Band once after each reached strike's +critical/variation calculation, including nonlethal hits, Substitute hits +and Endure's zero-damage hits. Each strike receives its own roll; a previous +successful nonlethal or Substitute roll does not protect a later strike. +The separate Ruby/Sapphire reference policy keeps move-local success state; +that policy is not selected by these FireRed/Emerald profiles and is not +emulated here. + +The same API-3 consumer and shared native fixture execute actual selected +moves on both engines. Seventeen cases at all four user positions cover: + +- No item, physical native Focus Band (196), and an imported-shaped custom + item with adjustable hold metadata (1196). +- Successful/failed nonlethal and lethal rolls, the strict 9/10 threshold, + and a zero-percent item that must still consume its random draw. +- Breaking/surviving Substitute, Endure, later successful rolls, removal + and equipping inside real public damage-event callbacks. +- A second move that cannot reuse a previous successful roll, owned PP, + per-hit public event amounts/metadata, special counter memory and the + precise number/order/value of Focus Band draws. + +Native physical IDs have built-in hold metadata in the reference. Parameter +mutation therefore uses the imported-shaped custom ID rather than pretending +that changing a cached physical row overrides that native table. The test +restores source-process fixture tables when finished; the Gen1 checkout is +never edited. + +```sh +luajit tests/engine/gen3_beat_up_adjustment.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The focused suite passes **1,672 checks**. The full comparison passes +**44 isolated workers / 4,622 paired observations** against clean read-only +Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, using JIT-enabled +LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`. The worker also retains +all previous action, target, Helping Hand, Follow Me, Spit Up and Beat Up +contracts. RNG observations normalize critical/variation operands; the new +Focus Band checks assert the actual 0..99 draws and their per-hit order, +not a complete raw engine RNG trace. + +Negative control: #206 without #182 fails **56 assertions** in the new +FireRed cases. The clean composed prerequisite is +`45220e44` (#206 plus #182); it already passes the new behavior. No built-in +record revision or fingerprint golden changes are necessary for test-only +coverage. Full gameplay, animations, joined multi-trainer/link parties, +Ruby/Sapphire controllers and other scripted damage executors remain +separate acceptance surfaces. diff --git a/tests/enemy_actions/beat_up.lua b/tests/enemy_actions/beat_up.lua index ae818b98d..06855dc8a 100644 --- a/tests/enemy_actions/beat_up.lua +++ b/tests/enemy_actions/beat_up.lua @@ -1,5 +1,6 @@ local M = {} -function M.run(root, backend, version, patchStats) +function M.run(root, backend, version, patchStats, options) + options = options or {} local native = backend == "gen1" local species = { { "CHARMANDER", 4, 60, 40 }, @@ -63,6 +64,20 @@ function M.run(root, backend, version, patchStats) end end if engine == "gen2" then + data.items.FOCUS_BAND = { + index = 1196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.NATIVE_FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } data.type_chart.types = { NORMAL = { id = "NORMAL", index = 0, category = "physical" }, DARK = { id = "DARK", index = 17, category = "special" }, @@ -103,6 +118,14 @@ function M.run(root, backend, version, patchStats) if patchStats then T.eq(f.e.baseAttack, 133, "the identical API-3 species patch loads through both real SDKs") end + local sourceItems, restoreItems + local bandDef = { name = "FOCUS BAND", pocket = "ITEMS", holdEffect = 39, holdEffectParam = 10 } + if native and options.adjustment then + sourceItems = require("src.core.game3.items_data") + restoreItems = { sourceItems._byId, sourceItems._byName, sourceItems._pack } + sourceItems._byId, sourceItems._byName, sourceItems._pack = + { [1196] = bandDef }, {}, { items = { [1196] = bandDef } } + end local mutate local stopCrit = f.e.api.hooks:wrap("battle.crit", function() calls.crit = calls.crit + 1 @@ -311,6 +334,19 @@ function M.run(root, backend, version, patchStats) if spec.substitute then target.substituteHP = spec.substitute end + if options.adjustment then + bandDef.holdEffectParam = spec.chance or 10 + if native then + target.item, target.mon.item = + spec.noItem and 0 or spec.item or 1196, spec.noItem and 0 or spec.item or 1196 + else + f.data.items.FOCUS_BAND.holdEffectParam = spec.chance or 10 + target.items, target.mon.item = f.data.items, spec.item == 196 and "NATIVE_FOCUS_BAND" or "FOCUS_BAND" + if spec.noItem then + target.mon.item = nil + end + end + end if spec.endure then target.mon.hp = 4 if native then @@ -322,8 +358,15 @@ function M.run(root, backend, version, patchStats) if spec.lowHP then target.mon.hp = spec.lowHP end - local phase, number = "critical", 0 + local phase, number, bandNumber = "critical", 0, 0 b.rng = function(lo, hi) + if lo == 0 and hi == 99 then + bandNumber = bandNumber + 1 + local values = spec.bands or { 99 } + local value = values[math.min(bandNumber, #values)] + trace[#trace + 1] = { kind = "focusBand", value = value } + return value + end if lo == 1 and hi == 100 then return spec.miss and 100 or 1 end @@ -416,6 +459,33 @@ function M.run(root, backend, version, patchStats) { name = "callback-user-hp", dynamic = "userHP" }, { name = "accuracy-wake", allBlocked = true, dynamic = "accuracy" }, } + if options.adjustment then + specs = { + { name = "no-band", noItem = true, lowHP = 4 }, + { name = "native-band-nonlethal", item = 196, bands = { 0, 99 }, lowHP = 4, firstProc = true }, + { name = "native-band-lethal", item = 196, bands = { 0 }, lowHP = 1 }, + { name = "nonlethal-proc", bands = { 0, 99 }, lowHP = 1000 }, + { name = "failed-procs", bands = { 99 }, lowHP = 4 }, + { name = "always-proc", bands = { 0 }, lowHP = 1 }, + { name = "nonlethal-proc-does-not-stick", bands = { 0, 99 }, lowHP = 4, firstProc = true }, + { + name = "substitute-proc-does-not-stick", + bands = { 0, 99 }, + lowHP = 4, + substitute = 1, + firstProc = true, + }, + { name = "substitute-survives", bands = { 0, 99 }, lowHP = 4, substitute = 1000 }, + { name = "endure-with-band", bands = { 0, 99 }, lowHP = 4, endure = true }, + { name = "zero-chance", chance = 0, bands = { 0 }, lowHP = 4 }, + { name = "chance-boundary-win", bands = { 9, 10 }, lowHP = 4, firstProc = true }, + { name = "chance-boundary-loss", bands = { 10 }, lowHP = 4 }, + { name = "late-proc", bands = { 99, 0, 99 }, lowHP = 4, firstProc = true, late = true }, + { name = "remove-after-proc", bands = { 0, 99 }, lowHP = 4, firstProc = true, remove = true }, + { name = "equip-after-hit", noItem = true, bands = { 0 }, lowHP = 4, equip = true }, + { name = "next-move-resets", bands = { 0 }, lowHP = 1, repeatMove = true }, + } + end for id = 0, 3 do for _, spec in ipairs(specs) do local b, user, target, party = fresh(spec, id) @@ -458,6 +528,20 @@ function M.run(root, backend, version, patchStats) end end end + if spec.remove or spec.equip then + mutate = function(number) + if number ~= 1 then + return + end + if native then + target.item, target.mon.item = spec.remove and 0 or 1196, spec.remove and 0 or 1196 + elseif spec.remove then + target.mon.item = nil + else + target.mon.item = "FOCUS_BAND" + end + end + end local stopAccuracy if spec.dynamic == "accuracy" then stopAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) @@ -467,10 +551,22 @@ function M.run(root, backend, version, patchStats) end local before = target.mon.hp use(b, id) + if spec.repeatMove then + T.eq(#hits, 6, "the first move completes six held-item saves") + bandDef.holdEffectParam = 0 + if not native then + f.data.items.FOCUS_BAND.holdEffectParam = 0 + end + use(b, id) + end if stopAccuracy then stopAccuracy() end - T.eq(pp(user), spec.sleep and 20 or 19, "native Beat Up spends PP once, not per participant") + T.eq( + pp(user), + spec.repeatMove and 18 or spec.sleep and 20 or 19, + "native Beat Up spends PP once, not per participant" + ) T.same( calls, { crit = 0, damage = 0 }, @@ -517,6 +613,44 @@ function M.run(root, backend, version, patchStats) if spec.name == "ko-stop" then T.eq(#hits, 1, "remaining strikes stop when the target faints") end + if options.adjustment then + local expectedHits, expectedHP = 6, 1 + if + spec.noItem and not spec.equip + or spec.name == "failed-procs" + or spec.chance == 0 + or spec.name == "chance-boundary-loss" + then + expectedHits, expectedHP = 2, 0 + elseif spec.firstProc then + expectedHits, expectedHP = spec.late and 3 or 2, 0 + elseif spec.name == "nonlethal-proc" then + expectedHP = 966 + elseif spec.name == "substitute-survives" then + expectedHP = 4 + elseif spec.repeatMove then + expectedHits, expectedHP = 7, 0 + end + T.eq(#hits, expectedHits, spec.name .. " native strike count") + T.eq(target.mon.hp, expectedHP, spec.name .. " native target HP") + local bands = 0 + for _, draw in ipairs(trace) do + if draw.kind == "focusBand" then + bands = bands + 1 + end + end + local expectedBands = spec.remove and 1 + or spec.equip and expectedHits - 1 + or spec.noItem and 0 + or expectedHits + T.eq(bands, expectedBands, "every reached strike adjusts exactly once, including Substitute/Endure") + for k = 1, #trace do + if trace[k].kind == "focusBand" then + T.eq(trace[k - 1] and trace[k - 1].kind, "variation", "Focus Band follows that strike's variance") + T.eq(trace[k - 2] and trace[k - 2].kind, "critical", "critical precedes that strike's adjustment") + end + end + end local memory = native and (target.lastSpecialDamageTaken or 0) or (target.specialDamageTaken or 0) if spec.endure then T.eq(target.mon.hp, 1, "Endure retains one HP through the remaining strikes") @@ -544,6 +678,9 @@ function M.run(root, backend, version, patchStats) stopMove() stopDamage() stopCrit() + if sourceItems then + sourceItems._byId, sourceItems._byName, sourceItems._pack = unpack(restoreItems, 1, 3) + end f.close() return rows end diff --git a/tests/engine/gen3_beat_up_adjustment.lua b/tests/engine/gen3_beat_up_adjustment.lua new file mode 100644 index 000000000..9414aedba --- /dev/null +++ b/tests/engine/gen3_beat_up_adjustment.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version, false, { adjustment = true }) +end +require("tests.modkit").finish("native GBA Beat Up held damage adjustment") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index a49ccd4c1..5688a02fa 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 3cad8e3e0..f9db890fb 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "beat_up_content" then +if mode == "beat_up_adjustment" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( + root, + backend, + version, + false, + { adjustment = true } + ) +elseif mode == "beat_up_content" then rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version, true) elseif mode == "beat_up" then rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version) From c2a1a032eab21646d025b8b9704b6553507e1a01 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:10:21 +0200 Subject: [PATCH 30/90] Read native Beat Up participants across both side parties --- .github/workflows/enemy-action-contract.yml | 1 + docs/known-differences.md | 7 + docs/native-gba-beat-up-side-parties.md | 67 ++++++ docs/new-features.md | 7 + src/battle/Gen3BeatUp.lua | 47 ++-- src/battle/Gen3MoveEffects.lua | 2 +- tests/enemy_actions/beat_up.lua | 213 ++++++++++++++++++- tests/engine/gen3_beat_up_side_party.lua | 5 + tests/goldens/fixture_fingerprint.txt | 2 +- tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 10 +- 11 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 docs/native-gba-beat-up-side-parties.md create mode 100644 tests/engine/gen3_beat_up_side_party.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index e97359f09..61c14a396 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -14,6 +14,7 @@ on: - 'tests/engine/gen3_spit_up.lua' - 'tests/engine/gen3_beat_up.lua' - 'tests/engine/gen3_beat_up_adjustment.lua' + - 'tests/engine/gen3_beat_up_side_party.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/known-differences.md b/docs/known-differences.md index e67c899a4..7049d7e0b 100644 --- a/docs/known-differences.md +++ b/docs/known-differences.md @@ -48,3 +48,10 @@ and target species base Defense, with per-hit native critical/variation rolls, live callback eligibility, Substitute continuation and last-strike special counter memory. The effect revision intentionally changes the link fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). + +## Native GBA Beat Up shared side parties + +Beat Up now reads the entire side party in two-trainer and player/ally +battles, retaining live slot replacement and the active user's HP/status. +Replacement benches remain owned separately. The reviewed effect revision +changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). diff --git a/docs/native-gba-beat-up-side-parties.md b/docs/native-gba-beat-up-side-parties.md new file mode 100644 index 000000000..c582c58c0 --- /dev/null +++ b/docs/native-gba-beat-up-side-parties.md @@ -0,0 +1,67 @@ +# Native GBA Beat Up side parties + +A two-trainer opponent side and a player/ally side each use one native party +for Beat Up. Gen2Recomped stores the trainers' parties separately for +replacement, payout and defeat handling. Its Beat Up helper previously used +only the active trainer's party, so three eligible members produced three +strikes where the reference used the combined six. + +Keep the owning arrays separate and expose a private, live indexed view to +the native Beat Up executor. Both positions use primary-party slots followed +by partner-party slots. A battle-scoped player party takes precedence over +the save party. Eligibility and replacement of an upcoming member are read +live; the active user's current HP/status still overrides its party slot, +including a detached active battler with an explicit native party index. + +The source captures the current party array for a move. Reassigning the +battle's entire party field in a hit callback therefore does not replace +that in-flight iteration. The host likewise retains its owning array +references for the move while reading each upcoming slot live. This avoids +copying a stale list of member objects or rereading a newly assigned party. +Native six-slot bounds and the ordinary single-owner fallback remain. + +## Verification + +```sh +luajit tests/engine/gen3_beat_up_side_party.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The same API-3 consumer and shared native fixture run on unchanged read-only +Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Gen1 uses the actual +native State constructor with player/foe ownership halves. Gen2 uses the +actual trainer constructor, `setAlly`, `addOpponentTrainer` and +`sendOutSecondPair`, with prebuilt imported-shaped party members. Source +ownership guards and host replacement benches are checked before action +execution. A separately seeded battle-scoped player view is also exercised; +this is not a new replacement/selection API. + +Eighteen cases at all four positions on FireRed/Emerald cover six/five +members, filtering, either owner having no eligible members, a fully blocked +side, a participant awakened in the accuracy hook, waking/blocking/replacing +an upcoming member in hit callbacks, whole-array reassignment, live active +HP and a detached active battler, Substitute, fainting, criticals and low +variation. Actual PP, hit amounts/order, public event metadata, callback +counts, special counter memory and semantic critical/variation observations +are compared. The focused suite passes **1,728 checks**; the complete matrix +passes **48 isolated workers / 4,766 paired observations**. #209 without this +change fails **58 assertions** in the new FireRed cases. +The full feature quick run passes **91/100 engine suites** versus **90/99** +on clean #209; exact Counters of the **25 inherited FAIL lines across nine +suites** match with no additions. SDK 3/3 and B30 pass. + +The runtime is JIT-enabled LuaJIT v2.1 commit +`a4c064539ea931274d407e1249b4bb575424a0f4`, also pinned in the contract +workflow. Full raw RNG-trace parity, graphical intros/animations, arbitrary +mid-move ownership reshaping, link/multi-owner controller execution and +complete gameplay remain separate surfaces. + +The feature stacks on #209. `BEAT_UP_EFFECT.rev` increases from 1 to 2, so +builds with different native party execution cannot silently negotiate the +same link fingerprint. The fixture digest changes from `d518411055f17444` +to `fdbfa30b20cbbaef`; only `move_effects` changes, from `f45a80ce5e45ef42` to +`e4d28f474c15cd8b`. Restoring revision 1 restores the prior full digest; all +six other populated sections remain identical. No Gen1 source edit or +publication is required. diff --git a/docs/new-features.md b/docs/new-features.md index 591d00660..a28430fce 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -516,3 +516,10 @@ and target species base Defense, with per-hit native critical/variation rolls, live callback eligibility, Substitute continuation and last-strike special counter memory. The effect revision intentionally changes the link fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). + +## Native GBA Beat Up shared side parties + +Beat Up now reads the entire side party in two-trainer and player/ally +battles, retaining live slot replacement and the active user's HP/status. +Replacement benches remain owned separately. The reviewed effect revision +changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). diff --git a/src/battle/Gen3BeatUp.lua b/src/battle/Gen3BeatUp.lua index 922d330ab..d72f8195e 100644 --- a/src/battle/Gen3BeatUp.lua +++ b/src/battle/Gen3BeatUp.lua @@ -2,25 +2,27 @@ local Damage = require("src.battle.Damage") local HelpingHand = require("src.battle.Gen3HelpingHand") local B = {} -local function partyFor(ctx) +-- The native side party is shared across both trainers. Keep the engine's +-- owning arrays separate, but retain live slot reads rather than copying +-- their members: a public hit callback may replace an upcoming bench mon. +local function partiesFor(ctx) local battle, user = ctx.battle, ctx.user if user.isPlayer then - if battle.playerParty then - return battle.playerParty - end - if battle.ally and user.position == 2 then - return battle.ally.party or {} - end - return battle.game and battle.game.save and battle.game.save.party or {} + local first = battle.playerParty or (battle.game and battle.game.save and battle.game.save.party) or {} + return first, battle.ally and battle.ally.party end - if user.position == 3 and battle.enemyPartyB then - return battle.enemyPartyB + return battle.enemyParty or { user.mon }, battle.enemyPartyB +end +local function member(ctx, index) + if index <= ctx._gen3BeatUpSplit then + return ctx._gen3BeatUpParty[index] end - return battle.enemyParty or { user.mon } + local second = ctx._gen3BeatUpSecondParty + return second and second[index - ctx._gen3BeatUpSplit] end -local function userIndex(ctx, party) - for i, mon in ipairs(party) do - if mon == ctx.user.mon then +local function userIndex(ctx, count) + for i = 1, count do + if member(ctx, i) == ctx.user.mon then return i end end @@ -42,10 +44,16 @@ local function base(ctx, species, stat) return def and def.baseStats and def.baseStats[stat] or 50 end function B.prepare(ctx) - local party = partyFor(ctx) - ctx._gen3BeatUpParty, ctx._gen3BeatUpCursor = party, 0 - ctx._gen3BeatUpUserIndex = userIndex(ctx, party) - for i, mon in ipairs(party) do + local first, second = partiesFor(ctx) + ctx._gen3BeatUpParty, ctx._gen3BeatUpSecondParty = first, second + ctx._gen3BeatUpSplit, ctx._gen3BeatUpCursor = #first, 0 + local count = #first + (second and #second or 0) + ctx._gen3BeatUpUserIndex = userIndex(ctx, count) + for i = 1, count do + local mon = member(ctx, i) + if not mon then + break + end if eligible(ctx, mon, i) then return true end @@ -53,11 +61,10 @@ function B.prepare(ctx) return false end function B.nextDamage(ctx) - local party = ctx._gen3BeatUpParty while true do local index = ctx._gen3BeatUpCursor + 1 ctx._gen3BeatUpCursor = index - local mon = party[index] + local mon = member(ctx, index) if not mon then return nil end diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 2bee59976..24b7ebe22 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -1030,7 +1030,7 @@ F.FUTURE_SIGHT_EFFECT = { -- BEAT UP strikes once per healthy party member. F.BEAT_UP_EFFECT = { - rev = 1, + rev = 2, chooseDamage = function(ctx) if not require("src.core.GameVersion").isGen3() then return ctx.computeDamage({ rng = ctx.rng }) end if not require("src.battle.Gen3BeatUp").prepare(ctx) then return nil, Strings("But, it failed!") end diff --git a/tests/enemy_actions/beat_up.lua b/tests/enemy_actions/beat_up.lua index 06855dc8a..b3a09fef6 100644 --- a/tests/enemy_actions/beat_up.lua +++ b/tests/enemy_actions/beat_up.lua @@ -226,17 +226,95 @@ function M.run(root, backend, version, patchStats, options) return p end local player, enemy = party("A"), party("B") - b.double = true - if native then - b.playerParty, b.foeParty = player, enemy + local split = spec.split or 3 + if options.sideParty then + for _, p in ipairs({ player, enemy }) do + if spec.five then + p[6] = nil + end + if spec.sideFilter then + p[2].status, p[3].hp, p[5].isEgg = "PSN", 0, true + end + if spec.sideChange == "wake" then + p[5].status = "PSN" + end + if spec.primaryBlocked then + for i = 1, split do + p[i].status = "PSN" + end + end + if spec.secondaryBlocked then + for i = split + 1, #p do + p[i].status = "PSN" + end + end + end + if native then + b = require("src.core.game3.battle.state").new({ + playerParty = player, + foeParty = enemy, + double = true, + playerHalf = split, + foeHalf = split, + partnerIndex = split + 1, + foePartnerIndex = split + 1, + }) + local State = require("src.core.game3.battle.state") + T.eq(State.ownsSlot(b, 0, split + 1), false, "player cannot replace from the ally's party") + T.eq(State.ownsSlot(b, 2, 1), false, "ally cannot replace from the player's party") + T.eq(State.ownsSlot(b, 1, split + 1), false, "first trainer cannot take the second trainer's bench") + T.eq(State.ownsSlot(b, 3, 1), false, "second trainer cannot take the first trainer's bench") + else + local function half(p, first, last) + local out = {} + for i = first, last do + out[#out + 1] = p[i] + end + return out + end + local primary, ally = half(player, 1, split), half(player, split + 1, #player) + local function trainer(id, p, first, last) + local slots = {} + for i = first, last do + slots[#slots + 1] = { prebuilt = p[i] } + end + f.data.trainers[id] = { id = id, name = id, baseMoney = 0, parties = { slots } } + end + f.data.trainers = f.data.trainers or {} + trainer("OPP_CONTRACT_A", enemy, 1, split) + trainer("OPP_CONTRACT_B", enemy, split + 1, #enemy) + f.game.save.party = primary + b = require("src.battle.BattleState").newTrainer(f.game, "OPP_CONTRACT_A", 1) + b:setAlly({ name = "ALLY", party = ally }) + T.eq(b:addOpponentTrainer("OPP_CONTRACT_B"), true, "native second trainer is installed") + b:sendOutSecondPair() + T.eq(b:benchFor(true, 0), primary, "player replacements remain in the owning party") + T.eq(b:benchFor(true, 2), ally, "ally replacements remain in the owning party") + T.eq(b:benchFor(false, 1), b.enemyParty, "first trainer retains their replacement bench") + T.eq(b:benchFor(false, 3), b.enemyPartyB, "second trainer retains their replacement bench") + if spec.battleScope then + b.playerParty = primary + f.game.save.party = party("FOREIGN") + end + end else - f.game.save.party, b.enemyParty = player, enemy + b.double = true + if native then + b.playerParty, b.foeParty = player, enemy + else + f.game.save.party, b.enemyParty = player, enemy + end end for id = 0, 3 do local p = id % 2 == 0 and player or enemy - local index = id < 2 and 1 or 2 - put(b, id, make(p[index], id, index)) + local index = id < 2 and 1 or options.sideParty and split + 1 or 2 + if not options.sideParty then + put(b, id, make(p[index], id, index)) + end local a = at(b, id) + if options.sideParty then + T.eq(a.mon, p[index], "native constructors preserve slot ownership") + end if native then a.ability, a.mon.ability = 0, 0 else @@ -246,6 +324,11 @@ function M.run(root, backend, version, patchStats, options) if not native then b:syncSides() end + if spec.cloneUser then + local old = at(b, userId) + local index = userId < 2 and 1 or split + 1 + put(b, userId, make(copy(old.mon), userId, index)) + end local user = at(b, userId) local target = at(b, userId % 2 == 0 and 1 or 0) local p = userId % 2 == 0 and player or enemy @@ -486,6 +569,28 @@ function M.run(root, backend, version, patchStats, options) { name = "next-move-resets", bands = { 0 }, lowHP = 1, repeatMove = true }, } end + if options.sideParty then + specs = { + { name = "joined-six" }, + { name = "joined-filtered", sideFilter = true }, + { name = "joined-primary-blocked", primaryBlocked = true }, + { name = "joined-secondary-blocked", secondaryBlocked = true }, + { name = "joined-all-blocked", allBlocked = true }, + { name = "joined-accuracy-wake", allBlocked = true, sideAccuracy = true }, + { name = "joined-five", five = true, split = 2 }, + { name = "joined-battle-scope", battleScope = true }, + { name = "joined-wake", sideChange = "wake" }, + { name = "joined-block", sideChange = "block" }, + { name = "joined-replace", sideChange = "replace" }, + { name = "joined-array-reassigned", sideChange = "array" }, + { name = "joined-user-hp", sideChange = "userHP" }, + { name = "joined-clone-hp", sideChange = "userHP", cloneUser = true }, + { name = "joined-sub-break", substitute = 1 }, + { name = "joined-ko-stop", lowHP = 1 }, + { name = "joined-critical", critical = { true } }, + { name = "joined-variation", variation = 85 }, + } + end for id = 0, 3 do for _, spec in ipairs(specs) do local b, user, target, party = fresh(spec, id) @@ -542,10 +647,58 @@ function M.run(root, backend, version, patchStats, options) end end end + if spec.sideChange then + mutate = function(number) + if number ~= 1 then + return + end + if spec.sideChange == "wake" then + party[5].status = nil + elseif spec.sideChange == "block" then + party[5].status = "PSN" + elseif spec.sideChange == "userHP" then + user.mon.hp = 0 + elseif spec.sideChange == "array" then + local blocked = require("src.mods.Merge").deepCopy(party) + for _, mon in ipairs(blocked) do + mon.status = "PSN" + end + if native then + if user.isPlayer then + b.playerParty = blocked + else + b.foeParty = blocked + end + else + local first, second = {}, {} + for i, mon in ipairs(blocked) do + if i <= (spec.split or 3) then + first[#first + 1] = mon + else + second[#second + 1] = mon + end + end + if user.isPlayer then + b.playerParty, b.ally.party = first, second + else + b.enemyParty, b.enemyPartyB = first, second + end + end + elseif spec.sideChange == "replace" then + local replacement = require("src.mods.Merge").deepCopy(party[5]) + replacement.species = native and 5 or "CHARMELEON" + party[5] = replacement + if not native then + local second = user.isPlayer and b.ally.party or b.enemyPartyB + second[5 - (spec.split or 3)] = replacement + end + end + end + end local stopAccuracy - if spec.dynamic == "accuracy" then + if spec.dynamic == "accuracy" or spec.sideAccuracy then stopAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) - party[3].status = nil + party[spec.sideAccuracy and 5 or 3].status = nil return nextAccuracy(...) end) end @@ -651,6 +804,50 @@ function M.run(root, backend, version, patchStats, options) end end end + if options.sideParty then + local expected = { 3, 5, 7, 4, 9, 6 } + if spec.five then + expected[6] = nil + end + if spec.primaryBlocked then + expected = { 4, 9, 6 } + end + if spec.secondaryBlocked then + expected = { 3, 5, 7 } + end + if spec.allBlocked then + expected = spec.sideAccuracy and { 9 } or {} + end + if spec.sideFilter then + expected = { 3, 4, 6 } + end + if spec.sideChange == "block" then + expected = { 3, 5, 7, 4, 6 } + end + if spec.sideChange == "replace" then + expected[5] = 8 + end + if spec.sideChange == "userHP" and id >= 2 then + expected = { 3, 5, 7, 9, 6 } + end + if spec.critical then + expected = { 6, 10, 14, 8, 18, 12 } + end + if spec.variation then + expected = { 2, 4, 5, 3, 7, 5 } + end + if spec.substitute then + expected[1] = 1 + end + if spec.lowHP then + expected = { 1 } + end + local amounts = {} + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.amount + end + T.same(amounts, expected, spec.name .. " uses the entire live side party in native order") + end local memory = native and (target.lastSpecialDamageTaken or 0) or (target.specialDamageTaken or 0) if spec.endure then T.eq(target.mon.hp, 1, "Endure retains one HP through the remaining strikes") diff --git a/tests/engine/gen3_beat_up_side_party.lua b/tests/engine/gen3_beat_up_side_party.lua new file mode 100644 index 000000000..bdd73b799 --- /dev/null +++ b/tests/engine/gen3_beat_up_side_party.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version, false, { sideParty = true }) +end +require("tests.modkit").finish("native GBA Beat Up joined side parties") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 7d21d8663..273d86e19 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -d518411055f17444 +fdbfa30b20cbbaef diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 5688a02fa..cd634b55f 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index f9db890fb..8abd1ee0a 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "beat_up_adjustment" then +if mode == "beat_up_side_party" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( + root, + backend, + version, + false, + { sideParty = true } + ) +elseif mode == "beat_up_adjustment" then rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( root, backend, From d2d1f3b7b56493505df147fb5083d68ba78f46d7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:44:38 +0200 Subject: [PATCH 31/90] Match native GBA critical context and hit metadata --- .github/workflows/enemy-action-contract.yml | 1 + docs/known-differences.md | 9 + docs/native-gba-critical-context.md | 79 ++++ docs/new-features.md | 9 + src/battle/BattleState.lua | 15 +- src/battle/Damage.lua | 32 +- src/battle/EffectRegistry.lua | 15 +- src/battle/Gen3BeatUp.lua | 11 +- src/battle/Gen3Critical.lua | 72 +++ src/battle/Gen3MoveEffects.lua | 2 +- src/battle/HoldItems.lua | 2 + src/script/Gen3Commands.lua | 1 + tests/enemy_actions/critical_kinds.lua | 462 +++++++++++++++++++ tests/engine/gen3_critical_context.lua | 19 + tests/goldens/fixture_fingerprint.txt | 2 +- tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 17 files changed, 712 insertions(+), 25 deletions(-) create mode 100644 docs/native-gba-critical-context.md create mode 100644 src/battle/Gen3Critical.lua create mode 100644 tests/enemy_actions/critical_kinds.lua create mode 100644 tests/engine/gen3_critical_context.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 61c14a396..afeba167b 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -15,6 +15,7 @@ on: - 'tests/engine/gen3_beat_up.lua' - 'tests/engine/gen3_beat_up_adjustment.lua' - 'tests/engine/gen3_beat_up_side_party.lua' + - 'tests/engine/gen3_critical_context.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/known-differences.md b/docs/known-differences.md index 7049d7e0b..66953dc82 100644 --- a/docs/known-differences.md +++ b/docs/known-differences.md @@ -55,3 +55,12 @@ Beat Up now reads the entire side party in two-trainer and player/ally battles, retaining live slot replacement and the active user's HP/status. Replacement benches remain owned separately. The reviewed effect revision changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). + +## Native GBA critical context and hit metadata + +Critical exclusions now preserve native draw order for first/tutorial battles +and Teachy TV, including scripted Beat Up. Shared critical context fields, +physical item/current-species stages, Lansat/Focus Energy behavior and GBA +hit kind/Substitute metadata are covered by cross-engine contracts. The +reviewed scripted effect revision changes the link fingerprint; see +[verification and remaining scope](native-gba-critical-context.md). diff --git a/docs/native-gba-critical-context.md b/docs/native-gba-critical-context.md new file mode 100644 index 000000000..a5c0deba4 --- /dev/null +++ b/docs/native-gba-critical-context.md @@ -0,0 +1,79 @@ +# Native GBA critical context and hit events + +Ordinary damage and dedicated Beat Up strikes now share the native critical +command policy. Birch's first battle and Wally's tutorial return before the +critical random draw. Teachy TV consumes that draw and then discards its +result. FireRed's Oak predicate also checks the inflicted-damage flag; +the HP-damage command advances it after the public hit event and only for +player damage to a non-Substitute opponent. This includes later strikes of +the same Beat Up action. Old-man control cases still roll normally. + +The actual first-battle script forwards its metadata through the ordinary +battle constructor, and the existing Wally/Teachy TV constructors provide +their native state. Ordinary critical hooks retain their override/delegation +semantics; dedicated Beat Up bypasses them. Both hooked and unhooked ordinary +paths share the policy. Earlier-generation policies remain unchanged. + +## Public context and native stages + +The shared public `battle.crit` context carries battle, target, canonical +move ID, numeric move ID and the current critical stage. Stage calculation +uses native physical item IDs: Scope Lens, Lucky Punch and Stick; the last +two inspect the current battler species rather than the retained party mon. +Mutating a physical Scope Lens's held-effect metadata does not remove its +native stage bonus, and custom Scope-Lens-like metadata does not give an +unrelated item that native ID's behavior. Portable custom behavior can use +the shared public critical hook instead. + +Focus Energy, high-critical effects and the native cap are covered. Lansat +Berry uses the Focus Energy marker and is not consumed when that marker is +already active. Ordinary GBA hit events now also carry physical/special kind +and the Substitute flag; scripted Beat Up retains its native special kind +and noncritical event metadata even for critical damage. + +## Verification + +```sh +luajit tests/engine/gen3_critical_context.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The same API-3 consumer uses actual action selection and move executors on +unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new matrix has 27 FireRed +and 29 Emerald cases at all four positions, each with ordinary damage and +scripted Beat Up. It compares critical/variation operands, damage and hit +metadata, public context fields, callbacks and the Oak flag's event timing. +Cases cover normal and special damage, Focus Energy, native/custom held +items, active species, high-critical effects/cap, the actual Lansat producer, +armor, hook force/delegation/suppression, unhooked paths, first-battle kinds, +Wally, Poke Dude, old-man controls and Substitute. + +Gen2 first-battle cases run `Gen3Commands.firstBattle` through +`Commands.start_battle` and `BattleState.newWild`. Wally and Teachy TV use +their actual constructors. The source fixture seeds the native kind fields; +Oak laboratory flags and detached/current species are also seeded explicitly. +Stats are controlled and imported-shaped; full controller/intro playback, +constructor stat generation, advice text, animations and a fresh ROM import +are not claimed. + +The focused suite passes **3,396 checks**, including non-GBA passthrough +controls. The complete comparison passes **52 isolated workers / 5,214 +paired observations**. #210 fails **288 assertions** in the new FireRed +cases. The full feature quick run passes **92/101 engine suites** versus +**91/100** on clean #210; exact Counters of the **25 inherited FAIL lines +across nine suites** match with no additions. SDK 3/3 and B30 pass. + +Both engines and the workflow use JIT-enabled LuaJIT v2.1 commit +`a4c064539ea931274d407e1249b4bb575424a0f4`. Critical operands are normalized +across native range conventions; complete raw RNG traces and full +link/multi-owner controller execution remain separate surfaces. + +The feature stacks on #210. The scripted Beat Up policy change increments +`BEAT_UP_EFFECT.rev` from 2 to 3. The fixture digest changes from +`fdbfa30b20cbbaef` to `a000e3862d11a73a`; only `move_effects` changes, from +`e4d28f474c15cd8b` to `cca86e08b893522c`. Restoring revision 2 restores the +prior full digest; all six other populated sections remain identical. +Gen1 remains read-only and requires no source change or publication. diff --git a/docs/new-features.md b/docs/new-features.md index a28430fce..9133c678d 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -523,3 +523,12 @@ Beat Up now reads the entire side party in two-trainer and player/ally battles, retaining live slot replacement and the active user's HP/status. Replacement benches remain owned separately. The reviewed effect revision changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). + +## Native GBA critical context and hit metadata + +Critical exclusions now preserve native draw order for first/tutorial battles +and Teachy TV, including scripted Beat Up. Shared critical context fields, +physical item/current-species stages, Lansat/Focus Energy behavior and GBA +hit kind/Substitute metadata are covered by cross-engine contracts. The +reviewed scripted effect revision changes the link fingerprint; see +[verification and remaining scope](native-gba-critical-context.md). diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1993688ec..ac588f0ca 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1417,6 +1417,11 @@ function BattleState.newWild(game, species, level, opts) -- kind of their own, because everything else about the fight -- the music, -- the intro line, the wild-mon held item roll -- is an ordinary wild one. self.noRun = opts and opts.noRun or nil + if GameVersion.isGen3() then + self.firstBattle = opts and opts.firstBattle and true or nil + self.firstBattleKind = opts and opts.firstBattleKind + or (self.firstBattle and (GameVersion.get() == "emerald" and "birch" or "oak")) + end self.canLose = opts and opts.canLose or nil -- ...AND A ROAMER IS THE SAME INDIVIDUAL EVERY TIME. The cartridge keeps -- its personality and IVs and rebuilds from them, which is what makes a @@ -2138,6 +2143,7 @@ end -- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the -- displayed thrower name is a parameter. function BattleState:makeOldManDemo(name) + self.wallyTutorial = nil self.demo = true self.demoName = name or "OLD MAN" -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: @@ -2185,6 +2191,7 @@ end function BattleState:makeDudeDemo(name) self:makeOldManDemo(name or "DUDE") self.dudeDemo = true + self.wallyTutorial = GameVersion.get() == "emerald" and true or nil self.demoBallCount = "x1" end @@ -5230,7 +5237,10 @@ end -- Damage.compute, hooked as battle.damage; the ctx table is only built -- when a chain is installed, so the no-mod path allocates nothing function BattleState:computeDamage(user, target, move, opts) - if opts and opts.gbaSetDamage then opts.battle = self end + if GameVersion.isGen3() then + opts = opts or {} + opts.battle = self + end -- Damage's Gen II held-item path needs the imported item records alongside -- the ordinary formula options. Keep Gen I's no-mod fast path allocation-free. if self.data and self.data.constants and self.data.constants.generation == 2 then @@ -6297,7 +6307,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)) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 851c641d9..a283668ed 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -82,7 +82,7 @@ Damage.stageOf = stageOf -- ruleset that sets critStages cannot just tune the Gen 1 numbers. local CRIT_STAGE_DEN = { [0] = 16, 8, 4, 3, 2 } -function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) +function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data, battle) rng = rng or love.math.random if highCrit == nil then highCrit = HIGH_CRIT[moveId] end @@ -102,14 +102,8 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) end if ruleset.critStages then - local stage = 0 - if highCrit then stage = stage + 1 end - if attacker.focusEnergy then stage = stage + 2 end - if attacker.critStage then stage = stage + attacker.critStage end - -- SCOPE LENS is +1 for anybody; LUCKY PUNCH and STICK are +2 and are - -- dead weight on anything that is not a CHANSEY or a FARFETCH'D - stage = stage + HoldItems.critStages(attacker) - local den = CRIT_STAGE_DEN[math.max(0, math.min(4, stage))] or 16 + local stage = require("src.battle.Gen3Critical").stage(battle, attacker, highCrit) + local den = CRIT_STAGE_DEN[stage] or 16 return rng(1, den) == 1 end local function shl(x) return math.min(255, x * 2) end @@ -266,21 +260,27 @@ function Damage.compute(ruleset, attacker, defender, move, opts) crit = false end if crit == nil then + local high = opts.highCrit + if high == nil then high = move.highCrit end if Runtime.wantsHook("battle.crit") then + local gba = opts.battle and require("src.core.GameVersion").isGen3() + local stageHigh = high + if stageHigh == nil then stageHigh = HIGH_CRIT[move.id] end crit = Runtime.call("battle.crit", function(c) - return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit, c.data) - end, { ruleset = ruleset, attacker = attacker, moveId = move.id, + return require("src.battle.Gen3Critical").roll(c.battle, Damage.critRoll, + c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit, c.data) + end, { battle = opts.battle, target = defender, ruleset = ruleset, attacker = attacker, moveId = move.id, rng = rng, - highCrit = opts.highCrit ~= nil and opts.highCrit - or move.highCrit, + highCrit = high, + moveNum = gba and tonumber(move.index or move.numId) or nil, + stage = gba and require("src.battle.Gen3Critical").stage(opts.battle, attacker, stageHigh) or nil, data = opts.data }) else -- the record's highCrit (BLAZE KICK, POISON TAIL, the Hoenn moves whose -- crit rate is part of the EFFECT rather than a move-record field) -- wins over the move's own, which in turn wins over the id table - local high = move.highCrit - if opts.highCrit ~= nil then high = opts.highCrit end - crit = Damage.critRoll(ruleset, attacker, move.id, rng, high, opts.data) + crit = require("src.battle.Gen3Critical").roll(opts.battle, Damage.critRoll, + ruleset, attacker, move.id, rng, high, opts.data) end end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 463ec3280..1946c813e 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -368,13 +368,24 @@ function EffectRegistry.runDamaging(battle, ctx, record) or (record and record.chooseDamage) or not require("src.core.GameVersion").isGen3()) then local eventCritical = info.crit if info.gbaBeatUp then eventCritical = false end + local gba = require("src.core.GameVersion").isGen3() + local eventKind + if info.gbaBeatUp then eventKind = "special" + elseif gba then + local physical = info.physical + if physical == nil then physical = not require("src.battle.Damage").isSpecial(move.type) end + eventKind = physical and "physical" or "special" + end Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, damage = dealt, crit = eventCritical, typeMult = info.eventTypeMult or info.typeMult, - substitute = (info.gbaSetDamage or info.gbaBeatUp) and hadSub or nil, - kind = info.gbaBeatUp and "special" or nil, + substitute = (gba or info.gbaSetDamage or info.gbaBeatUp) and hadSub or nil, + kind = eventKind, }) end + if battle.firstBattle then + require("src.battle.Gen3Critical").noteHit(battle, user, target, dealt, hadSub) + end if hadSub and not target.substituteHP and not info.gbaBeatUp then -- AttackSubstitute: breaking the substitute ends a multi-hit move brokeSub = true diff --git a/src/battle/Gen3BeatUp.lua b/src/battle/Gen3BeatUp.lua index d72f8195e..9dd80673d 100644 --- a/src/battle/Gen3BeatUp.lua +++ b/src/battle/Gen3BeatUp.lua @@ -82,7 +82,16 @@ function B.nextDamage(ctx) name = "Enemy " .. name end ctx.say(ctx.battle:romText("STRINGID_PKMNATTACK", "%s's attack!", name)) - local critical = Damage.critRoll(ctx.ruleset, ctx.user, ctx.move.id, ctx.rng, false, ctx.data) + local critical = require("src.battle.Gen3Critical").roll( + ctx.battle, + Damage.critRoll, + ctx.ruleset, + ctx.user, + ctx.move.id, + ctx.rng, + false, + ctx.data + ) if critical then amount = amount * 2 end diff --git a/src/battle/Gen3Critical.lua b/src/battle/Gen3Critical.lua new file mode 100644 index 000000000..3de318f76 --- /dev/null +++ b/src/battle/Gen3Critical.lua @@ -0,0 +1,72 @@ +-- Native critical command guards, shared by ordinary and scripted GBA hits. +local Version = require("src.core.GameVersion") +local C = {} +local function inflicted(battle) + return math.floor(tonumber(battle.oakMsgFlags) or 0) % 2 == 1 +end +function C.stage(battle, attacker, highCrit) + local stage = highCrit and 1 or 0 + if battle and Version.isGen3() then + if attacker.focusEnergy or attacker.expFocusEnergy then + stage = stage + 2 + end + -- Native critcalc checks physical item IDs and the current battler species, + -- independently of mutable held-effect metadata or the retained party mon. + local item = tonumber(attacker.item) + if item == nil then + item = require("src.battle.Gen3AIItemKnowledge").item(battle, attacker) + end + local id = attacker.species or (attacker.mon and attacker.mon.species) + local def = battle.data and battle.data.pokemon and battle.data.pokemon[id] + local species = tonumber(id) or tonumber(def and (def.index or def.dex)) + if item == 198 then + stage = stage + 1 + end + if item == 222 and species == 113 or item == 225 and species == 83 then + stage = stage + 2 + end + else + if attacker.focusEnergy then + stage = stage + 2 + end + stage = stage + (attacker.critStage or 0) + require("src.battle.HoldItems").critStages(attacker) + end + return math.max(0, math.min(4, stage)) +end +function C.roll(battle, draw, ruleset, attacker, moveId, rng, highCrit, data) + if not battle or not Version.isGen3() then + return draw(ruleset, attacker, moveId, rng, highCrit, data) + end + -- Oak and Emerald's first/tutorial kinds return before consuming RNG. + if battle.firstBattle and not inflicted(battle) then + return false + end + local kinds = battle.kinds or {} + if + Version.get() == "emerald" + and (battle.firstBattleKind or kinds.firstBattle or battle.wallyTutorial or kinds.tutorial == "wally") + then + return false + end + local critical = draw(ruleset, attacker, moveId, rng, highCrit, data, battle) + -- Teachy TV still consumes critcalc's draw, then discards its result. + if battle.pokedudeDemo or battle.pokedude then + return false + end + return critical +end +function C.noteHit(battle, user, target, dealt, substitute) + if + Version.isGen3() + and battle.firstBattle + and not substitute + and dealt > 0 + and user.isPlayer + and not target.isPlayer + and not inflicted(battle) + then + -- The native HP-damage command updates this after the public hit event. + battle.oakMsgFlags = (tonumber(battle.oakMsgFlags) or 0) + 1 + end +end +return C diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 24b7ebe22..4c9ce6a96 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -1030,7 +1030,7 @@ F.FUTURE_SIGHT_EFFECT = { -- BEAT UP strikes once per healthy party member. F.BEAT_UP_EFFECT = { - rev = 2, + rev = 3, chooseDamage = function(ctx) if not require("src.core.GameVersion").isGen3() then return ctx.computeDamage({ rng = ctx.rng }) end if not require("src.battle.Gen3BeatUp").prepare(ctx) then return nil, Strings("But, it failed!") end diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index df5e2a031..cdc7592ab 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -401,6 +401,8 @@ function HoldItems.trigger(battler, when) consume = true } end if effect == "CRITICAL_UP" then + if require("src.core.GameVersion").isGen3() + and (battler.focusEnergy or battler.expFocusEnergy) then return nil end if mon.hp * divisor > max then return nil end return { kind = "critUp", effect = effect, delta = 2, consume = true } end diff --git a/src/script/Gen3Commands.lua b/src/script/Gen3Commands.lua index 6c4d921ff..f307ddc71 100644 --- a/src/script/Gen3Commands.lua +++ b/src/script/Gen3Commands.lua @@ -3756,6 +3756,7 @@ function Gen3Commands.firstBattle(ctx) Commands.start_battle(ctx, "wild", species, level, { -- BATTLE_TYPE_FIRST_BATTLE: there is nowhere to run to and nothing to -- black out to, and the script heals the party two commands later + firstBattle = true, noRun = true, canLose = record.canLose and true or false, }) diff --git a/tests/enemy_actions/critical_kinds.lua b/tests/enemy_actions/critical_kinds.lua new file mode 100644 index 000000000..d6dffb2b8 --- /dev/null +++ b/tests/enemy_actions/critical_kinds.lua @@ -0,0 +1,462 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + ids.BEAT_UP, names[251] = engine == "gen1" and "BEAT_UP" or "MOVE_251", "BEAT_UP" + if engine == "gen1" then + local d = + { effect = 154, power = 10, type = 17, target = 0, priority = 0, accuracy = 100, pp = 20, flags = 2 } + data.gen3Moves._rom[251], rom[251], data.gen3Pokemon._moveNames[251] = d, d, "BEAT_UP" + data.gen3Pokemon._stats[4] = { hp = 50, atk = 60, def = 40, spe = 50, spa = 50, spd = 50 } + else + data.moves.MOVE_251 = { + id = "MOVE_251", + index = 251, + name = "BEAT_UP", + gen3Effect = 154, + effect = "BEAT_UP_EFFECT", + power = 10, + type = "DARK", + target = 0, + priority = 0, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = true, + } + data.pokemon.CHARMANDER.baseStats.attack, data.pokemon.CHARMANDER.baseStats.defense = 60, 40 + data.pokemon.CHARMANDER.baseStats.spatk, data.pokemon.CHARMANDER.baseStats.spdef = 50, 50 + data.pokemon.CHARMANDER.evYield = { attack = 1 } + data.type_chart.types = data.type_chart.types or {} + data.type_chart.types.DARK = { id = "DARK", index = 17, category = "special" } + for id, row in pairs({ + SCOPE_LENS = { 198, "SCOPE_LENS" }, + CUSTOM_SCOPE = { 1198, "SCOPE_LENS" }, + LUCKY_PUNCH = { 222, "LUCKY_PUNCH" }, + STICK = { 225, "STICK" }, + LANSAT_BERRY = { 173, "CRITICAL_UP" }, + }) do + data.items[id] = { index = row[1], name = id, holdEffect = row[2], holdEffectParam = 4 } + end + for name, index in pairs({ RATTATA = 19, PIDGEY = 16, CHANSEY = 113, FARFETCH_D = 83 }) do + local d = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + d.id, d.name, d.index, d.dex, d.evolutions = name, name, index, index, {} + data.pokemon[name] = d + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T = f.T + local sourceItems, oldItems + if native then + sourceItems = require("src.core.game3.items_data") + oldItems = { sourceItems._byId, sourceItems._byName, sourceItems._pack } + local raw = { [1198] = { name = "CUSTOM SCOPE", pocket = "ITEMS", holdEffect = 41, holdEffectParam = 0 } } + sourceItems._byId, sourceItems._byName, sourceItems._pack = raw, {}, { items = raw } + end + local rows, hits, draws, calls, contexts = {}, {}, {}, {}, {} + local policy, liveBattle + local function criticalHook(nextCrit, context) + calls.crit = calls.crit + 1 + contexts[#contexts + 1] = { + battle = context.battle ~= nil, + target = context.target and context.target.mon.nickname, + move = context.moveId, + number = context.moveNum, + stage = context.stage, + } + if policy == "force" then + return true + end + local result = nextCrit(context) + if policy == "after" then + return true + end + if policy == "suppress" then + return false + end + return result + end + local stopCrit = f.e.api.hooks:wrap("battle.crit", criticalHook) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + damage = e.damage, + crit = e.crit, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + kind = e.kind, + substitute = e.substitute or false, + oakFlags = liveBattle.oakMsgFlags or 0, + } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local specs = { + { name = "ordinary", kind = "normal" }, + { name = "ordinary-unhooked", kind = "normal", noHook = true }, + { name = "ordinary-special", kind = "normal", special = true }, + { name = "scope-lens", kind = "normal", item = "SCOPE_LENS", stage = 1 }, + { name = "scope-native-id", kind = "normal", item = "SCOPE_LENS", noEffect = true, stage = 1 }, + { name = "custom-scope", kind = "normal", item = "CUSTOM_SCOPE", stage = 0 }, + { + name = "lucky-punch-current-species", + kind = "normal", + item = "LUCKY_PUNCH", + species = "CHANSEY", + speciesNum = 113, + stage = 2, + }, + { + name = "stick-current-species", + kind = "normal", + item = "STICK", + species = "FARFETCH_D", + speciesNum = 83, + stage = 2, + }, + { name = "high-critical", kind = "normal", high = true, stage = 1 }, + { name = "stage-cap", kind = "normal", high = true, focus = true, item = "SCOPE_LENS", stage = 4 }, + { name = "lansat", kind = "normal", item = "LANSAT_BERRY", berry = true, stage = 2 }, + { + name = "lansat-focus-energy", + kind = "normal", + item = "LANSAT_BERRY", + berry = true, + focus = true, + stage = 2, + }, + { name = "focus-energy", kind = "normal", focus = true }, + { name = "armor", kind = "normal", armor = true }, + { name = "forced-hook", kind = "normal", hook = "force" }, + { name = "suppressed-hook", kind = "normal", hook = "suppress" }, + { name = "after-hook", kind = "normal", hook = "after" }, + } + local extra = version == "firered" + and { + { name = "pokedude", kind = "pokedude" }, + { name = "pokedude-unhooked", kind = "pokedude", noHook = true }, + { name = "pokedude-focus", kind = "pokedude", focus = true }, + { name = "pokedude-forced", kind = "pokedude", hook = "force" }, + { name = "pokedude-after", kind = "pokedude", hook = "after" }, + { name = "pokedude-armor", kind = "pokedude", armor = true }, + { name = "old-man-control", kind = "oldman" }, + { name = "oak-before", kind = "oak" }, + { name = "oak-after", kind = "oak", flags = 1 }, + { name = "oak-substitute", kind = "oak", substitute = true }, + } + or { + { name = "first-battle", kind = "first" }, + { name = "first-unhooked", kind = "first", noHook = true }, + { name = "first-battle-after-damage", kind = "first", flags = 1 }, + { name = "first-forced", kind = "first", hook = "force" }, + { name = "first-after", kind = "first", hook = "after" }, + { name = "first-armor", kind = "first", armor = true }, + { name = "first-kind-only", kind = "firstKind" }, + { name = "wally", kind = "wally" }, + { name = "wally-unhooked", kind = "wally", noHook = true }, + { name = "wally-forced", kind = "wally", hook = "force" }, + { name = "wally-after", kind = "wally", hook = "after" }, + { name = "wally-focus", kind = "wally", focus = true }, + } + for _, s in ipairs(extra) do + specs[#specs + 1] = s + end + for _, spec in ipairs(specs) do + for id = 0, 3 do + for _, name in ipairs({ "TACKLE", "BEAT_UP" }) do + hits, draws, calls, contexts, policy = {}, {}, { crit = 0, damage = 0 }, {}, spec.hook + local b = f.fresh("TACKLE", { enemy = true }).battle + if native then + if spec.kind == "first" or spec.kind == "oak" then + b.firstBattle = true + end + if spec.kind == "pokedude" then + b.pokedude = true + end + if spec.kind == "oldman" then + b.oldManTutorial = true + end + b.kinds = {} + if spec.kind == "first" or spec.kind == "firstKind" then + b.kinds.firstBattle = "birch" + end + if spec.kind == "wally" then + b.kinds.tutorial = "wally" + end + elseif spec.kind == "first" then + f.data.constants.gen3FirstBattle = { species = "CHARMANDER", level = 30, canLose = true } + local yielded = 0 + local ctx = { + game = f.game, + save = f.game.save, + runner = { + yield = function() + yielded = yielded + 1 + end, + }, + overworld = { + pushBattle = function(_, battle) + b = battle + end, + }, + } + T.eq(require("src.script.Gen3Commands").firstBattle(ctx), true, "native first-battle script starts") + T.eq(yielded, 1, "native script yields after creating its battle") + T.eq(b.firstBattle, true, "first-battle metadata survives the native script and constructor") + elseif spec.kind == "firstKind" then + b = + require("src.battle.BattleState").newWild(f.game, "CHARMANDER", 30, { firstBattleKind = "birch" }) + elseif spec.kind == "pokedude" then + b = require("src.battle.BattleState").newPokedudeDemo(f.game, "battle") + T.check(b.pokedudeDemo ~= nil, "native Teachy TV constructor supplies its controller state") + elseif spec.kind == "wally" then + b:makeDudeDemo("WALLY") + elseif spec.kind == "oldman" then + b:makeOldManDemo("OLD MAN") + elseif spec.kind == "oak" then + b.firstBattle = true + end + b.oakMsgFlags = spec.flags or 0 + b.double = true + if not native then + b:syncSides() + end + for pos = 2, 3 do + local mon = require("src.mods.Merge").deepCopy(at(b, pos - 2).mon) + local a = native + and require("src.core.game3.battle.state").makeBattler( + mon, + pos == 2 and "player" or "enemy", + { id = pos, partyIndex = 2 } + ) + or require("src.battle.BattleState").makeBattler(f.data, mon, pos == 2) + put(b, pos, a) + end + if not native then + b:syncSides() + end + for pos = 0, 3 do + local a = at(b, pos) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. pos, 10000, 30 + if native then + a.species, a.mon.species, a.mon.maxHp = 4, 4, 10000 + a.type1, a.type2, a.ability, a.mon.ability = 0, 0, 0, 0 + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 251 }, { 10, 20 }, { 10, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = 50 + end + else + a.species, a.mon.species = "CHARMANDER", "CHARMANDER" + a.curStats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.BEAT_UP, pp = 20 } } + a.mon.moves = a.curMoves + end + end + if native then + b.playerParty, b.foeParty = { at(b, 0).mon, at(b, 2).mon }, { at(b, 1).mon, at(b, 3).mon } + else + f.game.save.party, b.enemyParty = { at(b, 0).mon, at(b, 2).mon }, { at(b, 1).mon, at(b, 3).mon } + end + local user, target = at(b, id), at(b, id % 2 == 0 and 1 or 0) + liveBattle = b + if native then + require("src.core.game3.battle.moves")._rom[33].type = spec.special and 17 or 0 + require("src.core.game3.battle.moves")._rom[33].effect = spec.high and 43 or 0 + if spec.special then + user.type1, user.type2 = 17, 17 + end + else + f.data.moves[f.ids.TACKLE].type = spec.special and "DARK" or "NORMAL" + local move = f.data.moves[f.ids.TACKLE] + move.effect, move.gen3Effect, move.highCrit = + spec.high and "HIGH_CRITICAL_EFFECT" or "NO_ADDITIONAL_EFFECT", spec.high and 43 or 0, spec.high + f.data.items.SCOPE_LENS.holdEffect = spec.noEffect and "NONE" or "SCOPE_LENS" + if spec.special then + user.curTypes = { "DARK" } + end + end + user.focusEnergy = spec.focus + if spec.item then + if native then + local item = ({ + SCOPE_LENS = 198, + CUSTOM_SCOPE = 1198, + LUCKY_PUNCH = 222, + STICK = 225, + LANSAT_BERRY = 173, + })[spec.item] + user.item, user.mon.item = item, item + else + user.items, user.mon.item = f.data.items, spec.item + end + end + if spec.species then + user.species = native and spec.speciesNum or spec.species + end + if spec.berry then + user.mon.hp = 2500 + local active + if native then + active = require("src.core.game3.battle.held_items").normal( + require("src.core.game3.battle.adapter").new(b), + user, + false + ) + else + active = b:holdItemTrigger(user, "turn") + end + T.eq(active, spec.focus ~= true, "Lansat activates only without existing Focus Energy") + local held = native and user.item == 173 or not native and user.mon.item == "LANSAT_BERRY" + T.eq(held, spec.focus == true, "already focused users keep their unconsumed berry") + T.eq(user.focusEnergy, true, "the native berry producer uses the Focus Energy marker") + end + if spec.armor then + if native then + target.ability, target.mon.ability = 4, 4 + else + target.abilityOverride = "BATTLE_ARMOR" + end + end + if spec.substitute then + target.substituteHP = 1 + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + draws[#draws + 1] = { kind = "variation" } + return 100 + end + if + (lo == 0 and (hi == 15 or hi == 7 or hi == 3 or hi == 2 or hi == 1)) + or (lo == 1 and (hi == 16 or hi == 8 or hi == 4 or hi == 3 or hi == 2)) + then + draws[#draws + 1] = { kind = "critical", den = hi - lo + 1 } + return lo + end + return lo or 0 + end + f.e.choose(function() + return { move = name, target = id % 2 == 0 and 1 or 0 } + end) + local slot = name == "TACKLE" and 1 or 2 + if spec.noHook then + stopCrit() + end + if native then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slot, id, id % 2 == 0 and 1 or 0) + or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + if id % 2 == 0 then + b.chosenTargets = { [id] = target } + end + local action = id % 2 == 0 and user.curMoves[slot] or b:enemyAction(user) + b:executeAction(user, target, action) + end + if spec.noHook then + stopCrit = f.e.api.hooks:wrap("battle.crit", criticalHook) + end + local ordinary = name == "TACKLE" + local guarded = spec.kind == "first" or spec.kind == "firstKind" or spec.kind == "wally" + local oakPending = spec.kind == "oak" and not spec.flags + local expected, criticalDraws = {}, 0 + local force = ordinary and (spec.hook == "force" or spec.hook == "after") + for hit = 1, ordinary and 1 or 2 do + local suppressed = guarded or (oakPending and (id % 2 == 1 or hit == 1 or spec.substitute)) + local critical = force + or not ( + suppressed + or spec.kind == "pokedude" + or (ordinary and (spec.armor or spec.hook == "suppress")) + ) + expected[hit] = ordinary and (critical and 33 or 16) or (critical and 12 or 6) + if spec.substitute and hit == 1 then + expected[hit] = 1 + end + if not suppressed and not (ordinary and (spec.armor or spec.hook == "force")) then + criticalDraws = criticalDraws + 1 + end + end + local amounts = {} + local actualDraws = 0 + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.damage + T.eq( + h.kind, + ordinary and not spec.special and "physical" or "special", + "native event carries the damage kind" + ) + end + for _, r in ipairs(draws) do + if r.kind == "critical" then + actualDraws = actualDraws + 1 + end + end + T.same(amounts, expected, spec.name .. " native critical damage") + T.eq(actualDraws, criticalDraws, spec.name .. " native critical draw count") + T.eq( + calls.crit, + ordinary and not spec.armor and not spec.noHook and 1 or 0, + "only ordinary unarmored hits enter the public critical hook" + ) + T.eq(calls.damage, ordinary and 1 or 0, "scripted Beat Up bypasses ordinary damage hooks") + for _, c in ipairs(contexts) do + T.same(c, { + battle = true, + target = target.mon.nickname, + move = "TACKLE", + number = 33, + stage = spec.stage or (spec.focus and 2 or 0), + }, "public critical context carries the native battle/target/move/stage") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + ordinary and 9 or 19, + "owned PP is spent once" + ) + rows[#rows + 1] = { + case = spec.name, + user = id, + move = name, + hits = hits, + draws = draws, + calls = calls, + contexts = contexts, + oakFlags = b.oakMsgFlags or 0, + } + end + end + end + stopHit() + stopDamage() + stopCrit() + if sourceItems then + sourceItems._byId, sourceItems._byName, sourceItems._pack = unpack(oldItems, 1, 3) + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_critical_context.lua b/tests/engine/gen3_critical_context.lua new file mode 100644 index 000000000..a30953fec --- /dev/null +++ b/tests/engine/gen3_critical_context.lua @@ -0,0 +1,19 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.critical_kinds").run(".", "gen2", version) +end +local T = require("tests.modkit") +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local calls = 0 + local result = require("src.battle.Gen3Critical").roll( + { firstBattle = true, firstBattleKind = "birch", wallyTutorial = true, pokedudeDemo = {} }, + function() + calls = calls + 1 + return true + end + ) + T.eq(result, true, version .. " retains the ordinary critical result") + T.eq(calls, 1, version .. " retains its ordinary random draw") +end +require("tests.modkit").finish("native GBA critical battle context") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 273d86e19..f9744c50c 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -fdbfa30b20cbbaef +a000e3862d11a73a diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index cd634b55f..c6715fefd 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 8abd1ee0a..60223a3bd 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "beat_up_side_party" then +if mode == "critical_context" then + rows = dofile(root .. "/tests/enemy_actions/critical_kinds.lua").run(root, backend, version) +elseif mode == "beat_up_side_party" then rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( root, backend, From 8e2aef3f6dde6f40714d9d0b7256d473156c40df 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 32/90] 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 | 60 +++++- 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, 1040 insertions(+), 15 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 759a8abca..a36ae5f8c 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -146,3 +146,6 @@ comparison pass by changing only one engine's mod fixture. For the native damage-order correction used by portable `battle.crit` mods, see [Gen-3 critical/type rounding](gen3-critical-order.md). + +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 ac588f0ca..a34d2a6e6 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1383,7 +1383,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 c7cf75d1a..4caf880d6 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -143,6 +143,7 @@ end function R.new(loader, name, generation, api3) local portableEnemyAction = api3 and generation == 3 and name == "battle.enemy_action" + 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) @@ -216,7 +217,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), @@ -240,9 +244,10 @@ 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 == "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 @@ -253,7 +258,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 @@ -262,6 +267,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] @@ -308,6 +314,9 @@ function R.new(loader, name, generation, api3) end 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 == "world.interacted" and k == "target" then role = nil end @@ -483,7 +492,7 @@ function R.new(loader, name, generation, api3) end return out end - restore = function(value, kind) + restore = function(value, kind, original) -- GBA enemy-action normalization accepts scalar move choices and false -- fallback. Other record boundaries keep their existing validation. if name == "battle.enemy_action" and generation == 3 and kind == "action" @@ -504,10 +513,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 @@ -515,7 +532,25 @@ 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 == "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( value.id == before.id and value.side == before.side and equal(value.native, before.native), @@ -708,11 +743,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 @@ -765,8 +803,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 32da142d7..11b3a6fd6 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", "45", "battle.status_hud_visible") +add("hooks", "23", "shiny.roll gender.roll") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..fa299c0ce 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1518,6 +1518,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 b1952b3c4..e2caf9179 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,6 +17,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 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 @@ -40,6 +41,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" }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, ["battle.crit"] = { "context" }, @@ -168,7 +171,8 @@ function C.install(loader, api, owner, generation) elseif role == "items" or role == "moves" then v = boundary.native(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 090401b37..b5c20f3aa 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -148,6 +148,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 2515a38241089c3b9bd78b1cbb53b61fd4a3b61d 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 33/90] 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 440a5da635dc9737875c38e4ac324ce2343a23ee Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:17:03 +0100 Subject: [PATCH 34/90] Create native Gen-3 preset parties with constrained identities --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-constrained-party.md | 68 ++++ src/battle/BattleState.lua | 72 ++-- src/pokemon/Gen3ConstrainedMon.lua | 97 ++++++ tests/enemy_actions/constrained_party.lua | 349 +++++++++++++++++++ tests/enemy_actions/critical_kinds.lua | 7 + tests/engine/gen3_constrained_party.lua | 5 + tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 9 files changed, 557 insertions(+), 48 deletions(-) create mode 100644 docs/native-gen3-constrained-party.md create mode 100644 src/pokemon/Gen3ConstrainedMon.lua create mode 100644 tests/enemy_actions/constrained_party.lua create mode 100644 tests/engine/gen3_constrained_party.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index afeba167b..5e48cb351 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -16,6 +16,7 @@ on: - 'tests/engine/gen3_beat_up_adjustment.lua' - 'tests/engine/gen3_beat_up_side_party.lua' - 'tests/engine/gen3_critical_context.lua' + - 'tests/engine/gen3_constrained_party.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-constrained-party.md b/docs/native-gen3-constrained-party.md new file mode 100644 index 000000000..d1e057ed9 --- /dev/null +++ b/docs/native-gen3-constrained-party.md @@ -0,0 +1,68 @@ +# Native Gen-3 constrained party creation + +Teachy TV previously called the ordinary random-mon builder with midpoint IVs, +then overwrote its nature and gender. That left a PID inconsistent with its +stored identity and passed the nature into the wrong `Stats.calc` argument. +Hard-coded English species/move keys also failed against native-index or +translated registries. + +The private `Gen3ConstrainedMon` builder now follows the unchanged Gen1 native +`Pokedude.build` creation path. It consumes low/high 16-bit PID words until the +requested native nature and gender match, with `gender.roll` evaluated only for +nature-matching candidates. It creates zero IVs/EVs, native nature-adjusted +stats, full HP/PP, the PID-selected ability, experience, friendship and the +native Poke Ball. It does not invoke generic encounter generation, random +IV/DV generation or the shiny-odds roll. No new public API is introduced. + +Species, moves, nature and ball use cartridge order maps, falling back to +explicit native indices. Missing or ambiguous rows fail explicitly. All party +members are created before taking ownership of the player's actual party; +normal restoration keeps its original reference and contents. Catching retains +its already-correct move order `{93, 77, 79, 78}`. + +This change composes the critical-context work in PR #212 and the published +identity hooks in PR #169. Gen1 remains a clean, read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Reproduce + +Run `luajit tests/engine/gen3_constrained_party.lua`, or the full ROM-free +`scripts/test.sh --quick`. The enemy-action checker now also runs the shared +`tests/enemy_actions/constrained_party.lua`: + +```sh +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The maintained LuaJIT source pin is +`a4c064539ea931274d407e1249b4bb575424a0f4`, as in the existing dedicated workflow. +The fixture runs all four native presets, three seeds, three registry layouts +(canonical, raw native IDs without order maps, translated names/keys) and five +public gender policies (absent, delegation, first-candidate rejection, invalid +result fallback, call-local threshold steering). It compares the actual created +fields, every consumed word, final input-stream state and public gender callback +order, rather than overwriting generated stats afterward. + +The source executes its original LCRNG; a pass-through observer records its +actual output. The host receives an independently generated equivalent word +stream through the constructor's private RNG argument. Observations normalize +only engine schema differences: registry IDs, stat/IV/EV field names, native +gender codes and growth-curve IDs. Every observation is retained by the checker. +Host-only assertions also cover party restoration, incomplete opposing data, +the default word source and absence of a shiny callback during private creation. + +## Scope + +There are 180 paired presets per GBA data profile. The existing comparison +matrix remains intact: 56 worker runs compare 5,574 paired observations. Emerald +runs prove the shared Gen-3 builder contract with Emerald data; the retail +Teachy-TV entry point is FireRed-specific. These ROM-free tests do not certify +live UI playback or a complete retail tutorial playthrough. + +Global game RNG/VBlank scheduling and the subsequent demo controller's existing +midpoint battle RNG are still separate gaps. This constructor-level comparison +does not claim to close them. The move-effects protocol and fingerprint are +unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a34d2a6e6..2ba015d10 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1465,11 +1465,11 @@ end local POKEDUDE_DEMOS = { battle = { player = { - { species="RATTATA", level=15, nature="LONELY", - moves={"TACKLE","TAIL_WHIP","HYPER_FANG","QUICK_ATTACK"} }, + { species=19, level=15, nature=1, gender="male", + moves={33,39,158,98} }, }, - enemy = { species="PIDGEY", level=18, nature="NAUGHTY", - moves={"TACKLE","SAND_ATTACK","GUST","QUICK_ATTACK"} }, + enemy = { species=16, level=18, nature=4, gender="male", + moves={33,28,16,98} }, turns = { { action="fight", playerMove=3, enemyMove=3, voices={1} }, { action="fight", playerMove=3, enemyMove=3, voices={2,3}, repeatTurn=true }, @@ -1478,11 +1478,11 @@ local POKEDUDE_DEMOS = { }, status = { player = { - { species="RATTATA", level=15, nature="LONELY", - moves={"TACKLE","TAIL_WHIP","HYPER_FANG","QUICK_ATTACK"} }, + { species=19, level=15, nature=1, gender="male", + moves={33,39,158,98} }, }, - enemy = { species="ODDISH", level=14, nature="RASH", - moves={"ABSORB","SWEET_SCENT","POISONPOWDER"} }, + enemy = { species=43, level=14, nature=19, gender="male", + moves={71,230,77} }, turns = { { action="fight", playerMove=3, enemyMove=3 }, { action="item", item="ANTIDOTE", enemyMove=1, voices={1,2} }, @@ -1492,13 +1492,13 @@ local POKEDUDE_DEMOS = { }, matchups = { player = { - { species="POLIWAG", level=15, nature="RASH", - moves={"WATER_GUN","HYPNOSIS","BUBBLE"} }, - { species="BUTTERFREE", level=15, nature="RASH", - moves={"CONFUSION","POISONPOWDER","STUN_SPORE","SLEEP_POWDER"} }, + { species=60, level=15, nature=19, gender="male", + moves={55,95,145} }, + { species=12, level=15, nature=19, gender="male", + moves={93,77,78,79} }, }, - enemy = { species="ODDISH", level=14, nature="RASH", - moves={"ABSORB","SWEET_SCENT","POISONPOWDER"} }, + enemy = { species=43, level=14, nature=19, gender="male", + moves={71,230,77} }, turns = { { action="fight", playerMove=3, enemyMove=1 }, { action="switch", switchTo=2, enemyMove=1, voices={1,2,3} }, @@ -1508,11 +1508,11 @@ local POKEDUDE_DEMOS = { }, catch = { player = { - { species="BUTTERFREE", level=15, nature="RASH", - moves={"CONFUSION","POISONPOWDER","SLEEP_POWDER","STUN_SPORE"} }, + { species=12, level=15, nature=19, gender="male", + moves={93,77,79,78} }, }, - enemy = { species="JIGGLYPUFF", level=11, nature="CAREFUL", - moves={"SING","DEFENSE_CURL","POUND"} }, + enemy = { species=39, level=11, nature=23, gender="male", + moves={47,111,1} }, turns = { { action="fight", playerMove=1, enemyMove=3, voices={1} }, { action="fight", playerMove=3, enemyMove=3, voices={2,3} }, @@ -1522,33 +1522,14 @@ local POKEDUDE_DEMOS = { }, } -local function pokedudeMon(game, spec) - -- Fixed mid-range IVs keep the scripted demonstrations deterministic while - -- still using the ordinary Gen-3 stat path. Retail's creator also forces - -- the requested nature/gender rather than using the player's Pokémon. - local mon = Pokemon.new(game.data, spec.species, spec.level, - function(a, b) return math.floor((a + b) / 2) end) - mon.nature = spec.nature - mon.gender = "male" - mon.stats = require("src.pokemon.Stats").calc(game.data.pokemon[spec.species], - spec.level, mon.ivs, mon.evs, - mon.nature) - mon.hp = mon.stats.hp - mon.moves = {} - for _, id in ipairs(spec.moves) do - local def = game.data.moves[id] - mon.moves[#mon.moves + 1] = { id=id, pp=def and def.pp or 0, - maxPp=def and def.pp or 0 } - end - return mon -end - -function BattleState.newPokedudeDemo(game, lesson) +function BattleState.newPokedudeDemo(game, lesson, rng) + local Preset = require("src.pokemon.Gen3ConstrainedMon") local def = POKEDUDE_DEMOS[lesson] assert(def, "unknown POKé DUDE lesson " .. tostring(lesson)) local savedParty = game.save.party local party = {} - for _, spec in ipairs(def.player) do party[#party + 1] = pokedudeMon(game, spec) end + for _, spec in ipairs(def.player) do party[#party + 1] = Preset.new(game.data, spec, rng) end + local enemyParty = { Preset.new(game.data, def.enemy, rng) } require("src.pokemon.PartyAside").hold(game.save, savedParty) -- kept in the save too game.save.party = party @@ -1556,7 +1537,7 @@ function BattleState.newPokedudeDemo(game, lesson) self.kind = "wild" self.noRun = true self.player = makeBattler(game.data, party[1], true, game.save) - self.enemyParty = { pokedudeMon(game, def.enemy) } + self.enemyParty = enemyParty self.enemy = makeBattler(game.data, self.enemyParty[1], false) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) @@ -1565,10 +1546,9 @@ function BattleState.newPokedudeDemo(game, lesson) lesson=lesson, def=def, turn=1, timer=0, savedParty=savedParty, voiceDone={}, } - -- The retail tutorial is a prerecorded demonstration, not a battle whose - -- damage/accuracy swings with the player's global RNG state. A midpoint - -- generator keeps the scripted turns stable while still running through the - -- ordinary Gen-3 battle formulas and status checks. + -- The existing scripted-turn midpoint policy is separate from native + -- identity creation above. Whole-game RNG scheduling and retail demo + -- playback timing still need their own controller-level comparison. self.rng = function(a, b) if a ~= nil and b ~= nil then return math.floor((a + b) / 2) end if a ~= nil then return math.max(1, math.floor((a + 1) / 2)) end diff --git a/src/pokemon/Gen3ConstrainedMon.lua b/src/pokemon/Gen3ConstrainedMon.lua new file mode 100644 index 000000000..8f78e4b45 --- /dev/null +++ b/src/pokemon/Gen3ConstrainedMon.lua @@ -0,0 +1,97 @@ +-- Private native preset builder (CreateMonWithGenderNatureLetter). +-- Unlike an ordinary encounter, this draws only candidate PID words: no +-- random IVs/DVs, learnset moves or shiny-odds roll. Specs use cartridge IDs. +local Stats = require("src.pokemon.Stats") +local Identity = require("src.pokemon.IdentityRolls") +local M = {} + +local function resolve(data, registry, orderName, number) + local rows = data[registry] or {} + local order = (data.constants or {})[orderName] or {} + local id = order[number] + if id ~= nil and rows[id] then + return id, rows[id] + end + local found + for key, row in pairs(rows) do + if tonumber(row.index) == number then + assert(found == nil or rows[found] == row, "ambiguous native " .. registry .. " ID " .. number) + found = key + end + end + assert(found ~= nil, "missing native " .. registry .. " ID " .. number) + return found, rows[found] +end + +function M.new(data, spec, rng) + assert( + type(spec.nature) == "number" and spec.nature % 1 == 0 and spec.nature >= 0 and spec.nature < 25, + "native preset requires a nature ID in 0..24" + ) + assert( + spec.gender == "male" or spec.gender == "female" or spec.gender == "unknown", + "native preset requires a gender" + ) + local species, def = resolve(data, "pokemon", "speciesOrder", spec.species) + assert(Stats.isGen3(def), "native preset requires a Gen-3 species") + local constants = data.constants or {} + local nature = (constants.natureOrder or {})[spec.nature + 1] + if nature == nil then + for id, row in pairs(constants.natures or {}) do + if tonumber(row.index) == spec.nature then + nature = id + break + end + end + end + assert(nature ~= nil, "missing native nature ID " .. tostring(spec.nature)) + local ball = resolve(data, "items", "itemOrder", 4) + local moves = {} + for _, number in ipairs(spec.moves) do + if number ~= 0 then + local id, move = resolve(data, "moves", "moveOrder", number) + moves[#moves + 1] = { id = id, pp = move.pp, maxPp = move.pp } + end + end + rng = rng or love.math.random + local mon = { + species = species, + level = spec.level, + metLevel = spec.level, + nature = nature, + ball = ball, + growthRate = def.growthRate, + catchRate = def.catchRate, + moves = moves, + ivs = {}, + evs = {}, + friendship = def.friendship or 70, + happiness = def.friendship or 70, + } + repeat + -- Random32 consumes the low word first. Gender is evaluated only for + -- matching natures, including the public hook's rejection/override policy. + local lo, hi = rng(0, 65535), rng(0, 65535) + mon.personality = lo + hi * 65536 + until mon.personality % 25 == spec.nature and Identity.gender(data, mon, 3) == spec.gender + mon.gender = spec.gender + local abilities = def.abilities or {} + mon.abilitySlot = abilities[2] and mon.personality % 2 + 1 or 1 + mon.ability = abilities[mon.abilitySlot] + for _, stat in ipairs(Stats.ORDER_GEN3) do + mon.ivs[stat], mon.evs[stat] = 0, 0 + end + -- Native nature effects follow the PID's 0..24 matrix, independently of + -- global preview state or translated nature registry keys. + mon.stats = Stats.calcGen3(def, spec.level, mon.ivs, mon.evs) + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(spec.nature / 5) + 1, spec.nature % 5 + 1 + if up ~= down then + mon.stats[order[up]] = math.floor(mon.stats[order[up]] * 1.1) + mon.stats[order[down]] = math.floor(mon.stats[order[down]] * 0.9) + end + mon.stats.special, mon.hp = mon.stats.spatk, mon.stats.hp + mon.exp = require("src.pokemon.Growth").expForLevel(def.growthRate, spec.level) + return mon +end +return M diff --git a/tests/enemy_actions/constrained_party.lua b/tests/enemy_actions/constrained_party.lua new file mode 100644 index 000000000..4d7480e54 --- /dev/null +++ b/tests/enemy_actions/constrained_party.lua @@ -0,0 +1,349 @@ +-- Run the actual native builders with ROM-shaped data, never patched formulas. +local M = {} +local SPECIES = { + { 19, "RATTATA", { 30, 56, 35, 72, 25, 35 }, 127, 50, 62, 70, 0 }, + { 16, "PIDGEY", { 40, 45, 40, 56, 35, 35 }, 127, 51, 0, 70, 3 }, + { 43, "ODDISH", { 45, 50, 55, 30, 75, 65 }, 127, 34, 0, 70, 3 }, + { 60, "POLIWAG", { 40, 50, 40, 90, 40, 40 }, 127, 11, 6, 70, 3 }, + { 12, "BUTTERFREE", { 60, 45, 50, 70, 80, 80 }, 127, 14, 0, 70, 0 }, + { 39, "JIGGLYPUFF", { 115, 45, 20, 20, 45, 25 }, 191, 56, 0, 35, 4 }, +} +local MOVES = { + { 33, "TACKLE", 35 }, + { 39, "TAIL_WHIP", 30 }, + { 158, "HYPER_FANG", 15 }, + { 98, "QUICK_ATTACK", 30 }, + { 28, "SAND_ATTACK", 15 }, + { 16, "GUST", 35 }, + { 71, "ABSORB", 25 }, + { 230, "SWEET_SCENT", 20 }, + { 77, "POISONPOWDER", 35 }, + { 55, "WATER_GUN", 25 }, + { 95, "HYPNOSIS", 20 }, + { 145, "BUBBLE", 30 }, + { 93, "CONFUSION", 25 }, + { 78, "STUN_SPORE", 30 }, + { 79, "SLEEP_POWDER", 15 }, + { 47, "SING", 15 }, + { 111, "DEFENSE_CURL", 40 }, + { 1, "POUND", 35 }, +} +local LESSONS = { "battle", "status", "matchups", "catch" } +local STATS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local SHORT = { "hp", "atk", "def", "spe", "spa", "spd" } +local SOURCE_STATS = { "maxHp", "attack", "defense", "speed", "spAtk", "spDef" } +local NATURES = { [1] = "LONELY", [4] = "NAUGHTY", [19] = "RASH", [23] = "CAREFUL" } +local GROWTH = { [0] = "MEDIUM_FAST", [3] = "MEDIUM_SLOW", [4] = "FAST" } +local GROWTH_IDS = {} +for n, id in pairs(GROWTH) do + GROWTH_IDS[id] = n +end +local function key(format, prefix, n, name) + if format == "raw" then + return (prefix .. "_%03d"):format(n) + end + return format == "translated" and ("DE_" .. name) or name +end +function M.run(root, backend, version) + local native, all = backend == "gen1", {} + for _, format in ipairs({ "canonical", "raw", "translated" }) do + local speciesKeys, moveKeys, expectedPp, natureIds, abilityIds = {}, {}, {}, {}, {} + local function configure(data, engine, ids, rom, names) + data.constants = data.constants or {} + data.constants.speciesOrder, data.constants.moveOrder = {}, {} + data.constants.natureOrder, data.constants.natures = {}, {} + data.abilities = data.abilities or {} + if not native then + local id = key(format, "ITEM", 4, "POKE_BALL") + data.items[id] = { index = 4, name = "POKE BALL", ball = "POKE_BALL" } + data.constants.itemOrder = { [4] = id } + if format == "raw" then + data.items.FIX_BADGE_1.index = nil + data.constants.itemOrder = nil + end + end + for n, name in pairs(NATURES) do + local id = key(format, "NATURE", n, name) + data.constants.natureOrder[n + 1] = id + data.constants.natures[id] = { index = n, modifiers = {} } + natureIds[id] = n + end + for _, s in ipairs(SPECIES) do + local n, name, stats, ratio, a1, a2, friendship, growth = unpack(s) + local id = key(format, "SPECIES", n, name) + speciesKeys[n] = id + local display = format == "translated" and ("DE " .. name) or name + if native then + local p = data.gen3Pokemon + p._names[n], p._types[n] = display, { 0, 0 } + p._stats[n] = {} + for i, k in ipairs(SHORT) do + p._stats[n][k] = stats[i] + end + p._speciesMeta[n] = { + genderRatio = ratio, + friendship = friendship, + growthRate = growth, + catchRate = 45, + expYield = 50, + } + p._abilities[n] = { a1, a2 } + p._learnsets[n] = { { 1, 33 } } + else + local def = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + def.id, def.name, def.index, def.dex = id, display, n, n + def.baseStats, def.evYield, def.genderRatio = {}, {}, ratio + for i, k in ipairs(STATS) do + def.baseStats[k] = stats[i] + end + def.baseStats.special = def.baseStats.spatk + def.friendship, def.growthRate, def.evolutions = friendship, GROWTH[growth], {} + def.abilities = {} + for i, a in ipairs({ a1, a2 }) do + if a ~= 0 then + local aid = "ABILITY_" .. a + def.abilities[i], abilityIds[aid] = aid, a + data.abilities[aid] = { index = a, name = aid } + end + end + data.pokemon[id] = def + data.constants.speciesOrder[n] = id + end + end + for _, m in ipairs(MOVES) do + local n, name, pp = unpack(m) + local id = key(format, "MOVE", n, name) + moveKeys[id], expectedPp[n] = n, pp + local display = format == "translated" and ("DE " .. name) or name + names[n] = display + if native then + local d = { + effect = 0, + power = 35, + type = 0, + target = 0, + priority = 0, + accuracy = 100, + pp = pp, + flags = 0, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, display + else + -- Remove fixture aliases: translated/raw rows are the only valid IDs. + for k, d in pairs(data.moves) do + if d.index == n then + data.moves[k] = nil + end + end + data.moves[id] = { + id = id, + name = display, + index = n, + pp = pp, + power = 35, + accuracy = 100, + type = "NORMAL", + effect = "NO_ADDITIONAL_EFFECT", + } + data.constants.moveOrder[n] = id + end + end + if not native then + if format == "raw" then + data.constants.speciesOrder, data.constants.moveOrder, data.constants.natureOrder = nil, nil, nil + end + for _, s in ipairs(SPECIES) do + if speciesKeys[s[1]] ~= s[2] then + data.pokemon[s[2]] = nil + end + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, calls, policy = f.T, {}, "none" + local stop + local function hook(nextGender, c) + calls[#calls + 1] = + { species = c.species, def = c.def.id, ratio = c.ratio, personality = c.personality } + if policy == "reject-first" and #calls == 1 then + return "female" + end + if policy == "invalid" then + return "not-a-gender" + end + if policy == "threshold" then + c.def.genderRatio = 0 + end + return nextGender(c) + end + local sourceRng, oldRandom, oldState + if native then + sourceRng = require("src.core.game3.rng") + oldRandom, oldState = sourceRng.Random, sourceRng.getState() + end + for _, mode in ipairs({ "none", "delegate", "reject-first", "invalid", "threshold" }) do + policy = mode + if mode ~= "none" then + stop = f.e.api.hooks:wrap("gender.roll", hook) + end + for _, seed in ipairs({ 0, 1, 65535 }) do + for lesson, name in ipairs(LESSONS) do + calls = {} + local words, state = {}, seed + -- Independent input generator; the source still executes its original Random(). + local function word(lo, hi) + T.eq(lo, 0, "constructor requests a complete low/high word") + T.eq(hi, 65535, "constructor requests a complete low/high word") + local low, high = state % 65536, math.floor(state / 65536) + state = (low * 20077 + ((low * 16838 + high * 20077) % 65536) * 65536 + 24691) % 4294967296 + local value = math.floor(state / 65536) + words[#words + 1] = value + return value + end + local original = { { species = "REAL", hp = 17, nested = { marker = seed } } } + f.game.save.party = original + local before = require("src.link.Json").encode(original) + local party, foes, b + if native then + sourceRng.SeedRng(seed) + sourceRng.Random = function() + local result = oldRandom() + words[#words + 1] = result + return result + end + party, foes = require("src.core.game3.battle.pokedude").build(lesson - 1) + state = sourceRng.getState().value + sourceRng.Random = oldRandom + else + b = require("src.battle.BattleState").newPokedudeDemo(f.game, name, word) + party, foes = f.game.save.party, b.enemyParty + end + T.eq(#party, lesson == 3 and 2 or 1, "native player party size") + T.eq(#foes, 1, "native opposing party size") + local observed = {} + for _, side in ipairs({ party, foes }) do + local out = {} + for _, mon in ipairs(side) do + local n = native and mon.species or f.data.pokemon[mon.species].index + local stats, ivs, evs, moves = {}, {}, {}, {} + for i, k in ipairs(STATS) do + stats[i] = native and mon[SOURCE_STATS[i]] or mon.stats[k] + ivs[i], evs[i] = mon.ivs[native and SHORT[i] or k], mon.evs[native and SHORT[i] or k] + T.eq(ivs[i], 0, "native preset IV is zero") + T.eq(evs[i], 0, "native preset EV is zero") + end + for j, m in ipairs(mon.moves) do + local mn = native and m or moveKeys[m.id] + local pp, maxPp = native and mon.pp[j] or m.pp, native and mon.maxPp[j] or m.maxPp + T.eq(pp, expectedPp[mn], "move PP follows its native definition") + T.eq(maxPp, pp, "native slots start with full PP") + moves[#moves + 1] = { mn, pp, maxPp } + end + local nature = native and mon.nature or natureIds[mon.nature] + T.eq(mon.personality % 25, nature, "stored nature agrees with the native PID") + T.eq(mon.gender, native and "M" or "male", "native requested gender is stored") + T.eq(mon.hp, stats[1], "preset starts at full HP") + local ball = native and mon.pokeball or (f.data.items[mon.ball] or {}).index + T.eq(ball, 4, "native preset records its Poke Ball") + out[#out + 1] = { + species = n, + level = mon.level, + metLevel = mon.metLevel, + exp = mon.exp, + personality = mon.personality, + nature = nature, + gender = "male", + stats = stats, + ivs = ivs, + evs = evs, + moves = moves, + ability = native and mon.ability or abilityIds[mon.ability], + ball = ball, + growthRate = native and mon.growthRate or GROWTH_IDS[mon.growthRate], + happiness = mon.happiness, + friendship = mon.friendship, + } + end + observed[#observed + 1] = out + end + if lesson == 4 then + local ids = {} + for _, m in ipairs(observed[1][1].moves) do + ids[#ids + 1] = m[1] + end + T.same(ids, { 93, 77, 79, 78 }, "catching lesson preserves native move-slot order") + end + if b then + T.eq(b.player.mon, party[1], "native player battler uses the created party member") + T.eq(b.enemy.mon, foes[1], "native opposing battler uses the created foe") + b:restorePokedudeParty() + T.eq(f.game.save.party, original, "demo restores the original party reference") + b:restorePokedudeParty() + T.eq(f.game.save.party, original, "restoration is idempotent") + end + T.eq( + require("src.link.Json").encode(original), + before, + "native preset leaves real party bytes unchanged" + ) + all[#all + 1] = { + format = format, + mode = mode, + seed = seed, + lesson = name, + parties = observed, + words = words, + finalState = state, + genderCalls = calls, + } + end + end + if stop then + stop() + stop = nil + end + end + if native then + sourceRng.Random = oldRandom + sourceRng.setState(oldState) + else + local B, Preset = require("src.battle.BattleState"), require("src.pokemon.Gen3ConstrainedMon") + local original = f.game.save.party + local missing = f.data.moves[key(format, "MOVE", 16, "GUST")] + f.data.moves[key(format, "MOVE", 16, "GUST")] = nil + T.raises( + function() + B.newPokedudeDemo(f.game, "battle") + end, + "missing native moves ID 16", + "incomplete preset data fails explicitly before party ownership changes" + ) + T.eq(f.game.save.party, original, "failed opposing preset leaves the real party selected") + T.eq(f.game.save.partyAside, nil, "failed preset does not leave a borrowed-party stash") + f.data.moves[key(format, "MOVE", 16, "GUST")] = missing + local shinyCalls, wordCalls = 0, 0 + local stopShiny = f.e.api.hooks:wrap("shiny.roll", function(nextShiny, ...) + shinyCalls = shinyCalls + 1 + return nextShiny(...) + end) + local oldRandom = love.math.random + love.math.random = function(lo, hi) + wordCalls = wordCalls + 1 + T.eq(lo, 0, "default preset source requests a low/high word") + T.eq(hi, 65535, "default preset source requests a low/high word") + return wordCalls % 2 == 1 and 151 or 0 + end + local mon = + Preset.new(f.data, { species = 19, level = 15, nature = 1, gender = "male", moves = { 33 } }) + love.math.random = oldRandom + stopShiny() + T.eq(mon.personality, 151, "default word source follows native low-word-first identity") + T.eq(wordCalls, 2, "native preset draws no generic IV/DV or shiny-odds randomness") + T.eq(shinyCalls, 0, "private native creation does not call the shiny predicate") + end + f.close() + end + return all +end +return M diff --git a/tests/enemy_actions/critical_kinds.lua b/tests/enemy_actions/critical_kinds.lua index d6dffb2b8..c3f0fd60b 100644 --- a/tests/enemy_actions/critical_kinds.lua +++ b/tests/enemy_actions/critical_kinds.lua @@ -38,6 +38,13 @@ function M.run(root, backend, version) }) do data.items[id] = { index = row[1], name = id, holdEffect = row[2], holdEffectParam = 4 } end + data.constants.itemOrder = { [4] = "POKE_BALL" } + data.items.POKE_BALL = { index = 4, name = "POKE BALL", ball = "POKE_BALL" } + data.constants.natureOrder = { [2] = "LONELY", [5] = "NAUGHTY" } + data.pokemon.CHARMANDER.genderRatio = 127 + for _, number in ipairs({ 39, 158, 98, 28, 16 }) do + data.moves["MOVE_" .. number] = { index = number, pp = 30 } + end for name, index in pairs({ RATTATA = 19, PIDGEY = 16, CHANSEY = 113, FARFETCH_D = 83 }) do local d = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) d.id, d.name, d.index, d.dex, d.evolutions = name, name, index, index, {} diff --git a/tests/engine/gen3_constrained_party.lua b/tests/engine/gen3_constrained_party.lua new file mode 100644 index 000000000..59d06a21c --- /dev/null +++ b/tests/engine/gen3_constrained_party.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.constrained_party").run(".", "gen2", version) +end +require("tests.modkit").finish("native Gen-3 constrained party construction") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index c6715fefd..ab88aead4 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 60223a3bd..7b5e307da 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "critical_context" then +if mode == "constrained_party" then + rows = dofile(root .. "/tests/enemy_actions/constrained_party.lua").run(root, backend, version) +elseif mode == "critical_context" then rows = dofile(root .. "/tests/enemy_actions/critical_kinds.lua").run(root, backend, version) elseif mode == "beat_up_side_party" then rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( From 184fdaaaf10db458065533d30522bdef34ec7377 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:37:36 +0100 Subject: [PATCH 35/90] Implement native GBA accuracy with retained execution gap evidence --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-accuracy-work.md | 54 +++ src/battle/BattleState.lua | 13 +- src/battle/EffectRegistry.lua | 13 +- src/battle/Gen3Accuracy.lua | 148 ++++++++ tests/enemy_actions/accuracy.lua | 369 +++++++++++++++++++ tests/engine/gen3_native_accuracy.lua | 7 + tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 6 +- 9 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 docs/native-gen3-accuracy-work.md create mode 100644 src/battle/Gen3Accuracy.lua create mode 100644 tests/enemy_actions/accuracy.lua create mode 100644 tests/engine/gen3_native_accuracy.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 5e48cb351..6e8e3788d 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -17,6 +17,7 @@ on: - 'tests/engine/gen3_beat_up_side_party.lua' - 'tests/engine/gen3_critical_context.lua' - 'tests/engine/gen3_constrained_party.lua' + - 'tests/engine/gen3_native_accuracy.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-accuracy-work.md b/docs/native-gen3-accuracy-work.md new file mode 100644 index 000000000..50f95b2df --- /dev/null +++ b/docs/native-gen3-accuracy-work.md @@ -0,0 +1,54 @@ +# Native GBA accuracy work in progress + +This development branch starts at PR #214 (`440a5da6`). It is not yet a pull +request and is not included in the green `codex/portability-testing` branch. +The clean read-only Gen1 reference remains +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +Implemented: the private `Gen3Accuracy` command uses native percentage draws, +the combined accuracy/evasion stage ladder, ordered ability/item floors, +Foresight, all-active-battler weather suppression, Protect/semi-invulnerability, +Oak's separate damage/stat flags and Teachy-TV guarantees. The public +`battle.accuracy` boundary carries native move IDs and the calculated accuracy. +As in the reference, the threshold is steerable; replacing the context's `rng` +field does not replace the live battle stream. Absorb abilities are resolved +before damage computation and do not print a generic miss message. Older +cartridges keep the existing byte path. + +The actual reference `Engine.newContext(...):accuracyCheck` is compared with +`BattleState:accuracyRoll`. A second matrix executes the real move resolver on +both engines with a shared public critical hook returning false, to isolate +accuracy/secondary ordering from the already-tested critical command. It keeps +all bounded RNG calls and values, public accuracy contexts, damage-callback +counts, PP, target HP, stat stages and Flash Fire state. No observation is +removed or rewritten to hide mismatches. + +Current local evidence (maintained LuaJIT pin +`a4c064539ea931274d407e1249b4bb575424a0f4`): + +- 15,082 focused assertions pass on FireRed/Emerald profiles. The complete + accuracy-command observation matrices match exactly. +- Full ROM-free run: 96/105 engine suites, SDK 3/3 and B30. The exact Counter of + the 25 inherited FAIL lines across nine suites matches clean #214 (95/104). +- Strict shared checker: 64 worker runs / 12,314 paired observations, **not + fully passing**. All prior modes and both new accuracy-command comparisons + pass. The execution matrix retains 56 different Thunder cases per profile: + the reference's post-hit secondary roll is `0..99`; the legacy host handler + uses `0..255`. The accuracy thresholds, accuracy draw boundaries and other + observed ordinary cases agree. The raw mismatch report is retained in + `tmp/native-accuracy-qa/retained-execution-gaps.json`; the complete report is + `tmp/native-accuracy-qa/contracts-first.json`. These are ignored private QA + outputs, not distributed cartridge data. + +Before publishing or integrating this branch, implement and independently test +the shared native post-hit secondary-chance path, including explicit zero +chance, Serene Grace, Shield Dust, Substitute and status/type guards. Do not +normalize or filter the differing RNG calls to make the checker pass. Decide +and test a link-visible protocol revision for the changed native battle +behavior; current green fingerprint tests alone do not prove mixed-version +link safety. + +Remaining adjacent lifecycle work includes native timed Lock-On ownership +(the existing one-use host pointer keeps its previous lifetime), whole-game +RNG/VBlank scheduling and the demo controller's midpoint battle RNG/playback. +This branch does not claim those producers or a full tutorial playthrough. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2ba015d10..30243122e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5167,6 +5167,9 @@ end -- sun and rain, and `50 percent + 1` is 128 rather than the 127 a percentage -- would round to. function BattleState:accuracyRoll(move, user, target, accuracyRaw) + if GameVersion.isGen3() then + return require("src.battle.Gen3Accuracy").check(self, move, user, target, accuracyRaw) + end -- LOCK-ON / MIND READER: the next move cannot miss, and the aim is spent -- whether it was needed or not. if user.lockedOn == target then @@ -8163,9 +8166,13 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the -- never-miss paths (X ACCURACY) live inside Damage.accuracyRoll - if record.accuracyChecked - and (target.invulnerable - or not self:accuracyRoll(move, user, target)) then + local hit, reason = true, nil + if record.accuracyChecked then + if target.invulnerable and not GameVersion.isGen3() then hit = false + else hit, reason = self:accuracyRoll(move, user, target) end + end + if not hit then + if reason == "absorbed" then self:cancelMoveAnim(); return end -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- after the effect lands; a miss skips it self:cancelMoveAnim() diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 1946c813e..7076dd698 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -104,6 +104,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local user, target = ctx.user, ctx.target local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss + local gba = require("src.core.GameVersion").isGen3() -- alwaysHits is neverMiss's weaker sibling and the distinction is CheckHit's -- own ordering: .FlyDigMoves is tested BEFORE .ThunderRain -- (effect_commands.asm:1559-1563), so Thunder in rain skips the accuracy @@ -115,13 +116,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- accuracyRaw replaces the move's accuracy BYTE for this turn -- (BattleCommand_ThunderAccuracy), so it is a 0-255 threshold, not a percent local accuracyRaw = record and record.accuracyRaw and record.accuracyRaw(ctx) + local nativeAccuracy = gba and (not neverMiss and not alwaysHits + or move.gen3Effect == 17 or move.gen3Effect == 78 or move.gen3Effect == 152) -- PROTECT and DETECT, before anything else happens. Only a move the -- cartridge marks PROTECT-affected is stopped, which is the second use the -- extracted flags byte is put to; a move with no flags recorded (every Gen -- 1 and Gen 2 one) is treated as affected, which is that generation's rule. local lockedOHKO = record and record.gbaOHKO and user.lockedOn == target - if target.protecting and move.protectAffected ~= false and not lockedOHKO then + if target.protecting and (not gba or target ~= user) and move.protectAffected ~= false and not lockedOHKO then battle:cancelMoveAnim() if record and record.onProtect and record.onProtect(ctx) then return end battle:sayNext(Strings("%s\nprotected itself!", displayName(target))) @@ -132,7 +135,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- SWIFT_EFFECT before the INVULNERABLE check), and so does SKY UPPERCUT, -- which reaches a target in the middle of FLY without being a never-miss -- move in any other respect -- hence the second, narrower field. - if target.invulnerable and not neverMiss + if target.invulnerable and not nativeAccuracy and not neverMiss and not (record and record.neverMissInvulnerable) and not lockedOHKO then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end @@ -162,8 +165,10 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end - if not neverMiss and not alwaysHits then - if not battle:accuracyRoll(move, user, target, accuracyRaw) then + if nativeAccuracy or not neverMiss and not alwaysHits then + local hit, reason = battle:accuracyRoll(move, user, target, accuracyRaw) + if not hit then + if reason == "absorbed" then battle:cancelMoveAnim(); return end -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) diff --git a/src/battle/Gen3Accuracy.lua b/src/battle/Gen3Accuracy.lua new file mode 100644 index 000000000..a4f01fcef --- /dev/null +++ b/src/battle/Gen3Accuracy.lua @@ -0,0 +1,148 @@ +-- Native GBA accuracy command; older generations keep their byte model. +local Abilities = require("src.battle.Abilities") +local Runtime = require("src.mods.Runtime") +local A = {} +local STAGE = { + [-6] = 33, + [-5] = 36, + [-4] = 43, + [-3] = 50, + [-2] = 60, + [-1] = 75, + [0] = 100, + 133, + 166, + 200, + 233, + 266, + 300, +} +local function hasFlag(battle, flag) + return math.floor((tonumber(battle.oakMsgFlags) or 0) / flag) % 2 == 1 +end +local function weather(battle) + local sky = require("src.battle.Weather").raw(battle) + if not sky then + return nil + end + for _, b in battle:activeBattlers() do + local ab = Abilities.of(b) + if ab == "CLOUD_NINE" or ab == "AIR_LOCK" then + return nil + end + end + return sky == "SANDSTORM" and "SAND" or sky +end +function A.guaranteed(battle, user, target, move) + if battle.firstBattle and user.isPlayer then + if not hasFlag(battle, (tonumber(move.power) or 0) > 0 and 1 or 2) then + return true + end + end + return battle.pokedudeDemo ~= nil and battle.pokedudeDemo ~= false + or battle.pokedude == true + or user.lockedOn == target +end +local function absorbed(battle, user, target, move) + if target == user then + return false + end + local ability, kind = Abilities.of(target), Abilities.normalizeType(move.type) + local can = (tonumber(move.power) or 0) ~= 0 + and (ability == "VOLT_ABSORB" and kind == "ELECTRIC" or ability == "WATER_ABSORB" and kind == "WATER") + or ability == "FLASH_FIRE" and kind == "FIRE" and target.mon.status ~= "FRZ" + if can then + battle:abilityAbsorb(target, move) + return true + end + return false +end +function A.check(battle, move, user, target, accuracyRaw) + if target ~= user and target.protecting and move.protectAffected ~= false then + return false, "protected" + end + local guaranteed = A.guaranteed(battle, user, target, move) + -- The existing Lock-On producer uses a one-use pointer. Its native timed + -- producer is a separate lifecycle contract; preserve that legacy lifetime. + if user.lockedOn == target then + user.lockedOn = nil + end + local effect = tonumber(move.gen3Effect) + if not guaranteed and target ~= user and target.invulnerable then + local hidden = target.hiddenAs + local air = hidden == "air" or hidden == "ON_AIR" + local number = tonumber(move.index) + local reaches = air and (effect == 152 or effect == 207 or number == 16 or number == 239) + or (hidden == "underground" or hidden == "UNDERGROUND") and (number == 89 or number == 222) + or (hidden == "underwater" or hidden == "UNDERWATER") and (number == 57 or number == 250) + if not reaches then + return false, "invulnerable" + end + end + local sky = weather(battle) + if guaranteed or effect == 17 or effect == 78 or effect == 152 and sky == "RAIN" then + if absorbed(battle, user, target, move) then + return false, "absorbed" + end + return true + end + local buff = (user.stages and user.stages.accuracy or 0) + - (target.identified and 0 or target.stages and target.stages.evasion or 0) + buff = math.max(-6, math.min(6, buff)) + local base = tonumber(move.accuracy) or 100 + if effect == 152 and sky == "SUN" then + base = 50 + elseif accuracyRaw ~= nil then + base = accuracyRaw * 100 / 255 + end + local calc = math.floor(STAGE[buff] * base / 100) + local mine = Abilities.of(user) + if mine == "COMPOUND_EYES" or mine == "COMPOUNDEYES" then + calc = math.floor(calc * 130 / 100) + end + if sky == "SAND" and Abilities.of(target) == "SAND_VEIL" then + calc = math.floor(calc * 80 / 100) + end + if + mine == "HUSTLE" + and require("src.battle.TypeChart").category(Abilities.normalizeType(move.type)) == "physical" + then + calc = math.floor(calc * 80 / 100) + end + local held, parameter = require("src.battle.HoldItems").effectOf(target, "accuracy") + if held == "EVASION_UP" then + calc = math.floor(calc * (100 - parameter) / 100) + end + local function draw(c) + -- Native accuracy uses the live battle stream; the public rng field is + -- informational, while c.accuracy is the steerable threshold. + return battle.rng(1, 100) <= c.accuracy + end + local hit + if Runtime.wantsHook("battle.accuracy") then + hit = Runtime.call("battle.accuracy", draw, { + battle = battle, + move = move, + moveId = move.id, + moveNum = tonumber(move.index), + user = user, + target = target, + accuracy = calc, + rng = battle.rng, + ruleset = battle.ruleset, + data = battle.data, + accuracyRaw = accuracyRaw, + weather = sky, + }) + else + hit = draw({ accuracy = calc }) + end + if not hit then + return false + end + if absorbed(battle, user, target, move) then + return false, "absorbed" + end + return true +end +return A diff --git a/tests/enemy_actions/accuracy.lua b/tests/enemy_actions/accuracy.lua new file mode 100644 index 000000000..2abfc5ab1 --- /dev/null +++ b/tests/enemy_actions/accuracy.lua @@ -0,0 +1,369 @@ +-- Exercise the real native accuracy command and the public API-3 hook. +local M = {} +local STAGE = { + [-6] = 33, + [-5] = 36, + [-4] = 43, + [-3] = 50, + [-2] = 60, + [-1] = 75, + [0] = 100, + 133, + 166, + 200, + 233, + 266, + 300, +} +function M.run(root, backend, version, execution) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ { 129, "SWIFT" }, { 87, "THUNDER" }, { 327, "SKY_UPPERCUT" } }) do + local number, name = unpack(row) + local id = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if engine == "gen1" then + local d = require("src.mods.Merge").deepCopy(data.gen3Moves._rom[33]) + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = d, d, name + else + local d = require("src.mods.Merge").deepCopy(data.moves[ids.TACKLE]) + d.id, d.name, d.index = id, name, number + data.moves[id] = d + end + end + if engine == "gen2" then + data.items.BRIGHTPOWDER = + { index = 179, name = "BRIGHTPOWDER", holdEffect = "EVASION_UP", holdEffectParam = 10 } + data.items.LAX_INCENSE = + { index = 221, name = "LAX INCENSE", holdEffect = "EVASION_UP", holdEffectParam = 5 } + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, calls, draws, policy = f.T, {}, {}, {}, "delegate" + local stop + local damageCalls = 0 + local stopCrit, stopDamage + if execution then + stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + damageCalls = damageCalls + 1 + return nextDamage(...) + end) + end + local function hook(nextAccuracy, c) + calls[#calls + 1] = { + move = c.moveId, + number = c.moveNum, + accuracy = c.accuracy, + user = c.user.mon.nickname, + target = c.target.mon.nickname, + battle = c.battle ~= nil, + } + if policy == "force" then + return true + end + if policy == "suppress" then + return false + end + if policy == "threshold" then + c.accuracy = 37 + end + if policy == "rng" then + c.rng = function(lo, hi) + T.eq(lo, 1, "replacement accuracy RNG lower bound") + T.eq(hi, 100, "replacement accuracy RNG upper bound") + return 1 + end + end + return nextAccuracy(c) + end + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local abilities = { + NONE = 0, + COMPOUND_EYES = 14, + HUSTLE = 55, + SAND_VEIL = 8, + CLOUD_NINE = 13, + VOLT_ABSORB = 10, + WATER_ABSORB = 11, + FLASH_FIRE = 18, + } + local cases = {} + for acc = -6, 6 do + for eva = -6, 6 do + for _, word in ipairs({ 0, 49, 82, 83, 99 }) do + cases[#cases + 1] = + { name = "stages", accuracy = 83, acc = acc, eva = eva, word = word, user = (acc - eva) % 4 } + end + end + end + for _, spec in ipairs({ + { name = "swift", accuracy = 1, move = "SWIFT", number = 129, effect = 17 }, + { name = "swift-air", accuracy = 1, move = "SWIFT", number = 129, effect = 17, semi = "ON_AIR" }, + { name = "thunder-rain", accuracy = 70, move = "THUNDER", number = 87, effect = 152, sky = "RAIN" }, + { name = "thunder-sun", accuracy = 70, move = "THUNDER", number = 87, effect = 152, sky = "SUN" }, + { + name = "thunder-air", + accuracy = 70, + move = "THUNDER", + number = 87, + effect = 152, + semi = "ON_AIR", + executionOnly = true, + }, + { + name = "sky-uppercut-air", + accuracy = 90, + move = "SKY_UPPERCUT", + number = 327, + effect = 207, + semi = "ON_AIR", + executionOnly = true, + }, + { name = "full-accuracy", accuracy = 100 }, + { name = "zero-accuracy", accuracy = 0 }, + { name = "compound-eyes", mine = "COMPOUND_EYES", accuracy = 73, acc = 1 }, + { name = "hustle", mine = "HUSTLE", accuracy = 83, acc = -1 }, + { name = "hustle-special", mine = "HUSTLE", kind = 13, accuracy = 83 }, + { name = "sand-veil", theirs = "SAND_VEIL", sky = "SAND", accuracy = 83, acc = 1 }, + { name = "sand-veil-suppressed", theirs = "SAND_VEIL", sky = "SAND", suppressSky = true, accuracy = 83 }, + { name = "brightpowder", item = "BRIGHTPOWDER", accuracy = 83, acc = 1 }, + { name = "lax-incense", item = "LAX_INCENSE", accuracy = 83, acc = 1 }, + { + name = "composed-modifiers", + mine = "COMPOUND_EYES", + theirs = "SAND_VEIL", + sky = "SAND", + item = "LAX_INCENSE", + accuracy = 73, + acc = 1, + }, + { name = "identified", identified = true, accuracy = 83, acc = -1, eva = 6 }, + { name = "locked-on", locked = true, accuracy = 1 }, + { name = "semi-air", semi = "ON_AIR", accuracy = 100 }, + { name = "semi-ground", semi = "UNDERGROUND", accuracy = 100 }, + { name = "semi-water", semi = "UNDERWATER", accuracy = 100 }, + { name = "protected", protect = true, accuracy = 1 }, + { name = "unaffected-by-protect", protect = true, noProtect = true, accuracy = 83 }, + { name = "pokedude", demo = true, accuracy = 1 }, + { name = "pokedude-air", demo = true, semi = "ON_AIR", accuracy = 1 }, + { name = "pokedude-protect", demo = true, protect = true, accuracy = 1 }, + { name = "oak-damage-pending", oak = true, flags = 0, accuracy = 1 }, + { name = "oak-damage-done", oak = true, flags = 1, accuracy = 1 }, + { name = "oak-stat-pending", oak = true, flags = 1, power = 0, accuracy = 1 }, + { name = "oak-stat-done", oak = true, flags = 2, power = 0, accuracy = 1 }, + { name = "oak-separate-flags", oak = true, flags = 2, accuracy = 1 }, + { name = "oak-protected", oak = true, flags = 0, protect = true, accuracy = 1 }, + { name = "absorb-electric", theirs = "VOLT_ABSORB", kind = 13, accuracy = 83 }, + { name = "absorb-water", theirs = "WATER_ABSORB", kind = 11, accuracy = 83 }, + { name = "absorb-fire", theirs = "FLASH_FIRE", kind = 10, accuracy = 83 }, + { name = "pokedude-absorb", demo = true, theirs = "VOLT_ABSORB", kind = 13, accuracy = 1 }, + }) do + for id = 0, 3 do + local copy = require("src.mods.Merge").deepCopy(spec) + copy.user = id + copy.word = 49 + cases[#cases + 1] = copy + end + end + for _, mode in ipairs({ "none", "delegate", "force", "suppress", "threshold", "rng" }) do + policy = mode + if mode ~= "none" then + stop = f.e.api.hooks:wrap("battle.accuracy", hook) + end + for _, spec in ipairs(cases) do + if + (spec.name ~= "stages" or not execution and (mode == "none" or mode == "delegate")) + and (execution or not spec.executionOnly) + then + calls, draws, damageCalls = {}, {}, 0 + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + if native then + local S = require("src.core.game3.battle.state") + b.battlers[2] = + S.makeBattler(require("src.mods.Merge").deepCopy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(require("src.mods.Merge").deepCopy(b.enemy.mon), "enemy", { id = 3 }) + else + local S = require("src.battle.BattleState") + b:syncSides() + b:placeBattler(2, S.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.enemy.mon), false)) + b:syncSides() + end + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 100 + if native then + a.ability, a.mon.ability, a.mon.maxHp, a.type1, a.type2 = 0, 0, 400, 0, 0 + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[key] = 50 + end + else + a.abilityOverride, a.mon.stats.hp, a.curTypes = false, 400, { "NORMAL" } + for _, key in ipairs({ "attack", "defense", "speed", "spatk", "spdef" }) do + a.curStats[key] = 50 + end + end + end + local user, target = at(b, spec.user), at(b, spec.user % 2 == 0 and 1 or 0) + local function ability(a, name) + if native then + a.ability, a.mon.ability = abilities[name or "NONE"], abilities[name or "NONE"] + else + a.abilityOverride = name and name:gsub("COMPOUND_EYES", "COMPOUNDEYES") or false + end + end + ability(user, spec.mine) + ability(target, spec.theirs) + if spec.suppressSky then + ability(at(b, 3 - spec.user % 2), "CLOUD_NINE") + end + user.stages, target.stages = { accuracy = spec.acc or 0 }, { evasion = spec.eva or 0 } + b.firstBattle, b.oakMsgFlags = spec.oak, spec.flags or 0 + local move + local number, moveName = spec.number or 33, spec.move or "TACKLE" + if native then + user.mon.moves = { number } + else + user.curMoves = { { id = f.ids[moveName], pp = 10 } } + user.mon.moves = user.curMoves + end + if native then + b.pokedude, b.weather = spec.demo, spec.sky + target.expProtected, target.expIdentified, target.semiInvulnerable = + spec.protect, spec.identified, spec.semi + target.expLockedOn = spec.locked and 2 or nil + target.expLockedOnById = spec.locked and spec.user or nil + target.item = spec.item == "BRIGHTPOWDER" and 179 or spec.item == "LAX_INCENSE" and 221 or 0 + move = require("src.core.game3.battle.moves")._rom[number] + move.effect = spec.effect or (spec.power == 0 and 19 or 0) + move.accuracy, move.power, move.type, move.flags = + spec.accuracy, spec.power or 35, spec.kind or 0, spec.noProtect and 0 or 2 + else + b.pokedudeDemo, b.field.weather = spec.demo and {}, spec.sky == "SAND" and "SANDSTORM" or spec.sky + target.protecting, target.identified, target.invulnerable = + spec.protect, spec.identified, spec.semi ~= nil + target.hiddenAs = ({ ON_AIR = "air", UNDERGROUND = "underground", UNDERWATER = "underwater" })[spec.semi] + user.lockedOn = spec.locked and target or nil + target.mon.item = spec.item + move = f.data.moves[f.ids[moveName]] + move.effect = ({ [17] = "SWIFT_EFFECT", [152] = "THUNDER_EFFECT", [207] = "SKY_UPPERCUT_EFFECT" })[spec.effect] + or (spec.power == 0 and "DEFENSE_DOWN1_EFFECT" or "NO_ADDITIONAL_EFFECT") + move.gen3Effect = spec.effect or (spec.power == 0 and 19 or 0) + move.accuracy, move.power, move.type, move.protectAffected = + spec.accuracy, + spec.power or 35, + ({ [0] = "NORMAL", [13] = "ELECTRIC", [11] = "WATER", [10] = "FIRE" })[spec.kind or 0], + not spec.noProtect + end + b.rng = function(lo, hi) + local value = lo + spec.word % (hi - lo + 1) + draws[#draws + 1] = { lo = lo, hi = hi, word = spec.word, value = value } + return value + end + local result + if execution then + if native then + require("src.core.game3.battle.engine").resolveMove( + user, + target, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + result = "execution" + elseif native then + local context = require("src.core.game3.battle.engine").newContext( + user, + target, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + result = context:accuracyCheck("normal", false) + else + result = b:accuracyRoll(move, user, target) + end + local buff = math.max(-6, math.min(6, (spec.acc or 0) - (spec.identified and 0 or spec.eva or 0))) + local threshold = + math.floor(STAGE[buff] * (spec.effect == 152 and spec.sky == "SUN" and 50 or spec.accuracy) / 100) + if spec.mine == "COMPOUND_EYES" then + threshold = math.floor(threshold * 130 / 100) + end + if spec.theirs == "SAND_VEIL" and spec.sky == "SAND" and not spec.suppressSky then + threshold = math.floor(threshold * 80 / 100) + end + if spec.mine == "HUSTLE" and not spec.kind then + threshold = math.floor(threshold * 80 / 100) + end + if spec.item then + threshold = math.floor(threshold * (spec.item == "BRIGHTPOWDER" and 90 or 95) / 100) + end + local guaranteed = spec.demo + or spec.locked + or spec.oak + and spec.user % 2 == 0 + and math.floor((spec.flags or 0) / (spec.power == 0 and 2 or 1)) % 2 == 0 + local specialAir = execution and (spec.effect == 152 or spec.effect == 207) and spec.semi == "ON_AIR" + local guard = spec.protect and not spec.noProtect or spec.semi and not guaranteed and not specialAir + guaranteed = guaranteed + or spec.effect == 17 + or spec.effect == 78 + or spec.effect == 152 and spec.sky == "RAIN" + local callback = not guard and not guaranteed and mode ~= "none" + T.eq(#calls, callback and 1 or 0, spec.name .. " native callback boundary") + if not execution then + T.eq( + #draws, + not guard and not guaranteed and not (mode == "force" or mode == "suppress") and 1 or 0, + spec.name .. " exact native accuracy draw count" + ) + end + if callback then + T.eq(calls[1] and calls[1].accuracy, threshold, "public accuracy threshold uses native arithmetic") + end + rows[#rows + 1] = { + case = spec.name, + mode = mode, + user = spec.user, + acc = spec.acc, + eva = spec.eva, + word = spec.word, + result = result, + calls = calls, + draws = draws, + damageCalls = execution and damageCalls or nil, + pp = execution and (native and user.mon.pp[1] or user.curMoves[1].pp) or nil, + defense = target.stages.defense or 0, + hp = target.mon.hp, + flashFire = (native and target.expFlashFire or target.flashFire) or false, + } + end + end + if stop then + stop() + stop = nil + end + end + if stopCrit then + stopCrit() + stopDamage() + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_native_accuracy.lua b/tests/engine/gen3_native_accuracy.lua new file mode 100644 index 000000000..7678c8888 --- /dev/null +++ b/tests/engine/gen3_native_accuracy.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, execution in ipairs({ false, true }) do + require("tests.enemy_actions.accuracy").run(".", "gen2", version, execution) + end +end +require("tests.modkit").finish("native GBA accuracy command and execution boundaries") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index ab88aead4..6a560fe02 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 7b5e307da..f61c77a23 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,11 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "constrained_party" then +if mode == "accuracy_execution" then + rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version, true) +elseif mode == "accuracy" then + rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version) +elseif mode == "constrained_party" then rows = dofile(root .. "/tests/enemy_actions/constrained_party.lua").run(root, backend, version) elseif mode == "critical_context" then rows = dofile(root .. "/tests/enemy_actions/critical_kinds.lua").run(root, backend, version) From a6205dfce1b6d091e758238447db3b25462367f4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:24:31 +0100 Subject: [PATCH 36/90] Align native GBA secondary chances and guard battle rule compatibility --- .github/workflows/enemy-action-contract.yml | 2 + docs/native-gen3-accuracy-work.md | 54 --- docs/native-gen3-hit-commands.md | 97 +++++ src/battle/BattleState.lua | 1 + src/battle/EffectRegistry.lua | 19 +- src/battle/Gen3Accuracy.lua | 15 +- src/battle/Gen3BattleWeather.lua | 17 + src/battle/Gen3Secondary.lua | 168 ++++++++ src/battle/StatusRegistry.lua | 20 +- src/link/Fingerprint.lua | 29 +- src/link/Handshake.lua | 16 + src/link/LinkBattle.lua | 8 +- src/link/Protocol.lua | 2 + src/mods/Api3Records.lua | 1 + src/mods/CallbackRecords.lua | 12 +- tests/enemy_actions/secondary.lua | 387 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 123 ++++++ tests/engine/gen3_native_secondary.lua | 7 + tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 20 files changed, 900 insertions(+), 84 deletions(-) delete mode 100644 docs/native-gen3-accuracy-work.md create mode 100644 docs/native-gen3-hit-commands.md create mode 100644 src/battle/Gen3BattleWeather.lua create mode 100644 src/battle/Gen3Secondary.lua create mode 100644 tests/enemy_actions/secondary.lua create mode 100644 tests/engine/gen3_battle_protocol.lua create mode 100644 tests/engine/gen3_native_secondary.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 6e8e3788d..64c134914 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -18,6 +18,8 @@ on: - 'tests/engine/gen3_critical_context.lua' - 'tests/engine/gen3_constrained_party.lua' - 'tests/engine/gen3_native_accuracy.lua' + - 'tests/engine/gen3_native_secondary.lua' + - 'tests/engine/gen3_battle_protocol.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-accuracy-work.md b/docs/native-gen3-accuracy-work.md deleted file mode 100644 index 50f95b2df..000000000 --- a/docs/native-gen3-accuracy-work.md +++ /dev/null @@ -1,54 +0,0 @@ -# Native GBA accuracy work in progress - -This development branch starts at PR #214 (`440a5da6`). It is not yet a pull -request and is not included in the green `codex/portability-testing` branch. -The clean read-only Gen1 reference remains -`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. - -Implemented: the private `Gen3Accuracy` command uses native percentage draws, -the combined accuracy/evasion stage ladder, ordered ability/item floors, -Foresight, all-active-battler weather suppression, Protect/semi-invulnerability, -Oak's separate damage/stat flags and Teachy-TV guarantees. The public -`battle.accuracy` boundary carries native move IDs and the calculated accuracy. -As in the reference, the threshold is steerable; replacing the context's `rng` -field does not replace the live battle stream. Absorb abilities are resolved -before damage computation and do not print a generic miss message. Older -cartridges keep the existing byte path. - -The actual reference `Engine.newContext(...):accuracyCheck` is compared with -`BattleState:accuracyRoll`. A second matrix executes the real move resolver on -both engines with a shared public critical hook returning false, to isolate -accuracy/secondary ordering from the already-tested critical command. It keeps -all bounded RNG calls and values, public accuracy contexts, damage-callback -counts, PP, target HP, stat stages and Flash Fire state. No observation is -removed or rewritten to hide mismatches. - -Current local evidence (maintained LuaJIT pin -`a4c064539ea931274d407e1249b4bb575424a0f4`): - -- 15,082 focused assertions pass on FireRed/Emerald profiles. The complete - accuracy-command observation matrices match exactly. -- Full ROM-free run: 96/105 engine suites, SDK 3/3 and B30. The exact Counter of - the 25 inherited FAIL lines across nine suites matches clean #214 (95/104). -- Strict shared checker: 64 worker runs / 12,314 paired observations, **not - fully passing**. All prior modes and both new accuracy-command comparisons - pass. The execution matrix retains 56 different Thunder cases per profile: - the reference's post-hit secondary roll is `0..99`; the legacy host handler - uses `0..255`. The accuracy thresholds, accuracy draw boundaries and other - observed ordinary cases agree. The raw mismatch report is retained in - `tmp/native-accuracy-qa/retained-execution-gaps.json`; the complete report is - `tmp/native-accuracy-qa/contracts-first.json`. These are ignored private QA - outputs, not distributed cartridge data. - -Before publishing or integrating this branch, implement and independently test -the shared native post-hit secondary-chance path, including explicit zero -chance, Serene Grace, Shield Dust, Substitute and status/type guards. Do not -normalize or filter the differing RNG calls to make the checker pass. Decide -and test a link-visible protocol revision for the changed native battle -behavior; current green fingerprint tests alone do not prove mixed-version -link safety. - -Remaining adjacent lifecycle work includes native timed Lock-On ownership -(the existing one-use host pointer keeps its previous lifetime), whole-game -RNG/VBlank scheduling and the demo controller's midpoint battle RNG/playback. -This branch does not claim those producers or a full tutorial playthrough. diff --git a/docs/native-gen3-hit-commands.md b/docs/native-gen3-hit-commands.md new file mode 100644 index 000000000..0803521af --- /dev/null +++ b/docs/native-gen3-hit-commands.md @@ -0,0 +1,97 @@ +# Native GBA hit and secondary-chance commands + +The GBA battle path previously reused the older byte accuracy/secondary model. +That changed bounded RNG consumption, stage arithmetic and public hook entry +points. Thunder's post-hit roll also used `0..255` rather than the reference's +`0..99`. The clean reference is Gen1 commit +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; it remains read-only. + +`Gen3Accuracy` now implements the observed native percentage command: combined +accuracy/evasion stages, sequential ability/item floors, identification, +all-occupied-slot weather suppression, Protect/semi-invulnerability, separate +Oak damage/stat guards and Teachy-TV guarantees. Public `battle.accuracy` +contexts include native move identity and the calculated threshold. As in the +reference, `accuracy` is steerable; its `rng` field is informational rather +than a replacement for the live battle stream. Absorb abilities resolve before +damage computation without a generic miss message. Older games keep their +existing byte path and fingerprints. + +`Gen3Secondary` shares the ordinary secondary command across status, flinch, +confusion and stat-change families. It preserves the reference's inclusive +`roll <= secondaryChance` boundary, including explicit zero, Serene Grace and +the certain-opcode bypass. The chance draw precedes effect-level faint, +Substitute, Shield Dust, status/type, Safeguard and stat-protection gates. +Zero-length Substitute/protection states are inactive. Default built-in +handlers use this path; authored `run` handlers keep their own chance policy. +A real special Fire hit thaws the target after secondary processing; a +Substitute hit does not thaw it, even when the Substitute breaks. + +Strong poison uses the existing native `PSN` representation plus a boolean +`mon.toxic` marker. Native status application, API-3 callback views and edits, +party/battle snapshots, save serialization, battle construction and party-wire +transport retain that identity. Editing a bound mon's status updates its +marker and the owning battler's counter in the same callback transaction. +The public API-3 value is `TOX`, as in the reference; this is an explicit +schema mapping, not an observation filter. + +## Validation + +Run these ROM-free suites with the maintained LuaJIT pin +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_native_accuracy.lua +luajit tests/engine/gen3_native_secondary.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/native-hit-report.json +``` + +The shared fixture invokes the actual reference accuracy context and +`Secondary.withChance`, plus both engines' real move resolvers through the +public mod loader. Execution probes use the same public critical override to +isolate these phases from the separately tested critical command. They retain +all bounded RNG calls/values, accuracy contexts, damage-hook counts, PP, +HP/stages, status events and confusion/flinch/Substitute state. Conditions +include stage limits, zero-valued guards, all four actor slots, explicit +0/30/100 chances, forced effects, existing statuses, strong poison, abilities, +Fire thaw and Substitute breakage. All earlier comparison modes remain. + +Local acceptance: 15,082 accuracy checks, 53,598 secondary checks and 156 +protocol checks pass. All 72 workers / 48,524 paired observations agree. +The full feature run passes 98/107 engine suites, SDK 3/3 and B30. The exact +Counter of the 25 inherited FAIL lines across nine suites matches clean +PR #214 (`440a5da6`, 95/104); no failure is suppressed. Clean pre-secondary +`184fdaaa` retains 56 differing Thunder execution observations in the negative +control. The original failing reports remain in ignored private QA outputs. + +A separate CPU-only smoke loads actual locally imported FireRed/Emerald +Pokemon/move/item/type/constant tables and executes imported Thunder through +the public enemy-action and status-event path. Both profiles use the native +accuracy and secondary ranges and apply paralysis. Controlled HP avoids a KO; +this is imported-record execution acceptance, not live rendering or a full +cartridge playthrough. No imported data/assets/saves are distributed here. + +## Link compatibility + +The full GBA fingerprint carries native command revision 1. GBA move hashes +also include the native index/effect, secondary chance, target, flags and +Protect eligibility. Cache keys include the runtime native-rule revision so +switching game contexts cannot reuse an older digest. Non-GBA fixture digests +remain unchanged; no golden is reblessed. The audited raw fixture moves from +legacy `e33d251961f2a41d` to native `3a76cc5fce47a5c3`. + +Identical revised peers remain compatible. Older GBA fingerprints negotiate +only a safe content subset; the actual link controller stops before battle +setup. Unversioned peers are refused for these native rules with an engine +update message. Both role directions, per-field mutations, marker wire/save +round trips and two complete synthetic seeded link mirrors are tested. Each +mirror compares multiple completed turn hashes and all state components; +native toxic identity is included in active/bench signatures. + +This is a rule-compatibility and toxic-marker guarantee. Complete cartridge +party-codec fidelity (including GBA genetics), timed Lock-On/side/volatile +producers, whole-game RNG/VBlank scheduling and demo midpoint playback remain +separate work. These controlled command tests do not certify those lifecycles. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 30243122e..6ff487102 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1020,6 +1020,7 @@ local function makeBattler(data, mon, isPlayer, save) -- while the tilemap still shows the prior condition until the next -- post-action HUD refresh (core.asm after Execute*Move) shownStatus = mon.status, + toxicCounter = GameVersion.isGen3() and mon.status == "PSN" and mon.toxic == true and 1 or nil, -- a sleep put on outside battle with its own count (the Battle Pike's -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil, diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 7076dd698..7a28f7013 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -105,6 +105,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss local gba = require("src.core.GameVersion").isGen3() + if gba and type(target.substituteHP) == "number" and target.substituteHP <= 0 then + target.substituteHP = nil + end -- alwaysHits is neverMiss's weaker sibling and the distinction is CheckHit's -- own ordering: .FlyDigMoves is tested BEFORE .ThunderRain -- (effect_commands.asm:1559-1563), so Thunder in rain skips the accuracy @@ -333,6 +336,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) local dealt = battle:applyDamage(target, hitDamage, true, info.damageAdjustment) if (info.gbaSetDamage or info.gbaBeatUp) and substituteBefore then dealt = math.min(dealt, substituteBefore) end totalDealt = totalDealt + dealt + if gba and not hadSub and require("src.battle.Damage").isSpecial(move.type) then + ctx.gbaSpecialHit = true + end -- MIRROR COAT is COUNTER's special twin and needs the SPECIAL half of -- the damage kept separately; battle.lastDamage is shared by both and -- cannot answer for it. Cleared at the head of every turn. @@ -419,8 +425,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) battle:applyDamage(user, recoil) end - -- secondary side effects (blocked by fainting) - if record and record.run and record.kind ~= "primary" + -- Native chance runs before effect-level faint/Substitute/ability guards. + local nativeSecondary = require("src.core.GameVersion").isGen3() + and require("src.battle.Gen3Secondary").run(ctx, record) + -- Other/custom records retain their existing post-damage handler. + if not nativeSecondary and record and record.run and record.kind ~= "primary" and target.mon.hp > 0 and totalDealt > 0 then for _, m in ipairs(record.run(ctx)) do battle:sayNext(m) @@ -430,6 +439,12 @@ function EffectRegistry.runDamaging(battle, ctx, record) MoveEffects.warnUnknown(move.effect) end + if gba and ctx.gbaSpecialHit and target ~= user and target.mon.hp > 0 + and target.mon.status == "FRZ" and require("src.battle.Abilities").normalizeType(move.type) == "FIRE" then + target.mon.status = nil + battle:sayNext(Strings("Fire defrosted\n%s!", displayName(target))) + end + -- King's Rock is an explicit command in selected Gen II move scripts. It -- rolls once after the completed attack (not once per hit) and cannot pass a -- Substitute. Some native-flinch scripts (notably Sky Attack and Snore) diff --git a/src/battle/Gen3Accuracy.lua b/src/battle/Gen3Accuracy.lua index a4f01fcef..77fc249ae 100644 --- a/src/battle/Gen3Accuracy.lua +++ b/src/battle/Gen3Accuracy.lua @@ -20,19 +20,6 @@ local STAGE = { local function hasFlag(battle, flag) return math.floor((tonumber(battle.oakMsgFlags) or 0) / flag) % 2 == 1 end -local function weather(battle) - local sky = require("src.battle.Weather").raw(battle) - if not sky then - return nil - end - for _, b in battle:activeBattlers() do - local ab = Abilities.of(b) - if ab == "CLOUD_NINE" or ab == "AIR_LOCK" then - return nil - end - end - return sky == "SANDSTORM" and "SAND" or sky -end function A.guaranteed(battle, user, target, move) if battle.firstBattle and user.isPlayer then if not hasFlag(battle, (tonumber(move.power) or 0) > 0 and 1 or 2) then @@ -79,7 +66,7 @@ function A.check(battle, move, user, target, accuracyRaw) return false, "invulnerable" end end - local sky = weather(battle) + local sky = require("src.battle.Gen3BattleWeather").effective(battle) if guaranteed or effect == 17 or effect == 78 or effect == 152 and sky == "RAIN" then if absorbed(battle, user, target, move) then return false, "absorbed" diff --git a/src/battle/Gen3BattleWeather.lua b/src/battle/Gen3BattleWeather.lua new file mode 100644 index 000000000..a4504b8f0 --- /dev/null +++ b/src/battle/Gen3BattleWeather.lua @@ -0,0 +1,17 @@ +-- Native GBA weather suppression observes every occupied battle slot. +local M = {} +function M.effective(battle) + local sky = require("src.battle.Weather").raw(battle) + if not sky then + return nil + end + local Abilities = require("src.battle.Abilities") + for _, b in battle:activeBattlers() do + local ability = Abilities.of(b) + if ability == "CLOUD_NINE" or ability == "AIR_LOCK" then + return nil + end + end + return sky == "SANDSTORM" and "SAND" or sky +end +return M diff --git a/src/battle/Gen3Secondary.lua b/src/battle/Gen3Secondary.lua new file mode 100644 index 000000000..31bf190a0 --- /dev/null +++ b/src/battle/Gen3Secondary.lua @@ -0,0 +1,168 @@ +-- Native post-hit chance command and the ordinary secondary effect family. +local Abilities = require("src.battle.Abilities") +local StatusRegistry = require("src.battle.StatusRegistry") +local Strings = require("src.core.Strings") +local S = {} +local effects = { + [2] = { status = "PSN" }, + [4] = { status = "BRN" }, + [5] = { status = "FRZ" }, + [6] = { status = "PAR" }, + [31] = { flinch = true }, + [36] = { tri = true }, + [68] = { stat = "attack", delta = -1 }, + [69] = { stat = "defense", delta = -1 }, + [70] = { stat = "speed", delta = -1 }, + [71] = { stat = "spatk", delta = -1 }, + [72] = { stat = "spdef", delta = -1 }, + [73] = { stat = "accuracy", delta = -1 }, + [76] = { confusion = true }, + [77] = { status = "PSN" }, + [92] = { flinch = true }, + [125] = { status = "BRN" }, + [138] = { stat = "defense", delta = 1, user = true }, + [139] = { stat = "attack", delta = 1, user = true }, + [140] = { allStats = true, user = true }, + [146] = { flinch = true }, + [150] = { flinch = true }, + [152] = { status = "PAR" }, + [158] = { flinch = true, certain = true }, + [200] = { status = "BRN" }, + [202] = { status = "TOX" }, + [209] = { status = "PSN" }, +} +local function hasType(who, kind) + for _, t in ipairs(who.curTypes or {}) do + if Abilities.normalizeType(t) == kind then + return true + end + end + return false +end +local function active(value) + return value == true or type(value) == "number" and value > 0 +end +local function protected(battle, target, spec) + if (target.mon.hp or 0) <= 0 then + return true + end + if (battle.pokedudeDemo or battle.pokedude) and not target.isPlayer then + return true + end + if spec.user then + return false + end + if (target.substituteHP or 0) > 0 then + return true + end + local ability = Abilities.of(target) + if ability == "SHIELD_DUST" and (spec.status or spec.confusion or spec.flinch or spec.tri) then + return true + end + if active(target.safeguardTurns) and (spec.status or spec.confusion) then + return true + end + return false +end +function S.withChance(ctx, spec) + local battle, user = ctx.battle, ctx.user + local chance = tonumber(ctx.move.secondaryChance) or 0 + if Abilities.of(user) == "SERENE_GRACE" then + chance = chance * 2 + end + -- The native command draws before checking Substitute, fainting or abilities; + -- explicit zero stays zero and its inclusive comparison remains observable. + if not spec.certain and battle.rng(0, 99) > chance then + return false, {} + end + local target = spec.user and user or ctx.target + if protected(battle, target, spec) then + return false, {} + end + if spec.tri then + if target.mon.status then + return false, {} + end + return S.apply(ctx, { status = ({ "BRN", "FRZ", "PAR" })[battle.rng(0, 2) + 1] }, chance) + end + return S.apply(ctx, spec, spec.certain and 100 or chance) +end +function S.apply(ctx, spec, chance) + local battle, user = ctx.battle, ctx.user + local target = spec.user and user or ctx.target + local ability = Abilities.of(target) + if spec.status then + if spec.status == "FRZ" and require("src.battle.Gen3BattleWeather").effective(battle) == "SUN" then + return false, {} + end + local before = target.mon.status + local messages = StatusRegistry.inflict(battle, target, spec.status == "TOX" and "PSN" or spec.status, { + secondary = true, + nativeGbaSecondary = true, + moveType = ctx.move.type, + source = user, + user = user, + toxic = spec.status == "TOX", + }) + return target.mon.status ~= before, messages + elseif spec.confusion then + if ability == "OWN_TEMPO" or (target.confusedTurns or 0) > 0 then + return false, {} + end + target.confusedTurns = battle.rng(0, 3) % 4 + 2 + return true, { Strings("%s became\nconfused!", ctx.displayName(target)) } + elseif spec.flinch then + if ability == "INNER_FOCUS" then + return false, {} + end + if not target.expMovedThisTurn then + target.flinched = true + end + return true, {} + elseif spec.stat or spec.allStats then + local changed = false + for _, stat in + ipairs(spec.allStats and { "attack", "defense", "speed", "spatk", "spdef" } or { spec.stat }) + do + local delta = spec.delta or 1 + local blocked = delta < 0 + and ( + ability == "SHIELD_DUST" + or chance < 100 and (active(target.mist) or ability == "CLEAR_BODY" or ability == "WHITE_SMOKE") + or chance < 100 + and (ability == "KEEN_EYE" and stat == "accuracy" or ability == "HYPER_CUTTER" and stat == "attack") + ) + if not blocked then + local before = target.stages[stat] or 0 + local after = math.max(-6, math.min(6, before + delta)) + target.stages[stat] = after + if after ~= before then + target.hazeStatReset = nil + battle:sayNext( + require("src.battle.MoveEffects").stageMessage(ctx.displayName(target), stat, after - before) + ) + changed = true + end + end + end + return changed, {} + end + return false, {} +end +function S.run(ctx, record) + local spec = effects[tonumber(ctx.move.gen3Effect)] + if not spec then + return false + end + local native = require("src.battle.MoveEffects").RECORDS[ctx.move.effect] + -- Authored effect handlers retain their own semantics and chance policy. + if not record or not native or record.run ~= native.run then + return false + end + local _, messages = S.withChance(ctx, spec) + for _, message in ipairs(messages) do + ctx.battle:sayNext(message) + end + return true +end +return S diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..b7f998554 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -21,17 +21,21 @@ end -- Returns messages; empty means the status did not land. function StatusRegistry.inflict(battle, target, status, opts) opts = opts or {} + local nativeGba = opts.nativeGbaSecondary and require("src.core.GameVersion").isGen3() if target.mon.status then return {} end -- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute) -- and every secondary status, but NOT primary Sleep or Thunder Wave, -- their handlers never check the substitute in Gen 1. - if target.substituteHP and (opts.secondary or status == "PSN") then + local substitute = nativeGba and (target.substituteHP or 0) > 0 + or not nativeGba and target.substituteHP + if substitute and (opts.secondary or status == "PSN") then return {} end -- FreezeBurnParalyzeEffect: a secondary status never lands when the -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) - if opts.secondary and status ~= "PSN" then + if opts.secondary and status ~= "PSN" + and not nativeGba then for _, t in ipairs(target.curTypes or {}) do if opts.moveType == t then return {} end end @@ -46,7 +50,10 @@ function StatusRegistry.inflict(battle, target, status, opts) -- SAFEGUARD: the veil refuses every status for its five turns. A status a -- Pokemon inflicts on ITSELF still lands -- REST is the whole reason that -- distinction exists -- which is what opts.selfInflicted marks. - if target.safeguardTurns and not opts.selfInflicted then + local safeguard = nativeGba and (target.safeguardTurns == true + or type(target.safeguardTurns) == "number" and target.safeguardTurns > 0) + or not nativeGba and target.safeguardTurns + if safeguard and not opts.selfInflicted then return {} end local statuses = battle and battle.data and battle.data.statuses @@ -54,10 +61,15 @@ function StatusRegistry.inflict(battle, target, status, opts) -- `battle` is passed so a record can ask the RULESET: Hoenn's poison -- immunity covers STEEL as well as POISON, and the records are shared if record and record.canInflict + and not (opts.nativeGbaSecondary and status == "PAR" and require("src.core.GameVersion").isGen3() + and record.canInflict == Status.RECORDS.PAR.canInflict) and not record.canInflict(target, opts, battle) then return {} end target.mon.status = status + if require("src.core.GameVersion").isGen3() then + target.mon.toxic = status == "PSN" and opts.toxic == true or nil + end local msgs local display = displayName(target) if record and record.onInflict then @@ -67,7 +79,7 @@ function StatusRegistry.inflict(battle, target, status, opts) record and record.label or tostring(status)) } end Runtime.emit("battle.status_inflicted", { - battle = battle, target = target, status = status, source = opts.source, + battle = battle, target = target, status = opts.nativeGbaSecondary and opts.toxic and "TOX" or status, source = opts.source, }) -- Gen II status berries resolve immediately after the condition lands. -- Keep the infliction text first, then append the held-item cure line. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68ef..60af1e7f2 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -18,6 +18,13 @@ local Runtime = require("src.mods.Runtime") local Fingerprint = {} +-- Runtime command semantics are part of native GBA lockstep compatibility, +-- even when both peers load byte-identical data. Keep older game digests. +local function nativeBattleRevision() + return require("src.core.GameVersion").isGen3() and 1 or nil +end +Fingerprint.nativeBattleRevision = nativeBattleRevision + -- ------- FNV-1a, two lanes -- Two 32-bit lanes with different offset bases, concatenated into a 64-bit @@ -151,11 +158,20 @@ local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS, statuses = STATUS_FIELDS, move_effects = EFFECT_FIELDS } Fingerprint.FIELDS = RECORD_FIELDS +local GBA_MOVE_FIELDS = {} +for _, field in ipairs(MOVE_FIELDS) do GBA_MOVE_FIELDS[#GBA_MOVE_FIELDS + 1] = field end +for _, field in ipairs({ "index", "gen3Effect", "secondaryChance", "target", "flags", "protectAffected" }) do + GBA_MOVE_FIELDS[#GBA_MOVE_FIELDS + 1] = field +end +Fingerprint.GBA_MOVE_FIELDS = GBA_MOVE_FIELDS +local function fieldsFor(kind) + return kind == "moves" and nativeBattleRevision() and GBA_MOVE_FIELDS or RECORD_FIELDS[kind] +end local function writeSection(out, data, kind) local map = data[kind] if map == nil then return end - local fields = RECORD_FIELDS[kind] + local fields = fieldsFor(kind) out[#out + 1] = "[" .. kind .. "]" for _, id in ipairs(sortedIds(map)) do local record = map[id] @@ -238,6 +254,8 @@ local function surface(data, mods) writeSection(out, data, "move_effects") writeConstants(out, data) writeLinkFields(out, data) + local revision = nativeBattleRevision() + if revision then out[#out + 1] = "[native_gba_commands]" .. revision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -247,7 +265,7 @@ Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array function Fingerprint.compute(data, mods) if not data then return digest("") end - local key = modKey(mods) + local key = modKey(mods) .. "|native_gba:" .. tostring(nativeBattleRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) @@ -262,14 +280,15 @@ end local recordCache = setmetatable({}, { __mode = "k" }) function Fingerprint.records(data, kind) - local fields = RECORD_FIELDS[kind] + local fields = fieldsFor(kind) assert(fields, "no record allowlist for " .. tostring(kind)) local perData = recordCache[data] if not perData then perData = {} recordCache[data] = perData end - if perData[kind] then return perData[kind] end + local recordKey = kind .. "|native_gba:" .. tostring(nativeBattleRevision()) + if perData[recordKey] then return perData[recordKey] end local map = data[kind] or {} local out = {} for id, record in pairs(map) do @@ -279,7 +298,7 @@ function Fingerprint.records(data, kind) out[id] = digest(table.concat(buf)) end end - perData[kind] = out + perData[recordKey] = out return out end diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index aec8437e7..bbd9cc087 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -158,6 +158,7 @@ function Handshake.hello(game, mode) engineVersion = Version.engine, apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), + nativeBattleRevision = Fingerprint.nativeBattleRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -174,6 +175,7 @@ end function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} if not remoteHello or not remoteHello.protocol then + if localHello.nativeBattleRevision then return "refused", "native_rules_required" end if localHello.linkModified then return "refused", "peer_v1_modified" end @@ -265,6 +267,12 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" if verdict == "refused" then + if localHello.nativeBattleRevision and not (remoteHello and remoteHello.protocol) then + wrap(lines, "The other game needs") + wrap(lines, "an engine update") + wrap(lines, "before linking.") + return lines + end if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") wrap(lines, "an older version") @@ -278,6 +286,14 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) end return lines end + if localHello.nativeBattleRevision ~= (remoteHello and remoteHello.nativeBattleRevision) then + wrap(lines, "The battle rules") + wrap(lines, "need matching") + wrap(lines, "engine versions.") + if mode == "trade" then wrap(lines, "Trading is limited") + wrap(lines, "to shared POKéMON.") end + return lines + end wrap(lines, "Your games differ.") local diff = Handshake.modDiff(localHello, remoteHello) listMods(lines, peer .. " has:", diff.onlyTheirs) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 21881d63f..746dd9a06 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -152,17 +152,21 @@ local function volStr(b) return table.concat(out, ",") end +local function toxicSignature(mon) + if not require("src.core.GameVersion").isGen3() then return "" end + return ":toxic=" .. tostring(mon.status == "PSN" and mon.toxic == true) +end local function activeStr(b) return ("%s:%d:%s:%s:%s:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status), tostring(b.mon.item), - stageStr(b), ppStr(b.mon)) + stageStr(b), ppStr(b.mon)) .. toxicSignature(b.mon) end local function benchStr(party) local out = {} for i, mon in ipairs(party or {}) do out[i] = ("%s:%d:%s:%s"):format(tostring(mon.species), mon.hp or 0, - tostring(mon.status), tostring(mon.item)) + tostring(mon.status), tostring(mon.item)) .. toxicSignature(mon) end return table.concat(out, "|") end diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 787c37899..d45528d7e 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -60,6 +60,7 @@ function Protocol.packMon(mon) exp = mon.exp, hp = mon.hp, status = mon.status, + toxic = require("src.core.GameVersion").isGen3() and mon.status == "PSN" and mon.toxic == true or nil, nickname = mon.nickname, item = mon.item, dvs = mon.dvs, @@ -153,6 +154,7 @@ function Protocol.unpackMon(data, packed, opts) stats = stats, hp = hp, status = status, + toxic = require("src.core.GameVersion").isGen3() and not forced and status == "PSN" and packed.toxic == true or nil, nickname = packed.nickname, item = packed.item, ot = ot, diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua index 22d540b20..a102c7234 100644 --- a/src/mods/Api3Records.lua +++ b/src/mods/Api3Records.lua @@ -48,6 +48,7 @@ function R.mon(out, data, gen) return out end out.status = R.status(out.status) + if gen == 3 and (out.native or {}).toxic == true and out.status == "PSN" then out.status = "TOX" end 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 {} diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 4caf880d6..25b606ea6 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -229,7 +229,7 @@ function R.new(loader, name, generation, api3) level = raw.level, hp = raw.hp, maxHp = raw.stats and raw.stats.hp, - status = readStatus(raw.status), + status = portable and generation == 3 and raw.toxic == true and readStatus(raw.status) == "PSN" and "TOX" or 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), @@ -590,6 +590,16 @@ function R.new(loader, name, generation, api3) v = integer(v, 1, 65535, key) elseif key == "status" then v = writeStatus(v, raw.status) + if portable and generation == 3 then + local toxic = v == "TOX" + stage(raw, "toxic", toxic or nil) + for _, parent in ipairs(entries) do + if parent.kind == "battler" and parent.raw.mon == raw then + stage(parent.raw, "toxicCounter", toxic and 1 or nil) + end + end + if toxic then v = "PSN" end + end elseif key == "nickname" then assert(v == nil or type(v) == "string", "nickname must be a string") elseif key == "isEgg" then diff --git a/tests/enemy_actions/secondary.lua b/tests/enemy_actions/secondary.lua new file mode 100644 index 000000000..c986b7ce6 --- /dev/null +++ b/tests/enemy_actions/secondary.lua @@ -0,0 +1,387 @@ +local M = {} +local EFFECTS = { + { "poison", 2, "POISON_SIDE_EFFECT1", "POISON", { status = "PSN" } }, + { "burn", 4, "BURN_SIDE_EFFECT1", "BURN", { status = "BRN" } }, + { "freeze", 5, "FREEZE_SIDE_EFFECT1", "FREEZE", { status = "FRZ" } }, + { "paralysis", 6, "PARALYZE_SIDE_EFFECT1", "PARALYSIS", { status = "PAR" } }, + { "flinch", 31, "FLINCH_SIDE_EFFECT1", "FLINCH", { flinch = true } }, + { "confusion", 76, "CONFUSION_SIDE_EFFECT", "CONFUSION", { confusion = true } }, + { "attack-down", 68, "ATTACK_DOWN_SIDE_EFFECT", "ATK_MINUS_1", { stat = "attack", delta = -1 } }, + { "defense-down", 69, "DEFENSE_DOWN_SIDE_EFFECT", "DEF_MINUS_1", { stat = "defense", delta = -1 } }, + { "spatk-down", 71, "SP_ATK_DOWN_HIT_EFFECT", "SP_ATK_MINUS_1", { stat = "spatk", delta = -1 } }, + { "speed-down", 70, "SPEED_DOWN_SIDE_EFFECT", "SPD_MINUS_1", { stat = "speed", delta = -1 } }, + { "spdef-down", 72, "SP_DEF_DOWN_SIDE_EFFECT", "SP_DEF_MINUS_1", { stat = "spdef", delta = -1 } }, + { "accuracy-down", 73, "ACCURACY_DOWN_SIDE_EFFECT", "ACC_MINUS_1", { stat = "accuracy", delta = -1 } }, + { "attack-up", 139, "ATTACK_UP_HIT_EFFECT", "ATK_PLUS_1", { stat = "attack", delta = 1, user = true } }, + { "defense-up", 138, "DEFENSE_UP_HIT_EFFECT", "DEF_PLUS_1", { stat = "defense", delta = 1, user = true } }, + { "all-stats-up", 140, "ALL_UP_HIT_EFFECT", "ALL_STATS_UP", { allStats = true, user = true } }, + { "tri-attack", 36, "TRI_ATTACK_EFFECT", "TRI_ATTACK", { tri = true } }, + { "toxic", 202, "POISON_FANG_EFFECT", "TOXIC", { status = "TOX" } }, +} +local AB = { + NONE = 0, + SERENE_GRACE = 32, + SHIELD_DUST = 19, + LIMBER = 7, + IMMUNITY = 17, + WATER_VEIL = 41, + MAGMA_ARMOR = 40, + OWN_TEMPO = 20, + INNER_FOCUS = 39, + CLEAR_BODY = 29, + WHITE_SMOKE = 73, + KEEN_EYE = 51, + HYPER_CUTTER = 52, +} +function M.run(root, backend, version, execution) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + if engine == "gen2" then + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 } + end + for i, e in ipairs(EFFECTS) do + local n, name = 599 + i, "SECONDARY_" .. i + local id = engine == "gen1" and name or ("MOVE_%03d"):format(n) + ids[name], names[n] = id, name + if engine == "gen1" then + local d = { + effect = e[2], + power = 35, + type = 0, + accuracy = 100, + pp = 10, + flags = 2, + target = 0, + priority = 0, + secondaryChance = 30, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, name + else + data.moves[id] = { + id = id, + name = name, + index = n, + gen3Effect = e[2], + effect = e[3], + power = 35, + type = "NORMAL", + accuracy = 100, + pp = 10, + secondaryChance = 30, + effectChance = 77, + protectAffected = true, + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, events, liveUser = f.T, {}, {}, nil + local stops = { + f.e.api.hooks:wrap("battle.accuracy", function() + return true + end), + f.e.api.hooks:wrap("battle.crit", function() + return false + end), + f.e.api.events:on("battle.status_inflicted", function(e) + events[#events + 1] = { + status = e.status, + target = e.target.mon.nickname, + sourceIsUser = e.source == liveUser, + targetStatus = e.target.mon.status, + } + end), + } + local conditions = { + { name = "certain", certain = true, direct = true }, + { name = "certain-mist", certain = true, mist = true, direct = true }, + { name = "certain-clear-body", certain = true, theirs = "CLEAR_BODY", direct = true }, + { name = "certain-shield-dust", certain = true, theirs = "SHIELD_DUST", direct = true }, + { name = "stage-cap-up", stages = 6 }, + { name = "stage-cap-down", stages = -6 }, + { name = "ordinary" }, + { name = "serene-grace", mine = "SERENE_GRACE" }, + { name = "shield-dust", theirs = "SHIELD_DUST" }, + { name = "limber", theirs = "LIMBER" }, + { name = "immunity", theirs = "IMMUNITY" }, + { name = "water-veil", theirs = "WATER_VEIL" }, + { name = "magma-armor", theirs = "MAGMA_ARMOR" }, + { name = "own-tempo", theirs = "OWN_TEMPO" }, + { name = "inner-focus", theirs = "INNER_FOCUS" }, + { name = "clear-body", theirs = "CLEAR_BODY" }, + { name = "white-smoke", theirs = "WHITE_SMOKE" }, + { name = "substitute-zero", sub = 0 }, + { name = "safeguard-zero", safeguard = 0 }, + { name = "mist-zero", mist = 0 }, + { name = "substitute", sub = 1000 }, + { name = "broken-substitute", sub = 1 }, + { name = "fainted", hp = 0, direct = true }, + { name = "fainting-hit", hp = 1 }, + { name = "status-existing", status = "PAR" }, + { name = "frozen", status = "FRZ" }, + { name = "fire-hit-frozen", status = "FRZ", moveType = 10 }, + { name = "fire-substitute-frozen", status = "FRZ", moveType = 10, sub = 1000 }, + { name = "fire-broken-sub-frozen", status = "FRZ", moveType = 10, sub = 1 }, + { name = "safeguard", safeguard = true }, + { name = "mist", mist = true }, + { name = "fire-type", kind = 10 }, + { name = "ice-type", kind = 15 }, + { name = "poison-type", kind = 3 }, + { name = "steel-type", kind = 8 }, + { name = "sun", sky = "SUN" }, + { name = "moved", moved = true }, + { name = "pokedude", demo = true }, + } + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + for _, condition in ipairs(conditions) do + if not execution or not condition.direct then + for i, e in ipairs(EFFECTS) do + for _, chance in ipairs({ 0, 30, 100 }) do + for _, word in ipairs({ 0, 30, 31, 60, 99 }) do + events = {} + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local Copy = require("src.mods.Merge").deepCopy + if native then + local B = require("src.core.game3.battle.state") + b.battlers[2], b.battlers[3] = + B.makeBattler(Copy(b.player.mon), "player", { id = 2 }), + B.makeBattler(Copy(b.enemy.mon), "enemy", { id = 3 }) + else + local B = require("src.battle.BattleState") + b:syncSides() + b:placeBattler(2, B.makeBattler(f.data, Copy(b.player.mon), true)) + b:placeBattler(3, B.makeBattler(f.data, Copy(b.enemy.mon), false)) + b:syncSides() + end + local id = (i + chance + word) % 4 + local user, target = at(b, id), at(b, id % 2 == 0 and 1 or 0) + liveUser = user + for pos = 0, 3 do + local a = at(b, pos) + a.mon.nickname, a.mon.hp = "P" .. pos, 1000 + if native then + a.ability, a.mon.ability, a.type1, a.type2, a.mon.maxHp = 0, 0, 0, 0, 1000 + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = 50 + end + else + a.abilityOverride, a.curTypes, a.mon.stats.hp = false, { "NORMAL" }, 1000 + for _, k in ipairs({ "attack", "defense", "speed", "spatk", "spdef" }) do + a.curStats[k] = 50 + end + end + end + local function ability(a, name) + if native then + a.ability, a.mon.ability = AB[name or "NONE"], AB[name or "NONE"] + else + a.abilityOverride = name or false + end + end + if condition.stages then + for pos = 0, 3 do + local a = at(b, pos) + for _, stat in ipairs({ + "attack", + "defense", + "speed", + "spatk", + "spdef", + "accuracy", + "evasion", + }) do + local key = native and ({ spatk = "spAtk", spdef = "spDef" })[stat] or nil + a.stages[key or stat] = condition.stages + end + end + end + ability(user, condition.mine) + ability(target, condition.theirs) + local number, move = 599 + i + if native then + user.mon.moves, user.mon.pp = { number }, { 10 } + move = require("src.core.game3.battle.moves")._rom[number] + move.secondaryChance, move.type = chance, condition.moveType or 0 + target.type1, target.type2 = condition.kind or 0, condition.kind or 0 + target.status, target.mon.status = condition.status, condition.status + else + user.curMoves = { { id = f.ids["SECONDARY_" .. i], pp = 10 } } + user.mon.moves = user.curMoves + move = f.data.moves[user.curMoves[1].id] + move.secondaryChance = chance + move.type = condition.moveType == 10 and "FIRE" or "NORMAL" + move.effectChance = math.floor(chance * 255 / 100) + target.curTypes = { + ({ [0] = "NORMAL", [10] = "FIRE", [15] = "ICE", [3] = "POISON", [8] = "STEEL" })[condition.kind or 0], + } + target.mon.status = condition.status + end + target.mon.hp, target.substituteHP, target.expMovedThisTurn = + condition.hp or 1000, condition.sub, condition.moved + if native then + b.pokedude, b.weather = condition.demo, condition.sky + local side = target.side == "player" and b.playerSide or b.enemySide + side.expSafeguardTurns, side.expMistTurns = + condition.safeguard == true and 5 or condition.safeguard, + condition.mist == true and 5 or condition.mist + else + b.pokedudeDemo, b.field.weather = condition.demo and {}, condition.sky + target.safeguardTurns, target.mist = + condition.safeguard == true and 5 or condition.safeguard, condition.mist + end + local draws = {} + b.rng = function(lo, hi) + local value = lo + word % (hi - lo + 1) + draws[#draws + 1] = { lo = lo, hi = hi, word = word, value = value } + return value + end + local result + local descriptor = Copy(e[5]) + descriptor.certain = condition.certain + if native then + local Engine, Adapter = + require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + if execution then + Engine.resolveMove(user, target, number, 1, Adapter.new(b), b, {}) + result = "execution" + else + local context = Engine.newContext(user, target, number, 1, Adapter.new(b), b, {}, {}) + result = require("src.core.game3.battle.effects.secondary").withChance( + context, + e[4], + condition.certain == true, + e[5].user + ) + end + else + if execution then + b:executeAction(user, target, user.curMoves[1]) + result = "execution" + else + result = require("src.battle.Gen3Secondary").withChance( + require("src.battle.EffectRegistry").makeCtx(b, user, target, move, user.curMoves[1]), + descriptor + ) + end + end + if not execution and not condition.certain then + T.check(#draws >= 1, "native chance draws before effect protection even at zero") + T.same( + draws[1], + { lo = 0, hi = 99, word = word, value = word }, + "native secondary range and input word" + ) + end + if not execution and condition.certain then + local chanceDraws = 0 + for _, draw in ipairs(draws) do + if draw.lo == 0 and draw.hi == 99 then + chanceDraws = chanceDraws + 1 + end + end + T.eq(chanceDraws, 0, "certain effect opcode bypasses the chance draw") + end + if execution then + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 9, + "native executed move spends owned PP once" + ) + end + local function view(a) + local stats = {} + for _, k in ipairs({ "attack", "defense", "speed", "spatk", "spdef", "accuracy", "evasion" }) do + local nk = native and ({ spatk = "spAtk", spdef = "spDef" })[k] or nil + stats[#stats + 1] = a.stages[nk or k] or 0 + end + return { + status = not native and a.mon.status == "PSN" and a.mon.toxic and "TOX" + or a.mon.status + or false, + hp = a.mon.hp, + stages = stats, + flinched = a.flinched or false, + confusion = (native and a.confusionTurns or a.confusedTurns) or 0, + sub = a.substituteHP or 0, + } + end + rows[#rows + 1] = { + case = condition.name, + effect = e[1], + chance = chance, + word = word, + user = id, + result = result or false, + draws = draws, + target = view(target), + attacker = view(user), + events = events, + } + end + end + end + end + end + if not native then + local c = f.fresh("TACKLE", { enemy = true }) + local b, mon = c.battle, c.target.mon + mon.status, mon.toxic, c.target.toxicCounter = "PSN", true, 1 + f.game.stack.states = { b } + T.eq(f.e.api.world:party()[1].status, "TOX", "API-3 party query preserves badly poisoned identity") + T.eq( + f.e.api.battle:snapshot().player.status, + "TOX", + "API-3 battle snapshot preserves badly poisoned identity" + ) + local rebuilt = require("src.battle.BattleState").makeBattler(f.data, mon, true) + T.eq(rebuilt.toxicCounter, 1, "saved native toxic marker restores the initial residual counter") + for _, desired in ipairs({ "PSN", "TOX" }) do + local stop = f.e.api.hooks:wrap("battle.damage", function(nextDamage, context) + context.target.mon.status = desired + return nextDamage(context) + end) + c.use() + stop() + T.eq( + c.target.toxicCounter, + desired == "TOX" and 1 or nil, + "callback status write resets the owning battler counter" + ) + T.eq(mon.status, "PSN", "native callback writes keep the engine status representation") + T.eq( + mon.toxic == true, + desired == "TOX", + "native callback status edits update the toxic marker atomically" + ) + T.eq(f.e.api.world:party()[1].status, desired, "public read reflects callback status replacement") + end + local record = require("src.battle.MoveEffects").RECORDS.PARALYZE_SIDE_EFFECT1 + local custom = require("src.mods.Merge").deepCopy(record) + custom.run = function() + return {} + end + local ctx = require("src.battle.EffectRegistry").makeCtx( + b, + c.user, + c.target, + f.data.moves[f.ids.SECONDARY_4], + c.user.curMoves[1] + ) + T.eq( + require("src.battle.Gen3Secondary").run(ctx, custom), + false, + "authored secondary handlers retain their own chance contract" + ) + end + for _, stop in ipairs(stops) do + stop() + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua new file mode 100644 index 000000000..23a2ef684 --- /dev/null +++ b/tests/engine/gen3_battle_protocol.lua @@ -0,0 +1,123 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, F, H = + require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") +local Copy = require("src.mods.Merge").deepCopy +local data, before = T.fixtures.fresh(), V.get() +-- Audited against the unchanged #214 Fingerprint module and this raw fixture. +local LEGACY = "e33d251961f2a41d" +local NATIVE = "3a76cc5fce47a5c3" +for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do + V.set(version) + local gba = V.isGen3() + T.eq( + F.compute(data, {}), + gba and NATIVE or LEGACY, + "runtime command revision participates in cached fingerprints" + ) + T.eq(F.nativeBattleRevision(), gba and 1 or nil, "only GBA advertises the new command revision") + local game = { data = data, save = { player = { name = "RULES" } } } + local current = H.hello(game, "battle") + local legacy = Copy(current) + legacy.nativeBattleRevision, legacy.fingerprint = nil, LEGACY + T.eq(H.checkCompat(current, Copy(current)), "full", "identical native rules admit lockstep") + T.eq( + H.checkCompat(current, legacy), + gba and "subset" or "full", + "old wire digest cannot admit changed GBA battles" + ) + T.eq(H.checkCompat(legacy, current), gba and "subset" or "full", "the wire mismatch is symmetric") + T.eq( + H.checkCompat(current, {}), + gba and "refused" or "vanilla_peer", + "legacy unversioned peers remain safe" + ) + if gba then + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = game, myHello = current, peerHello = legacy }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller stops before battle setup") + T.eq(state.noticeExits, true, "mismatched commands cannot start a battle") + end + local message = table.concat(H.describe(current, legacy, "subset", "battle"), " ") + T.check(message:find("engine versions", 1, true) ~= nil, "rule mismatch names the needed engine update") + for field, value in pairs({ + index = 999, + gen3Effect = 6, + secondaryChance = 100, + target = 8, + flags = 0, + protectAffected = false, + }) do + local changed = Copy(data) + changed.moves.FIX_TACKLE[field] = value + T.neq(F.compute(changed, {}), NATIVE, "native " .. field .. " changes the full link surface") + T.neq( + F.records(changed, "moves").FIX_TACKLE, + F.records(data, "moves").FIX_TACKLE, + "native " .. field .. " changes move compatibility" + ) + V.set("red") + T.eq(F.compute(changed, {}), LEGACY, "native-only move fields retain old game digests") + V.set(version) + end + local Protocol = require("src.link.Protocol") + local mon = require("src.pokemon.Pokemon").new(data, "FIXMON_A", 20) + mon.status, mon.toxic = "PSN", true + local Serializer = require("src.core.SaveSerializer") + local saved = Serializer.decode(Serializer.encode({ party = { mon } })) + T.eq(saved.party[1].toxic, true, "native save representation preserves the toxic marker") + local packed = Protocol.packMon(mon) + local restored = Protocol.unpackMon(data, packed, { strict = true }) + T.eq(packed.toxic, true, "native party wire preserves the toxic marker") + T.eq(restored.toxic, true, "native party decode preserves the toxic marker") + packed.toxic = "true" + T.eq( + Protocol.unpackMon(data, packed, { strict = true }).toxic, + nil, + "wire marker requires an actual boolean" + ) + -- The content/trade record digest remains independent of runtime revision. + local records = F.records(data, "pokemon") + V.set("red") + T.same(F.records(data, "pokemon"), records, "shared Pokemon trade definitions are retained") + V.set(version) + end +end +for _, version in ipairs({ "firered", "emerald" }) do + V.set(version) + local simulated = T.fixtures.fresh() + local loaded = T.sdk.loadNone({ data = simulated }) + T.eq(#loaded.errors, 0, "link mirror fixture loads") + require("src.battle.TypeChart").load(simulated) + T.link.prepare(simulated) + local a = T.link.fakeGame(simulated, "FIXMON_A", { name = "A", level = 20 }) + local b = T.link.fakeGame(simulated, "FIXMON_B", { name = "B", level = 20 }) + a.save.party[1].status, a.save.party[1].toxic = "PSN", true + local result = T.link.lockstep(a, b, { maxFrames = 60000, seed = 327 }) + T.eq(result.completed, true, "native runtime mirror completes a full synthetic link battle") + T.eq(result.agreed, true, "native runtime mirror has no state hash split") + T.eq(result.battleA.player.mon.hp, result.battleB.enemy.mon.hp, "host HP matches peer view") + T.eq(result.battleA.enemy.mon.hp, result.battleB.player.mon.hp, "guest HP matches peer view") + T.eq(result.battleA.player.mon.toxic, true, "host battle retains its toxic marker") + T.eq(result.battleB.enemy.mon.toxic, true, "guest battle retains the remote toxic marker") + local compared = 0 + for turn, hash in pairs(result.battleA.localHashes) do + if result.battleB.localHashes[turn] then + compared = compared + 1 + T.eq(hash, result.battleB.localHashes[turn], "each paired turn hash agrees") + T.same( + result.battleA.localParts[turn], + result.battleB.localParts[turn], + "each paired state component agrees" + ) + end + end + T.check(compared >= 2, "link mirror compares several completed turns") + loaded.release() +end +V.set(before) +T.finish("native GBA battle protocol revision") diff --git a/tests/engine/gen3_native_secondary.lua b/tests/engine/gen3_native_secondary.lua new file mode 100644 index 000000000..8015baebf --- /dev/null +++ b/tests/engine/gen3_native_secondary.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, execution in ipairs({ false, true }) do + require("tests.enemy_actions.secondary").run(".", "gen2", version, execution) + end +end +require("tests.modkit").finish("native GBA secondary chance and effect boundaries") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 6a560fe02..29ab913c7 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index f61c77a23..043eaacf8 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "accuracy_execution" then +if mode == "secondary" or mode == "secondary_execution" then + rows = dofile(root .. "/tests/enemy_actions/secondary.lua").run(root, backend, version, mode == "secondary_execution") +elseif mode == "accuracy_execution" then rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version, true) elseif mode == "accuracy" then rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version) From b1795a79cb03a4de7171d50db93a39ce49dddd64 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:46:19 +0100 Subject: [PATCH 37/90] Preserve native GBA party identity across trades and link battles --- .github/workflows/enemy-action-contract.yml | 2 + docs/native-gen3-party-codec.md | 68 ++++ src/link/Fingerprint.lua | 6 + src/link/Gen3Party.lua | 314 +++++++++++++++++++ src/link/Handshake.lua | 4 + src/link/LinkBattle.lua | 1 + src/link/Protocol.lua | 6 + tests/enemy_actions/party_codec.lua | 206 ++++++++++++ tests/enemy_actions/party_codec_hooks.lua | 84 +++++ tests/engine/gen3_battle_protocol.lua | 4 +- tests/engine/gen3_party_codec.lua | 6 + tests/engine/gen3_party_transport.lua | 275 ++++++++++++++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 6 +- 14 files changed, 980 insertions(+), 4 deletions(-) create mode 100644 docs/native-gen3-party-codec.md create mode 100644 src/link/Gen3Party.lua create mode 100644 tests/enemy_actions/party_codec.lua create mode 100644 tests/enemy_actions/party_codec_hooks.lua create mode 100644 tests/engine/gen3_party_codec.lua create mode 100644 tests/engine/gen3_party_transport.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 64c134914..e0b3513e4 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -20,6 +20,8 @@ on: - 'tests/engine/gen3_native_accuracy.lua' - 'tests/engine/gen3_native_secondary.lua' - 'tests/engine/gen3_battle_protocol.lua' + - 'tests/engine/gen3_party_codec.lua' + - 'tests/engine/gen3_party_transport.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-party-codec.md b/docs/native-gen3-party-codec.md new file mode 100644 index 000000000..262c852c7 --- /dev/null +++ b/docs/native-gen3-party-codec.md @@ -0,0 +1,68 @@ +# Native GBA party codec + +The generic link codec serialized only DVs/stat experience. Native GBA mons +lost their personality, IVs, EVs, nature, ability slot and secret trainer ID. +A clean #215 negative control loses all six fields and changes the example's +HP maximum from 92 to 67. The failing report is retained in private QA output. + +`Protocol.packMon`/`unpackMon` now dispatch GBA games to a separate versioned +codec. It carries six-stat IV/EV data and PID, rebuilds native stats/nature and +gender, preserves the selected valid ability slot independently of PID parity, +and retains original-trainer identity (including both host secret-ID aliases), +PP-Ups, current PP, held item, ball, friendship, contest/origin data, ribbons, +markings, language, Pokerus and extension namespaces. Names use native character +limits without splitting UTF-8. Incoming stats are recomputed, never trusted. +Auto-level rebuilds experience/stats and heals status/HP while keeping genotype. +Native toxic identity and sleep counts survive ordinary party transport. + +The live path retains both `gender.roll` entry points, at pack and unpack, +including delegated, forced, invalid-result fallback and metadata-steering +policies. Standalone tools without a mounted species registry omit the sender's +derived gender; the decoder still derives it from authoritative data/PID. +Gen1 is an unchanged read-only reference. Comparison maps species/move IDs, +stat-key schemas and gender codes; it retains all compared observations and +callback calls. This does not assert cross-engine network packet compatibility. + +Both hellos advertise native party revision 1. Different/missing native codecs +are refused before party exchange in either trade or battle roles. GBA full +fingerprints include `[native_gba_party]1`; cache keys include its revision. +The raw fixture digest changes from #215's `3a76cc5fce47a5c3` to +`2fd444270c3d90db`. This was independently audited by feeding the additional +surface token to the unchanged #215 hasher. Other games' digests remain unchanged; +no golden is reblessed. Active/bench state components include native genotype. + +## Reproduce + +Use maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_party_codec.lua +luajit tests/engine/gen3_party_transport.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/party-codec-report.json +``` + +The shared fixture calls actual `packMon3`/`unpackMon3` on unchanged Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, and the actual Gen2 codec. It covers +all 25 natures; levels 1, 35 and 100, ordinary/forced levels; two native species +including Shedinja; alternate and single abilities; zero/max/mixed investments; +canonical/raw/translated registries; original-trainer and other native metadata. +It retains full normalized stats/identity/metadata and both public gender calls. + +Host transport checks exercise strict malformed-packet refusal, JSON/save +round trips, actual two-role trade negotiation/application, and complete seeded +link mirrors with nonzero IV/EVs and distinct PIDs/ability slots. Genotype is +checked against the original sender, not only against the other equally rebuilt +peer. Every completed paired turn hash and state component is compared. + +A CPU-only private smoke also round-trips actual locally imported FireRed and +Emerald Poliwag data, comparing all IVs/EVs/stats, selected ability, secret ID, +current PP and public API-3 `TOX`/personality reads. No ROM/assets/saves are +published. This is imported-record acceptance, not a live UI/full playthrough. + +Full native egg-legality checks, other-generation codecs, timed side/volatile +producers, whole-game RNG/VBlank scheduling and demo controller playback remain +separate work. This codec does not certify those lifecycles. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 60af1e7f2..1852018f8 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -24,6 +24,9 @@ local function nativeBattleRevision() return require("src.core.GameVersion").isGen3() and 1 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision +function Fingerprint.nativePartyRevision() + return require("src.core.GameVersion").isGen3() and require("src.link.Gen3Party").REVISION or nil +end -- ------- FNV-1a, two lanes @@ -256,6 +259,8 @@ local function surface(data, mods) writeLinkFields(out, data) local revision = nativeBattleRevision() if revision then out[#out + 1] = "[native_gba_commands]" .. revision end + local partyRevision = Fingerprint.nativePartyRevision() + if partyRevision then out[#out + 1] = "[native_gba_party]" .. partyRevision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -266,6 +271,7 @@ Fingerprint.surface = surface function Fingerprint.compute(data, mods) if not data then return digest("") end local key = modKey(mods) .. "|native_gba:" .. tostring(nativeBattleRevision()) + .. "|native_party:" .. tostring(Fingerprint.nativePartyRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) diff --git a/src/link/Gen3Party.lua b/src/link/Gen3Party.lua new file mode 100644 index 000000000..49b99fb4a --- /dev/null +++ b/src/link/Gen3Party.lua @@ -0,0 +1,314 @@ +-- Native GBA party data. Keep this separate from the older DV/stat-exp codec. +local C = { REVISION = 1 } +local STATS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local CONDITIONS = { "cool", "beauty", "cute", "smart", "tough", "sheen" } +function C.signature(mon) + local out = {} + for _, k in ipairs({ "personality", "abilitySlot", "level", "exp", "otId", "otSecretId", "happiness" }) do + out[#out + 1] = tostring(mon[k] or 0) + end + for _, kind in ipairs({ "ivs", "evs" }) do + for _, k in ipairs(STATS) do + out[#out + 1] = tostring((mon[kind] or {})[k] or 0) + end + end + return ":gen3=" .. table.concat(out, ",") +end +local STATUSES = { SLP = true, PSN = true, BRN = true, FRZ = true, PAR = true } +local function integer(value, lo, hi, default) + local n = tonumber(value) + if not n or n ~= n or n == math.huge or n == -math.huge then + n = default or lo + end + return math.max(lo, math.min(hi, math.floor(n))) +end +local function tableOf(v) + return type(v) == "table" and v or {} +end +local function name(value, limit) + if type(value) ~= "string" then + return "" + end + local out, count = {}, 0 + for c in value:gsub("%c", ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + if count == limit then + break + end + out[#out + 1], count = c, count + 1 + end + return table.concat(out) +end +local function copyStats(value, max) + local out = {} + for _, k in ipairs(STATS) do + out[k] = integer(tableOf(value)[k], 0, max, 0) + end + return out +end +function C.pack(mon, plainCopy) + local pid = integer(mon.personality, 0, 4294967295, 0) + local Identity = require("src.pokemon.IdentityRolls") + local data = Identity.data() + -- Live links have a mounted registry. Standalone tools can omit it; the + -- receiver still derives gender from its authoritative species and PID. + local gender = data + and data.pokemon + and data.pokemon[mon.species] + and Identity.gender(data, { species = mon.species, personality = pid }, 3) + or nil + local slot = mon.abilitySlot or (mon.altAbility and 2) or pid % 2 + 1 + local fullOt = integer(mon.otId, 0, 4294967295, 0) + local ribbons = integer(mon.ribbons, 0, 4294967295, 0) + if mon.modernFatefulEncounter ~= nil then + local has = math.floor(ribbons / 2147483648) % 2 == 1 + if mon.modernFatefulEncounter and not has then + ribbons = ribbons + 2147483648 + end + if not mon.modernFatefulEncounter and has then + ribbons = ribbons - 2147483648 + end + end + local moves = {} + for i, mv in ipairs(tableOf(mon.moves)) do + if i > 4 then + break + end + moves[i] = { id = mv.id, pp = mv.pp, ppUps = mv.ppUps or 0 } + end + return { + gen3Codec = C.REVISION, + species = mon.species, + level = mon.level, + exp = mon.exp, + hp = mon.hp, + status = mon.status, + sleepTurns = mon.sleepTurns, + toxic = mon.status == "PSN" and mon.toxic == true or nil, + personality = pid, + gender = gender, + nature = pid % 25, + abilitySlot = integer(slot, 1, 2, 1), + ivs = copyStats(mon.ivs, 31), + evs = copyStats(mon.evs, 255), + nickname = name(mon.nickname, 10), + moves = moves, + item = mon.item, + ot = name(mon.gen3OtName or mon.otName or mon.ot, 7), + otId = fullOt % 65536, + secretId = integer(mon.secretId or mon.otSecretId, 0, 65535, math.floor(fullOt / 65536)), + otGender = integer(mon.otGender, 0, 1, 0), + happiness = integer(mon.friendship or mon.happiness, 0, 255, 70), + gen3Language = integer(mon.gen3Language or mon.language, 0, 255, mon.isEgg and 1 or 2), + gen3Pokerus = integer(mon.gen3Pokerus or mon.pokerus, 0, 255, 0), + gen3Markings = integer(mon.gen3Markings or mon.markings, 0, 15, 0), + contest = plainCopy(mon.contest), + ribbons = ribbons, + metLocation = integer(mon.metLocation, 0, 255, 0), + metLevel = integer(mon.metLevel, 0, 100, 0), + metGame = integer(mon.metGame, 0, 15, 0), + ball = mon.ball, + isEgg = mon.isEgg == true, + eggCycles = mon.isEgg and integer(mon.eggCycles, 0, 255, 0) or nil, + fatefulEncounter = mon.fatefulEncounter == true, + modernFatefulEncounter = mon.modernFatefulEncounter == true, + extra = plainCopy(mon.extra), + } +end +function C.unpack(data, packed, opts, plainCopy) + opts = opts or {} + if type(packed) ~= "table" or packed.gen3Codec ~= C.REVISION then + return nil, "native party codec required" + end + local def = data.pokemon[packed.species] + if not def then + return nil, "unknown POKéMON" + end + local strict = opts.strict == true + local function field(value, lo, hi, default) + local n = tonumber(value) + if strict and (not n or n ~= n or n < lo or n > hi or n ~= math.floor(n)) then + return nil + end + return integer(value, lo, hi, default) + end + local level = field(packed.level, 1, 100, 5) + if not level then + return nil, "bad level" + end + local pid = field(packed.personality, 0, 4294967295, 0) + if not pid then + return nil, "bad personality" + end + if strict and packed.nature ~= pid % 25 then + return nil, "nature mismatch" + end + local ivs, evs, total = {}, {}, 0 + for _, k in ipairs(STATS) do + ivs[k] = field(tableOf(packed.ivs)[k], 0, 31, 0) + evs[k] = field(tableOf(packed.evs)[k], 0, 255, 0) + if not ivs[k] then + return nil, "bad ivs" + end + if not evs[k] then + return nil, "bad evs" + end + total = total + evs[k] + end + if total > 510 then + if strict then + return nil, "bad evs" + end + for i = #STATS, 1, -1 do + local k = STATS[i] + local cut = math.min(evs[k], total - 510) + evs[k], total = evs[k] - cut, total - cut + end + end + local slot = field(packed.abilitySlot, 1, 2, 1) + if not slot then + return nil, "bad ability" + end + local abilities = def.abilities or {} + if not abilities[2] or abilities[2] == 0 or abilities[2] == "NONE" then + slot = 1 + end + local constants = data.constants or {} + local nature = (constants.natureOrder or {})[pid % 25 + 1] + if not nature then + for id, row in pairs(constants.natures or {}) do + if row.index == pid % 25 then + nature = id + break + end + end + end + local Growth, Stats = require("src.pokemon.Growth"), require("src.pokemon.Stats") + local function expAt(n) + return Growth.withTables(constants.experienceTables, function() + return Growth.expForLevel(def.growthRate, n, data.growth_rates) + end) + end + local minExp, maxExp = expAt(level), expAt(math.min(level + 1, 100)) + if level < 100 then + maxExp = maxExp - 1 + end + local exp = field(packed.exp, minExp, maxExp, minExp) + if not exp then + return nil, "bad level" + end + local forced = tonumber(opts.forceLevel) + if forced and (forced ~= forced or math.abs(forced) == math.huge) then + forced = nil + end + if forced then + level = integer(forced, 1, 100, 50) + exp = expAt(level) + end + -- PID's native matrix works even without an English nature registry/global preview. + local stats = Stats.calcGen3(def, level, ivs, evs) + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(pid % 25 / 5) + 1, pid % 5 + 1 + if up ~= down then + stats[order[up]] = math.floor(stats[order[up]] * 110 / 100) + stats[order[down]] = math.floor(stats[order[down]] * 90 / 100) + end + stats.special = stats.spatk + local moves, seen = {}, {} + local entries = tableOf(packed.moves) + if strict and (#entries < 1 or #entries > 4) then + return nil, "bad move" + end + for _, mv in ipairs(entries) do + mv = tableOf(mv) + local mdef = data.moves[mv.id] + if not mdef or seen[mv.id] or #moves == 4 then + if strict then + return nil, "bad move" + end + else + local ups = field(mv.ppUps, 0, 3, 0) + if not ups then + return nil, "bad move" + end + local maxPp = mdef.pp + math.floor(mdef.pp * 20 * ups / 100) + local pp = field(mv.pp, 0, maxPp, maxPp) + if not pp then + return nil, "bad move" + end + moves[#moves + 1], seen[mv.id] = { id = mv.id, pp = pp, ppUps = ups, maxPp = maxPp }, true + end + end + if #moves == 0 then + return nil, "bad move" + end + if packed.item ~= nil and not data.items[packed.item] then + return nil, "bad item" + end + if packed.ball ~= nil and not data.items[packed.ball] then + return nil, "bad ball" + end + local otId, secretId = field(packed.otId, 0, 65535, 0), field(packed.secretId, 0, 65535, 0) + if not otId or not secretId then + return nil, "bad ot" + end + local ot, nickname = name(packed.ot, 7), name(packed.nickname, 10) + if strict and (ot ~= packed.ot or nickname ~= packed.nickname) then + return nil, "bad name" + end + local status = not forced and STATUSES[packed.status] and packed.status or nil + local gender = + require("src.pokemon.IdentityRolls").gender(data, { species = packed.species, personality = pid }, 3) + if strict and packed.gender ~= nil and packed.gender ~= gender then + return nil, "gender mismatch" + end + local mon = { + species = packed.species, + level = level, + exp = exp, + personality = pid, + ivs = ivs, + evs = evs, + nature = nature, + abilitySlot = slot, + ability = abilities[slot], + altAbility = slot == 2 or nil, + gender = gender, + stats = stats, + hp = forced and stats.hp or integer(packed.hp, 0, stats.hp, stats.hp), + status = status, + sleepTurns = status == "SLP" and integer(packed.sleepTurns, 1, 7, 1) or nil, + toxic = status == "PSN" and packed.toxic == true or nil, + moves = moves, + nickname = nickname, + item = packed.item, + ball = packed.ball, + ot = ot, + gen3OtName = ot, + otId = otId, + secretId = secretId, + otSecretId = secretId, + shiny = require("src.pokemon.IdentityRolls").nativeGen3Shiny(pid, otId, secretId), + otGender = integer(packed.otGender, 0, 1, 0), + friendship = integer(packed.happiness, 0, 255, def.friendship or 70), + happiness = integer(packed.happiness, 0, 255, def.friendship or 70), + gen3Language = integer(packed.gen3Language, 0, 255, 2), + gen3Pokerus = integer(packed.gen3Pokerus, 0, 255, 0), + gen3Markings = integer(packed.gen3Markings, 0, 15, 0), + contest = {}, + ribbons = integer(packed.ribbons, 0, 4294967295, 0), + metLocation = integer(packed.metLocation, 0, 255, 0), + metLevel = integer(packed.metLevel, 0, 100, 0), + metGame = integer(packed.metGame, 0, 15, 0), + isEgg = packed.isEgg == true or nil, + eggCycles = packed.isEgg == true and integer(packed.eggCycles, 0, 255, 0) or nil, + fatefulEncounter = packed.fatefulEncounter == true, + modernFatefulEncounter = packed.modernFatefulEncounter == true + or math.floor(integer(packed.ribbons, 0, 4294967295, 0) / 2147483648) % 2 == 1, + extra = plainCopy(packed.extra), + } + for _, k in ipairs(CONDITIONS) do + mon.contest[k] = integer(tableOf(packed.contest)[k], 0, 255, 0) + end + return mon +end +return C diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index bbd9cc087..567722b97 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -159,6 +159,7 @@ function Handshake.hello(game, mode) apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), nativeBattleRevision = Fingerprint.nativeBattleRevision(), + nativePartyRevision = Fingerprint.nativePartyRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -184,6 +185,9 @@ function Handshake.checkCompat(localHello, remoteHello) if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then return "refused", "engine_mismatch" end + if localHello.nativePartyRevision ~= remoteHello.nativePartyRevision then + return "refused", "native_party_codec_mismatch" + end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 746dd9a06..9ce3be503 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -155,6 +155,7 @@ end local function toxicSignature(mon) if not require("src.core.GameVersion").isGen3() then return "" end return ":toxic=" .. tostring(mon.status == "PSN" and mon.toxic == true) + .. require("src.link.Gen3Party").signature(mon) end local function activeStr(b) return ("%s:%d:%s:%s:%s:%s"):format(b.mon.species, b.mon.hp, diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index d45528d7e..1f345aa12 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -50,6 +50,9 @@ Protocol.plainCopy = plainCopy -- mon as traded (boosted EXP, high-level disobedience). Omitting them made -- a received mon show the receiver as its OT (#215). function Protocol.packMon(mon) + if require("src.core.GameVersion").isGen3() then + return require("src.link.Gen3Party").pack(mon, plainCopy) + end local moves = {} for _, mv in ipairs(mon.moves) do table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps }) @@ -77,6 +80,9 @@ end -- peers have agreed on a verdict: a mon that cannot be rebuilt identically -- is rejected by name instead of quietly mutated into something else. function Protocol.unpackMon(data, packed, opts) + if require("src.core.GameVersion").isGen3() then + return require("src.link.Gen3Party").unpack(data, packed, opts, plainCopy) + end local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") local strict = opts and opts.strict diff --git a/tests/enemy_actions/party_codec.lua b/tests/enemy_actions/party_codec.lua new file mode 100644 index 000000000..18decf502 --- /dev/null +++ b/tests/enemy_actions/party_codec.lua @@ -0,0 +1,206 @@ +-- Compare real codecs, retaining the complete identity/stats/metadata result. +local M = {} +local KEYS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local SHORT = { "hp", "atk", "def", "spe", "spa", "spd" } +local SOURCE = { "maxHp", "attack", "defense", "speed", "spAtk", "spDef" } +local SPECIES = { + { 60, { 40, 50, 40, 90, 40, 40 }, 127, { 11, 6 } }, + { 303, { 1, 90, 45, 40, 30, 30 }, 255, { 25, 0 } }, +} +function M.fixture(root, backend, version, format) + local native = backend == "gen1" + local ids = {} + local function configure(data) + data.constants = data.constants or {} + data.constants.natureOrder, data.constants.natures = {}, {} + for n = 0, 24 do + local id = format .. "_NATURE_" .. n + data.constants.natureOrder[n + 1] = id + data.constants.natures[id] = { index = n, modifiers = {} } + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(n / 5) + 1, n % 5 + 1 + if up ~= down then + data.constants.natures[id].modifiers[order[up]] = 110 + data.constants.natures[id].modifiers[order[down]] = 90 + end + end + for _, row in ipairs(SPECIES) do + local n, base, ratio, pair = unpack(row) + local id = format .. "_SPECIES_" .. n + ids[n] = id + if native then + local p = data.gen3Pokemon + p._names[n], p._stats[n], p._types[n] = id, {}, { 0, 0 } + for i, k in ipairs(SHORT) do + p._stats[n][k] = base[i] + end + p._speciesMeta[n] = { genderRatio = ratio, growthRate = 0, friendship = 70 } + p._abilities[n], p._learnsets[n] = pair, { { 1, 33 } } + else + local def = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + def.id, def.index, def.name, def.genderRatio = id, n, id, ratio + def.baseStats, def.evYield, def.growthRate, def.abilities = {}, {}, "MEDIUM_FAST", {} + for i, k in ipairs(KEYS) do + def.baseStats[k] = base[i] + end + for i, a in ipairs(pair) do + if a ~= 0 then + def.abilities[i] = "ABILITY_" .. a + end + end + data.pokemon[id] = def + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + f.codecIds = ids + return f +end +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + for _, format in ipairs({ "canonical", "raw", "translated" }) do + local f = M.fixture(root, backend, version, format) + local T, Protocol = f.T, require("src.link.Protocol") + for _, species in ipairs(SPECIES) do + for _, realLevel in ipairs({ 1, 35, 100 }) do + for nature = 0, 24 do + for slot = 1, 2 do + for _, investment in ipairs({ 0, 1, 2 }) do + for _, forced in ipairs({ false, 50 }) do + local pid = 100000 + nature + pid = pid - pid % 25 + nature + local ivs, evs = {}, {} + for i, k in ipairs(KEYS) do + ivs[native and SHORT[i] or k] = investment == 0 and 0 or investment == 1 and 31 or i * 4 + evs[native and SHORT[i] or k] = investment == 0 and 0 + or investment == 1 and (i < 3 and 252 or i == 3 and 6 or 0) + or i * 12 + end + local second = species[4][2] ~= 0 and slot == 2 + local mon = { + species = native and species[1] or f.codecIds[species[1]], + speciesNumbering = native and "internal" or nil, + level = realLevel, + exp = realLevel ^ 3 + (realLevel == 35 and 12 or 0), + hp = 1, + personality = pid, + ivs = ivs, + evs = evs, + nature = native and nature or f.data.constants.natureOrder[nature + 1], + abilitySlot = second and 2 or 1, + abilityNum = second and 1 or 0, + ability = native and species[4][second and 2 or 1] or nil, + moves = native and { 33 } or { { id = f.ids.TACKLE, pp = 6, ppUps = 3 } }, + pp = native and { 6 } or nil, + ppBonusesPacked = native and 3 or nil, + otId = 1234, + otSecretId = 5678, + secretId = 5678, + ot = "TRAINER", + nickname = "PÖKEMON", + otGender = 1, + item = native and 0 or nil, + pokeball = 4, + happiness = 203, + friendship = 203, + contest = { cool = 7, beauty = 19, cute = 31, smart = 43, tough = 55, sheen = 67 }, + language = 5, + gen3Language = 5, + pokerus = 35, + gen3Pokerus = 35, + markings = 9, + gen3Markings = 9, + ribbons = 123456, + metLevel = 12, + metLocation = 17, + metGame = 3, + status = "PAR", + fatefulEncounter = true, + modernFatefulEncounter = investment == 2 and true or nil, + } + if not native then + f.data.items.CODEC_BALL = { index = 4 } + mon.ball = "CODEC_BALL" + end + local before = require("src.link.Json").encode(mon) + local packed = native and Protocol.packMon3(mon) or Protocol.packMon(mon) + packed = require("src.link.Json").decode(require("src.link.Json").encode(packed)) + local restored, why + if native then + restored, why = + Protocol.unpackMon3(f.data, packed, { strict = true, forceLevel = forced or nil }) + else + restored, why = + Protocol.unpackMon(f.data, packed, { strict = true, forceLevel = forced or nil }) + end + T.check(restored ~= nil, "native party packet decodes: " .. tostring(why)) + assert(restored, why) + T.eq(require("src.link.Json").encode(mon), before, "codec does not mutate sender") + local stats, ivOut, evOut = {}, {}, {} + for i, k in ipairs(KEYS) do + stats[i] = native and restored[SOURCE[i]] or restored.stats[k] + ivOut[i], evOut[i] = + restored.ivs[native and SHORT[i] or k], restored.evs[native and SHORT[i] or k] + T.eq(ivOut[i], ivs[native and SHORT[i] or k], "full six-stat IVs survive") + T.eq(evOut[i], evs[native and SHORT[i] or k], "full six-stat EVs survive") + end + T.eq(restored.personality, pid, "PID survives the actual wire path") + T.eq(restored.hp, forced and stats[1] or 1, "forced level heals without changing genotype") + if not forced then + T.eq(restored.status, "PAR", "ordinary status survives") + end + -- Lua's a-and-nil-or-b idiom cannot represent the forced nil. + if forced then + T.eq(restored.status, nil, "forced-level status is cleared") + end + local ability = native and restored.ability or tonumber((restored.ability or ""):match("%d+")) + T.eq(ability, species[4][second and 2 or 1], "stored valid ability slot survives") + local gender = native and ({ M = "male", F = "female", U = "unknown" })[restored.gender] + or restored.gender + local pp = native and restored.pp[1] or restored.moves[1].pp + local maxPp = native and restored.maxPp[1] or restored.moves[1].maxPp + rows[#rows + 1] = { + species = species[1], + format = format, + pid = restored.personality, + nature = native and restored.nature or restored.personality % 25, + gender = gender, + ability = ability, + stats = stats, + ivs = ivOut, + evs = evOut, + level = restored.level, + exp = restored.exp, + hp = restored.hp, + status = restored.status, + moves = { { 33, pp, maxPp } }, + nickname = restored.nickname, + ot = restored.ot, + otId = restored.otId, + secretId = native and restored.otSecretId or restored.secretId, + friendship = restored.friendship, + contest = restored.contest, + language = native and restored.language or restored.gen3Language, + pokerus = native and restored.pokerus or restored.gen3Pokerus, + markings = native and restored.markings or restored.gen3Markings, + ribbons = restored.ribbons, + metLevel = restored.metLevel, + metLocation = restored.metLocation, + metGame = restored.metGame, + ball = native and restored.pokeball or f.data.items[restored.ball].index, + otGender = restored.otGender, + fatefulEncounter = restored.fatefulEncounter, + modernFatefulEncounter = restored.modernFatefulEncounter, + } + end + end + end + end + end + end + f.close() + end + return rows +end +return M diff --git a/tests/enemy_actions/party_codec_hooks.lua b/tests/enemy_actions/party_codec_hooks.lua new file mode 100644 index 000000000..9485ced30 --- /dev/null +++ b/tests/enemy_actions/party_codec_hooks.lua @@ -0,0 +1,84 @@ +local M = {} +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + local f = + dofile(root .. "/tests/enemy_actions/party_codec.lua").fixture(root, backend, version, "translated") + local P, calls, policy = require("src.link.Protocol"), {}, "none" + local function number(id) + if not native then + return f.data.pokemon[id].index + end + -- API 3 names source species by canonical text ID; the ROM codec uses numbers. + return type(id) == "number" and id or assert(tonumber(id:match("SPECIES_(%d+)$"))) + end + local function hook(nextGender, c) + calls[#calls + 1] = { + species = number(c.species), + definition = number(c.def.id), + ratio = c.ratio, + personality = c.personality, + } + if policy == "female" then + return "female" + end + if policy == "invalid" then + return "invalid" + end + if policy == "threshold" then + c.def.genderRatio = 0 + end + return nextGender(c) + end + for _, mode in ipairs({ "none", "delegate", "female", "invalid", "threshold" }) do + policy = mode + local stop = mode ~= "none" and f.e.api.hooks:wrap("gender.roll", hook) or nil + for _, pid in ipairs({ 0, 1, 127, 128, 255, 65535, 4294967295 }) do + local ivs, evs = {}, {} + for _, k in + ipairs( + native and { "hp", "atk", "def", "spe", "spa", "spd" } + or { "hp", "attack", "defense", "speed", "spatk", "spdef" } + ) + do + ivs[k], evs[k] = 0, 0 + end + local mon = { + species = native and 60 or f.codecIds[60], + speciesNumbering = native and "internal" or nil, + level = 20, + exp = 8000, + personality = pid, + ivs = ivs, + evs = evs, + hp = 1, + moves = native and { 33 } or { { id = f.ids.TACKLE, pp = 1, ppUps = 0 } }, + pp = native and { 1 } or nil, + ability = native and 11 or nil, + abilitySlot = 1, + } + calls = {} + local packed = native and P.packMon3(mon) or P.packMon(mon) + local restored, why + if native then + restored, why = P.unpackMon3(f.data, packed, { strict = true }) + else + restored, why = P.unpackMon(f.data, packed, { strict = true }) + end + f.T.check(restored ~= nil, "public gender policy survives the codec: " .. tostring(why)) + assert(restored, why) + f.T.eq(#calls, mode == "none" and 0 or 2, "pack and unpack retain public hook entry points") + rows[#rows + 1] = { + mode = mode, + pid = pid, + calls = calls, + gender = native and ({ M = "male", F = "female", U = "unknown" })[restored.gender] or restored.gender, + } + end + if stop then + stop() + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 23a2ef684..df392120e 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Audited against the unchanged #214 Fingerprint module and this raw fixture. +-- Legacy hash audited against #214; native hash is #215 plus [native_gba_party]1. local LEGACY = "e33d251961f2a41d" -local NATIVE = "3a76cc5fce47a5c3" +local NATIVE = "2fd444270c3d90db" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() diff --git a/tests/engine/gen3_party_codec.lua b/tests/engine/gen3_party_codec.lua new file mode 100644 index 000000000..6aaaae0e0 --- /dev/null +++ b/tests/engine/gen3_party_codec.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.party_codec").run(".", "gen2", version) + require("tests.enemy_actions.party_codec_hooks").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA party codec") diff --git a/tests/engine/gen3_party_transport.lua b/tests/engine/gen3_party_transport.lua new file mode 100644 index 000000000..411705e6e --- /dev/null +++ b/tests/engine/gen3_party_transport.lua @@ -0,0 +1,275 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local P, H = require("src.link.Protocol"), require("src.link.Handshake") +local Copy, Json = require("src.mods.Merge").deepCopy, require("src.link.Json") +local C = require("src.link.Gen3Party") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.party_codec").fixture(".", "gen2", version, "translated") + local Stats, Pokemon = require("src.pokemon.Stats"), require("src.pokemon.Pokemon") + Stats.setNatures(f.data.constants.natures) + local function mon(pid, slot) + local m = Pokemon.new(f.data, f.codecIds[60], 30) + m.evs = { hp = 252, attack = 0, defense = 6, speed = 252, spatk = 0, spdef = 0 } + Pokemon.applySeed( + f.data, + m, + { personality = pid, ivs = { hp = 31, attack = 29, defense = 27, speed = 25, spatk = 23, spdef = 21 } } + ) + m.abilitySlot, m.ot, m.otId, m.secretId, m.otSecretId = slot, "TRAINER", 1234, 5678, 5678 + m.moves = { { id = f.ids.TACKLE, pp = 10, ppUps = 3 } } + m.nickname, m.extra = "PÖKEMON", { consumer = { marker = version } } + return m + end + local original = mon(100001, 2) + local packed = P.packMon(original) + local decoded = assert(P.unpackMon(f.data, Json.decode(Json.encode(packed)), { strict = true })) + T.same(decoded.ivs, original.ivs, "actual packet preserves IVs") + T.same(decoded.evs, original.evs, "actual packet preserves EVs") + T.same(decoded.stats, original.stats, "party reconstructed with its original native stats") + T.eq(decoded.abilitySlot, 2, "explicit alternate ability survives independently of PID") + T.eq(decoded.otSecretId, 5678, "public shiny reader receives the secret ID alias") + T.eq( + require("src.mods.Api3Records").shiny(decoded, 3), + require("src.pokemon.IdentityRolls").nativeGen3Shiny(original.personality, 1234, 5678), + "public identity agrees after transport" + ) + local saved = require("src.core.SaveSerializer").decode( + require("src.core.SaveSerializer").encode({ party = { decoded } }) + ) + T.same(saved.party[1].ivs, original.ivs, "received genetics survive save reload") + decoded.ivs.hp = 0 + decoded.extra.consumer.marker = "changed" + T.eq(original.ivs.hp, 31, "received genotype is detached") + T.eq(original.extra.consumer.marker, version, "received extension namespace is detached") + local cases = { + { + "codec", + function(p) + p.gen3Codec = nil + end, + }, + { + "PID", + function(p) + p.personality = 4294967296 + end, + }, + { + "NaN", + function(p) + p.personality = 0 / 0 + end, + }, + { + "infinity", + function(p) + p.personality = math.huge + end, + }, + { + "nature", + function(p) + p.nature = 24 + end, + }, + { + "gender", + function(p) + p.gender = "female" + end, + }, + { + "IVs", + function(p) + p.ivs.hp = 32 + end, + }, + { + "EVs", + function(p) + p.evs.hp = 256 + end, + }, + { + "EV sum", + function(p) + p.evs.defense = 7 + end, + }, + { + "ability", + function(p) + p.abilitySlot = 3 + end, + }, + { + "species", + function(p) + p.species = "MISSING" + end, + }, + { + "moves", + function(p) + p.moves = {} + end, + }, + { + "duplicate", + function(p) + p.moves[2] = Copy(p.moves[1]) + end, + }, + { + "PP", + function(p) + p.moves[1].pp = 999 + end, + }, + { + "PP Ups", + function(p) + p.moves[1].ppUps = 4 + end, + }, + { + "OT", + function(p) + p.secretId = 65536 + end, + }, + { + "level", + function(p) + p.level = 0 + end, + }, + { + "exp", + function(p) + p.exp = 0 + end, + }, + { + "name", + function(p) + p.ot = "A\nB" + end, + }, + { + "item", + function(p) + p.item = "MISSING" + end, + }, + } + for _, case in ipairs(cases) do + local p = Copy(packed) + case[2](p) + local ok, got, why = pcall(P.unpackMon, f.data, p, { strict = true }) + T.eq(ok, true, "malformed " .. case[1] .. " returns without crashing") + T.eq(got, nil, "malformed " .. case[1] .. " is refused") + T.check(type(why) == "string", "refusal explains malformed " .. case[1]) + end + for _, status in ipairs({ "SLP", "PSN", "PAR", "BRN", "FRZ" }) do + local p = Copy(packed) + p.status, p.toxic, p.sleepTurns = status, true, 7 + local m = assert(P.unpackMon(f.data, p, { strict = true })) + T.eq(m.status, status, "party retains status") + T.eq(m.toxic, status == "PSN" and true or nil, "only poison retains strong-poison identity") + local forced = assert(P.unpackMon(f.data, p, { strict = true, forceLevel = 50 })) + T.eq(forced.status, nil, "auto-level clears status") + T.eq(forced.sleepTurns, nil, "auto-level clears sleep count") + T.eq(forced.toxic, nil, "auto-level clears toxic identity") + T.eq(forced.hp, forced.stats.hp, "auto-level starts full") + T.eq(forced.personality, original.personality, "auto-level retains personality") + end + local a, b = + P.TradeSession.new(f.data, { original }, { strict = true }), + P.TradeSession.new(f.data, { mon(100024, 1) }, { strict = true }) + local ao, bo = a:opening(), b:opening() + a:handle(bo) + b:handle(ao) + local ap, bp = a:pick(1), b:pick(1) + a:handle(bp) + b:handle(ap) + local ac, bc = a:confirm(true), b:confirm(true) + a:handle(bc) + b:handle(ac) + T.eq(a.stage, "done", "real trade controller completes") + T.eq(b.stage, "done", "both trade roles complete") + T.same(b.theirParty[1].ivs, original.ivs, "actual trade receives six native IVs") + T.eq(b.theirParty[1].abilitySlot, 2, "actual trade keeps selected ability") + local received = b:apply() + T.eq(received, b.party[1], "actual trade apply files the received party member") + T.eq(received.traded, true, "received native mon retains traded identity") + T.same(received.ivs, original.ivs, "trade application keeps all native IVs") + T.same(received.evs, original.evs, "trade application keeps all native EVs") + local persisted = + require("src.core.SaveSerializer").decode(require("src.core.SaveSerializer").encode({ party = b.party })) + T.eq(persisted.party[1].otSecretId, 5678, "applied trade retains secret ID through save reload") + local bad = P.TradeSession.new(f.data, { original }, { strict = true }) + local packet = P.packParty({ original, original }) + packet[1].ivs.hp = -1 + bad:handle({ type = "party", mons = packet }) + T.eq(bad.stage, "cancelled", "invalid row cancels instead of sliding later slots") + local hello = H.hello({ data = f.data, save = { player = { name = "RULES" } } }, "trade") + local old = Copy(hello) + old.nativePartyRevision = nil + for _, pair in ipairs({ { hello, old }, { old, hello } }) do + T.eq(H.checkCompat(pair[1], pair[2]), "refused", "old native party codec is refused in both roles") + for _, mode in ipairs({ "trade", "battle" }) do + local state = setmetatable( + { game = f.game, myHello = pair[1], peerHello = pair[2] }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat(mode, true) + T.eq(state.stage, "notice", "actual controller stops unsafe " .. mode) + T.eq(state.noticeExits, true, "unsafe codec cannot enter party exchange") + end + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, f.codecIds[60], { name = "A", level = 30 }) + local gb = T.link.fakeGame(f.data, f.codecIds[60], { name = "B", level = 30 }) + ga.save.party, gb.save.party = { original }, { mon(100024, 1) } + local result = T.link.lockstep(ga, gb, { seed = 327, maxFrames = 60000 }) + T.eq(result.completed, true, "full native-genetics link mirror completes") + T.eq(result.agreed, true, "native-genetics turn hashes agree") + for _, m in ipairs({ result.battleA.player.mon, result.battleB.enemy.mon }) do + T.same(m.ivs, original.ivs, "local and remote battle receive original IVs") + T.same(m.evs, original.evs, "local and remote battle receive original EVs") + T.eq(m.personality, original.personality, "local and remote battle receive original PID") + T.eq(m.abilitySlot, 2, "local and remote battle retain alternate ability") + end + for turn, hash in pairs(result.battleA.localHashes) do + if result.battleB.localHashes[turn] then + T.eq(hash, result.battleB.localHashes[turn], "every completed turn agrees") + T.same( + result.battleA.localParts[turn], + result.battleB.localParts[turn], + "every completed state component agrees" + ) + end + end + local signature = C.signature(original) + for _, key in ipairs({ "personality", "abilitySlot", "level", "exp", "otId", "otSecretId" }) do + local changed = Copy(original) + changed[key] = (changed[key] or 0) + 1 + T.neq(C.signature(changed), signature, "state signature detects altered " .. key) + end + for _, kind in ipairs({ "ivs", "evs" }) do + local changed = Copy(original) + changed[kind].hp = changed[kind].hp + 1 + T.neq(C.signature(changed), signature, "state signature detects altered " .. kind) + end + Stats.setNatures(nil) + f.close() +end +require("src.core.GameVersion").set("red") +local classic = T.fixtures.fresh() +local original = require("src.pokemon.Pokemon").new(classic, "FIXMON_A", 20) +local packed = P.packMon(original) +T.eq(packed.gen3Codec, nil, "older-game packet keeps its existing shape") +T.eq(packed.ivs, nil, "older games do not acquire GBA genetics") +T.same(assert(P.unpackMon(classic, packed)).dvs, original.dvs, "classic DV codec remains intact") +T.finish("native GBA party transport, trade and link") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 29ab913c7..039d95e58 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 043eaacf8..c29a70397 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,11 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "secondary" or mode == "secondary_execution" then +if mode == "party_codec_hooks" then + rows = dofile(root .. "/tests/enemy_actions/party_codec_hooks.lua").run(root, backend, version) +elseif mode == "party_codec" then + rows = dofile(root .. "/tests/enemy_actions/party_codec.lua").run(root, backend, version) +elseif mode == "secondary" or mode == "secondary_execution" then rows = dofile(root .. "/tests/enemy_actions/secondary.lua").run(root, backend, version, mode == "secondary_execution") elseif mode == "accuracy_execution" then rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version, true) From 0905dcd005d102c33d31fad12d2082290d76f56c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:12:21 +0100 Subject: [PATCH 38/90] Track native GBA Lock-On ownership through residuals and switches --- .github/workflows/enemy-action-contract.yml | 2 + docs/native-gen3-lock-on.md | 73 ++++ src/battle/BattleState.lua | 9 + src/battle/EffectRegistry.lua | 3 +- src/battle/Gen3Accuracy.lua | 6 +- src/battle/Gen3LockOn.lua | 90 ++++ src/battle/Gen3MoveEffects.lua | 18 +- src/battle/Gen3OHKO.lua | 2 +- src/link/Fingerprint.lua | 2 +- src/link/Handshake.lua | 3 + src/link/LinkBattle.lua | 5 +- tests/enemy_actions/lock_on.lua | 429 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_lock_on.lua | 5 + tests/engine/gen3_lock_on_link.lua | 128 ++++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 17 files changed, 771 insertions(+), 16 deletions(-) create mode 100644 docs/native-gen3-lock-on.md create mode 100644 src/battle/Gen3LockOn.lua create mode 100644 tests/enemy_actions/lock_on.lua create mode 100644 tests/engine/gen3_lock_on.lua create mode 100644 tests/engine/gen3_lock_on_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index e0b3513e4..ada3ee457 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -22,6 +22,8 @@ on: - 'tests/engine/gen3_battle_protocol.lua' - 'tests/engine/gen3_party_codec.lua' - 'tests/engine/gen3_party_transport.lua' + - 'tests/engine/gen3_lock_on.lua' + - 'tests/engine/gen3_lock_on_link.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-lock-on.md b/docs/native-gen3-lock-on.md new file mode 100644 index 000000000..0c8eeefec --- /dev/null +++ b/docs/native-gen3-lock-on.md @@ -0,0 +1,73 @@ +# Target-owned native GBA Lock-On + +The generic producer stored a one-use `user.lockedOn` pointer. Native GBA +instead owns sure-hit state on the target, starting at two residual phases, +with the originating side and (in doubles) battler slot. Repeated accuracy +commands and attacks do not consume this timer. The source is unchanged Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; all Gen1 files stay read-only. + +The native Lock-On/Mind Reader producer now runs Substitute, accuracy and +Protect/semi-invulnerability checks in the observed order, then records target +ownership. The regular accuracy and OHKO consumers share that ownership check. +The real residual sweep expires the timer; normal target replacement drops it, +owner replacement clears its bindings and unrelated partner replacement leaves +it intact. Both direct primary-slot assignment and placed/forced replacements +pass through the lifecycle. The existing Baton Pass switch entry transfers the +target timer/side with the reference's slot-owner fallback behavior. The full +Baton Pass move/UI chooser is separate work; these tests exercise its explicit +switch-control entry, not a complete player chooser lifecycle. + +Authored legacy GBA consumers can still supply their old one-use pointer; +older games keep their existing producer/consumer. Authored effect handlers +retain their own policies. Source constructor/party schemas and game-wide RNG +scheduling are not changed by these command contracts. + +Native command revision 2 participates in full GBA link fingerprints. The +explicit revision guard prevents an overridden/custom fingerprint from admitting +different native battle rules. Matching party codecs can still negotiate shared +trade data; older command revisions cannot start a battle. Duration and owned +side/slot enter active-state components, normalized to host coordinates so the +guest's mirrored view agrees. The audited raw fixture changes from #216's +`2fd444270c3d90db` to `cf73a8e083fb8a54`: unchanged #216 hasher, only the explicit +`[native_gba_commands]1` to `2` token changes. Non-GBA golden values stay unchanged. + +## Reproduce + +Use maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_lock_on.lua +luajit tests/engine/gen3_lock_on_link.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/lock-on-report.json +``` + +The comparison executes actual Lock-On/Mind Reader move resolvers, accuracy +commands, native residual sweeps and switch functions in both engines. All four +actor slots, singles/doubles, reapplication/ownership transfer, owner/partner/ +target replacement and the explicit Baton Pass transfer entry are covered. +Ordinary real hits, Protect and semi-invulnerability, Substitute including zero, +OHKO level/Sturdy/guard paths, accuracy overrides and both public accuracy/damage +entry points remain observable. Rows retain all bounded RNG calls/values, +public hook/event calls, PP, HP and raw timer/side/slot ownership. Only engine +schema representations are mapped; no observations are omitted. + +A clean #216 negative control completes all 213 new rows but differs in 207, +failing 103 assertions. Its complete failing output is retained privately. +Two full seeded link mirrors aim on the first turn, attack thereafter, and +verify both marks at one residual phase then expiry, with every completed +paired turn hash/component agreeing. These are CPU/controller fixtures, not +GPU or whole-game playthrough acceptance. + +A private CPU-only smoke loads actual FireRed/Emerald imports, selects canonical +`LOCK_ON` through the shared public enemy-action consumer, then executes two real +Tackle hits and two end-of-turn sweeps. It verifies one accuracy-hook call, +target/slot ownership, unchanged duration across hits and eventual expiry. +Controlled HP avoids a KO. No ROM/cache assets or saves are distributed. + +Full Baton Pass chooser/playback, other side/volatile producers, native egg +legality, whole-game RNG/VBlank scheduling and live UI/full playthrough +acceptance remain separate work. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6ff487102..2003754ca 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3406,6 +3406,9 @@ end -- caller already means. function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) + if GameVersion.isGen3() then + require("src.battle.Gen3LockOn").replacing(self, pos, self.sides[side].battlers[flank], battler) + end self.sides[side].battlers[flank] = battler if battler then battler.position = pos end if flank == 1 then @@ -3512,6 +3515,11 @@ end function BattleState:activeBattlers() return activeBattlers(self) end function BattleState:syncSides() + if GameVersion.isGen3() then + local lock = require("src.battle.Gen3LockOn") + lock.replacing(self, 0, self.sides[1].battlers[1], self.player) + lock.replacing(self, 1, self.sides[2].battlers[1], self.enemy) + end self.sides[1].battlers[1] = self.player self.sides[2].battlers[1] = self.enemy if self.player then self.player.position = BattleState.POS.PLAYER_LEFT end @@ -6436,6 +6444,7 @@ end function BattleState:gen3CountersTick() for _, b in activeBattlers(self) do if b and b.mon and b.mon.hp > 0 then + if GameVersion.isGen3() then require("src.battle.Gen3LockOn").tick(self, b) end -- 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 diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 7a28f7013..2383e1fc4 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -126,7 +126,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- cartridge marks PROTECT-affected is stopped, which is the second use the -- extracted flags byte is put to; a move with no flags recorded (every Gen -- 1 and Gen 2 one) is treated as affected, which is that generation's rule. - local lockedOHKO = record and record.gbaOHKO and user.lockedOn == target + local lockedOHKO = record and record.gbaOHKO + and require("src.battle.Gen3LockOn").active(battle, user, target) if target.protecting and (not gba or target ~= user) and move.protectAffected ~= false and not lockedOHKO then battle:cancelMoveAnim() if record and record.onProtect and record.onProtect(ctx) then return end diff --git a/src/battle/Gen3Accuracy.lua b/src/battle/Gen3Accuracy.lua index 77fc249ae..d7d95dda0 100644 --- a/src/battle/Gen3Accuracy.lua +++ b/src/battle/Gen3Accuracy.lua @@ -28,7 +28,7 @@ function A.guaranteed(battle, user, target, move) end return battle.pokedudeDemo ~= nil and battle.pokedudeDemo ~= false or battle.pokedude == true - or user.lockedOn == target + or require("src.battle.Gen3LockOn").active(battle, user, target) end local function absorbed(battle, user, target, move) if target == user then @@ -49,8 +49,8 @@ function A.check(battle, move, user, target, accuracyRaw) return false, "protected" end local guaranteed = A.guaranteed(battle, user, target, move) - -- The existing Lock-On producer uses a one-use pointer. Its native timed - -- producer is a separate lifecycle contract; preserve that legacy lifetime. + -- Authored legacy bindings keep their one-use lifetime. The native + -- producer stores a separate target-owned timer and is not consumed here. if user.lockedOn == target then user.lockedOn = nil end diff --git a/src/battle/Gen3LockOn.lua b/src/battle/Gen3LockOn.lua new file mode 100644 index 000000000..591367177 --- /dev/null +++ b/src/battle/Gen3LockOn.lua @@ -0,0 +1,90 @@ +-- Target-owned native sure-hit state. Older games retain their one-use pointer. +local L = {} +function L.signature(target, role) + local turns = target.lockOnTurns + if turns == nil or turns == false or turns == 0 then + return ":lock=0" + end + local side, pos = target.lockOnBy, target.lockOnByPos + if role == "guest" then + if type(side) == "boolean" then + side = not side + end + if type(pos) == "number" then + pos = pos % 2 == 0 and pos + 1 or pos - 1 + end + end + return ":lock=" .. tostring(turns) .. "," .. tostring(side) .. "," .. tostring(pos) +end +local function position(battle, who) + if who.position ~= nil then + return who.position + end + for pos = 0, 3 do + if battle.sides and battle:battlerAt(pos) == who then + return pos + end + end + return who.isPlayer and 0 or 1 +end +function L.active(battle, user, target) + if not (user and target) then + return false + end + local turns = target.lockOnTurns + if turns ~= nil and turns ~= false and turns ~= 0 then + if battle.double and target.lockOnByPos ~= nil then + return target.lockOnByPos == position(battle, user) + end + return target.lockOnBy == nil or target.lockOnBy == user.isPlayer + end + -- Authored legacy GBA consumers can still supply the old one-use binding. + return user.lockedOn == target +end +function L.mark(battle, user, target) + target.lockOnTurns = 2 + target.lockOnBy, target.lockOnByPos = user.isPlayer, position(battle, user) +end +function L.tick(battle, target) + if battle.result or not target.mon or (target.mon.hp or 0) <= 0 then + return + end + local turns = tonumber(target.lockOnTurns) + if turns and turns > 0 then + target.lockOnTurns = turns - 1 + if target.lockOnTurns <= 0 then + target.lockOnTurns, target.lockOnBy = nil, nil + end + elseif target.lockOnTurns == true then + target.lockOnTurns = 1 + end +end +function L.replacing(battle, pos, previous, incoming) + if not incoming or incoming == previous then + return + end + local pass = previous and previous.batonPass + if pass then + incoming.lockOnTurns, incoming.lockOnBy = previous.lockOnTurns, previous.lockOnBy + -- The reference's Baton Pass copy omits the slot owner, retaining side fallback. + end + local active = {} + for p = 0, 3 do + local side, flank = p % 2 + 1, math.floor(p / 2) + 1 + local s = battle.sides and battle.sides[side] + local b = s and s.battlers and s.battlers[flank] or p == 0 and battle.player or p == 1 and battle.enemy + if b then + active[#active + 1] = b + end + end + for _, target in ipairs(active) do + if battle.double then + if target ~= previous and target.lockOnByPos == pos then + target.lockOnTurns, target.lockOnBy, target.lockOnByPos = nil, nil, nil + end + elseif target.isPlayer ~= (pos % 2 == 0) and target.lockOnBy == (pos % 2 == 0) then + target.lockOnTurns = nil + end + end +end +return L diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 4c9ce6a96..f4a41fe68 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -323,9 +323,21 @@ P.ENCORE_EFFECT = function(battle, user, target) end ACC.ENCORE_EFFECT = true --- LOCK-ON / MIND READER: the user's next move cannot miss. -P.LOCK_ON_EFFECT = function(battle, user, target) - user.lockedOn = target +-- LOCK-ON / MIND READER: native GBA owns a target timer; older games keep +-- the existing next-move pointer. +P.LOCK_ON_EFFECT = function(battle, user, target, move) + if require("src.core.GameVersion").isGen3() then + if (target.substituteHP or 0) > 0 then return { Strings("But, it failed!") } end + local hit, reason = battle:accuracyRoll(move, user, target) + if not hit then + if reason == "absorbed" then return {} end + if reason == "protected" then return { Strings("%s\nprotected itself!", displayName(target)) } end + return { Strings("%s's\nattack missed!", displayName(user)) } + end + require("src.battle.Gen3LockOn").mark(battle, user, target) + else + user.lockedOn = target + end return { Strings("%s took aim\nat %s!", displayName(user), displayName(target)) } end diff --git a/src/battle/Gen3OHKO.lua b/src/battle/Gen3OHKO.lua index 5c72bd62c..db49bc8f0 100644 --- a/src/battle/Gen3OHKO.lua +++ b/src/battle/Gen3OHKO.lua @@ -18,7 +18,7 @@ local function gate(ctx) return false, Strings("%s's STURDY\nmade the move fail!", ctx.displayName(target)) end local userLevel, targetLevel = tonumber(user.mon.level) or 1, tonumber(target.mon.level) or 1 - if user.lockedOn == target and userLevel >= targetLevel then + if require("src.battle.Gen3LockOn").active(battle, user, target) and userLevel >= targetLevel then return true end local chance = (tonumber(ctx.move.accuracy) or 30) + userLevel - targetLevel diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 1852018f8..a551cb592 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 1 or nil + return require("src.core.GameVersion").isGen3() and 2 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 567722b97..2a55d340b 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -188,6 +188,9 @@ function Handshake.checkCompat(localHello, remoteHello) if localHello.nativePartyRevision ~= remoteHello.nativePartyRevision then return "refused", "native_party_codec_mismatch" end + if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then + return "subset", "native_rules_mismatch" + end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 9ce3be503..30ce322f4 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -157,10 +157,11 @@ local function toxicSignature(mon) return ":toxic=" .. tostring(mon.status == "PSN" and mon.toxic == true) .. require("src.link.Gen3Party").signature(mon) end -local function activeStr(b) +local function activeStr(b, role) return ("%s:%d:%s:%s:%s:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status), tostring(b.mon.item), stageStr(b), ppStr(b.mon)) .. toxicSignature(b.mon) + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3LockOn").signature(b, role) or "") end local function benchStr(party) @@ -179,7 +180,7 @@ local function stateSig(self, role, myParty, theirParty) local hostParty = role == "host" and myParty or theirParty local guestParty = role == "host" and theirParty or myParty return { - actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)), + actives = Fingerprint.digest(activeStr(host, role) .. "|" .. activeStr(guest, role)), volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)), bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)), } diff --git a/tests/enemy_actions/lock_on.lua b/tests/enemy_actions/lock_on.lua new file mode 100644 index 000000000..6ded2c731 --- /dev/null +++ b/tests/enemy_actions/lock_on.lua @@ -0,0 +1,429 @@ +-- Real move producers, repeated accuracy commands, residual sweeps and switches. +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local function configure(data, _, ids, rom, names) + for _, row in ipairs({ { 199, "LOCK_ON", 94 }, { 170, "MIND_READER", 94 }, { 90, "FISSURE", 38 } }) do + local n, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(n) + ids[name], names[n] = id, name + if native then + local d = { + effect = effect, + type = n == 90 and 4 or 0, + accuracy = n == 90 and 30 or 100, + power = 0, + pp = 20, + target = 0, + flags = 2, + priority = 0, + secondaryChance = 0, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, name + else + data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + power = 0, + type = n == 90 and "GROUND" or "NORMAL", + accuracy = n == 90 and 30 or 100, + pp = 20, + target = 0, + protectAffected = true, + effect = n == 90 and "OHKO_EFFECT" or "LOCK_ON_EFFECT", + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + return f +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local calls, draws, moves, policy = {}, {}, {}, "delegate" + local damageCalls = 0 + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + damageCalls = damageCalls + 1 + return nextDamage(...) + end) + local stop = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, c) + calls[#calls + 1] = { + move = c.moveId, + number = c.moveNum, + user = c.user.mon.nickname, + target = c.target.mon.nickname, + accuracy = c.accuracy, + } + if policy == "deny" then + return false + end + if policy == "force" then + return true + end + return nextAccuracy(c) + end) + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local stopMoves = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local function fresh(double) + damageCalls = 0 + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if double then + put(b, 2, actor(Copy(b.player.mon), 2)) + put(b, 3, actor(Copy(b.enemy.mon), 3)) + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + local ns = { 199, 170, 33, 90 } + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = ns, { 20, 20, 20, 20 }, { 20, 20, 20, 20 } + a.ability, a.mon.ability, a.mon.speed = 0, 0, 400 - id * 50 + a.mon.item, a.item = 0, 0 + a.type1, a.type2 = 0, nil + else + a.curMoves = { + { id = f.ids.LOCK_ON, pp = 20 }, + { id = f.ids.MIND_READER, pp = 20 }, + { id = f.ids.TACKLE, pp = 20 }, + { id = f.ids.FISSURE, pp = 20 }, + } + a.mon.moves, a.abilityOverride = a.curMoves, false + a.curStats.speed = 400 - id * 50 + a.mon.stats.hp = 400 + a.curTypes = { "NORMAL" } + for _, k in ipairs({ "attack", "defense", "spatk", "spdef", "special" }) do + a.curStats[k] = 50 + end + end + end + if native then + b.playerParty = { at(b, 0).mon, Copy(at(b, 0).mon) } + b.foeParty = { at(b, 1).mon, Copy(at(b, 1).mon) } + b.playerParty[2].nickname, b.foeParty[2].nickname = "BENCH_P", "BENCH_E" + else + b.game.save.party = { at(b, 0).mon, Copy(at(b, 0).mon) } + b.enemyParty = { at(b, 1).mon, Copy(at(b, 1).mon) } + b.game.save.party[2].nickname, b.enemyParty[2].nickname = "BENCH_P", "BENCH_E" + end + if double then + if native then + b.playerParty = { at(b, 0).mon, at(b, 2).mon, Copy(at(b, 0).mon) } + b.foeParty = { at(b, 1).mon, at(b, 3).mon, Copy(at(b, 1).mon) } + at(b, 2).partyIndex, at(b, 3).partyIndex = 2, 2 + b.playerParty[3].nickname, b.foeParty[3].nickname = "BENCH_P", "BENCH_E" + else + b.game.save.party = { at(b, 0).mon, at(b, 2).mon, Copy(at(b, 0).mon) } + b.enemyParty = { at(b, 1).mon, at(b, 3).mon, Copy(at(b, 1).mon) } + b.game.save.party[3].nickname, b.enemyParty[3].nickname = "BENCH_P", "BENCH_E" + end + end + b.rng = function(lo, hi) + local value = hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function cast(b, from, to, slot) + local user, target = at(b, from), at(b, to) + if native then + require("src.core.game3.battle.engine").resolveMove( + from, + to, + user.mon.moves[slot], + slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function check(b, from, to, kind) + local user, target = at(b, from), at(b, to) + if native then + local context = require("src.core.game3.battle.engine").newContext( + user, + target, + 33, + 3, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + context.move.accuracy = 0 + return context:accuracyCheck(kind or "normal", false) + end + local move = Copy(f.data.moves[f.ids.TACKLE]) + move.accuracy = 0 + return b:accuracyRoll(move, user, target) + end + local function tick(b) + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function timer(b, id) + local a = at(b, id) + return native and a.expLockedOn or a.lockOnTurns + end + local function switch(b, id, pass) + local old = at(b, id) + local slot = b.double and 3 or 2 + if id == 3 then + old.mon.hp = 0 + end + if native then + return require("src.core.game3.battle.engine").performSwitch( + b, + require("src.core.game3.battle.adapter").new(b), + id, + slot, + { batonPass = pass, reason = "switch" } + ) + elseif id % 2 == 0 then + if pass then + old.batonPass = {} + end + b:switchPlayerInto(id, b.game.save.party[slot]) + elseif id == 1 then + b:executeAction(old, at(b, 0), { special = "aiSwitch", index = slot }) + else + b:placeBattler(id, nil) + b.pendingReplacements = { id } + b:fillEmptySlots() + end + return at(b, id) + end + local function snapshot(b, label, extra) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + if a then + local side = native and a.expLockedOnBy or a.lockOnBy + if not native then + if a.lockOnBy == true then + side = "player" + elseif a.lockOnBy == false then + side = "enemy" + else + side = nil + end + end + all[#all + 1] = { + id = id, + name = a.mon.nickname, + hp = a.mon.hp, + turns = native and a.expLockedOn or a.lockOnTurns, + ownerSide = side, + ownerId = native and a.expLockedOnById or a.lockOnByPos, + pp = native and a.mon.pp + or { a.curMoves[1].pp, a.curMoves[2].pp, a.curMoves[3].pp, a.curMoves[4].pp }, + } + end + end + rows[#rows + 1] = { + label = label, + actors = all, + calls = Copy(calls), + draws = Copy(draws), + moves = Copy(moves), + damageCalls = damageCalls, + extra = extra, + } + end + for _, double in ipairs({ false, true }) do + for owner = 0, double and 3 or 1 do + for _, slot in ipairs({ 1, 2 }) do + for _, mode in ipairs({ "delegate", "deny", "force" }) do + policy = mode + calls, draws, moves = {}, {}, {} + local b = fresh(double) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, slot) + T.eq(timer(b, victim), mode ~= "deny" and 2 or nil, "producer stores two residual phases") + local label = table.concat({ double and "double" or "single", owner, slot, mode }, "/") + snapshot(b, label .. "/cast") + local a = check(b, owner, victim) + local c = check(b, owner, victim) + T.eq(a, c, "native guarantee is not consumed by an accuracy query") + T.eq(a, mode ~= "deny", "only successful native producers guarantee their owner") + snapshot(b, label .. "/repeated", { a, c }) + if double then + snapshot(b, label .. "/partner", { check(b, (owner + 2) % 4, victim) }) + end + tick(b) + T.eq(timer(b, victim), mode ~= "deny" and 1 or nil, "first residual leaves one phase") + snapshot(b, label .. "/first-residual", { check(b, owner, victim) }) + tick(b) + T.eq(timer(b, victim), nil, "second residual expires target-owned binding") + snapshot(b, label .. "/second-residual", { check(b, owner, victim) }) + end + end + end + end + policy = "delegate" + for _, double in ipairs({ false, true }) do + for owner = 0, double and 3 or 1 do + for _, kind in + ipairs( + double and { "owner", "target", "partner", "baton-target" } or { "owner", "target", "baton-target" } + ) + do + if kind ~= "baton-target" or owner % 2 == 1 then + calls, draws, moves = {}, {}, {} + local b = fresh(double) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + local id = kind == "owner" and owner or kind == "partner" and (owner + 2) % 4 or victim + T.check(switch(b, id, kind == "baton-target") ~= nil, "real switch installs a replacement") + local hit = check(b, owner, victim) + T.eq(hit, kind == "partner" or kind == "baton-target", "ownership follows native switch policy") + snapshot(b, "switch/" .. tostring(double) .. "/" .. owner .. "/" .. kind, { hit }) + end + end + end + end + for owner = 0, 3 do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + tick(b) + cast(b, owner, victim, 2) + T.eq(timer(b, victim), 2, "same owner refreshes the duration") + cast(b, (owner + 2) % 4, victim, 1) + T.eq(check(b, owner, victim), false, "reapplication transfers ownership from the old owner") + T.eq(check(b, (owner + 2) % 4, victim), true, "new owner receives the guarantee") + snapshot(b, "refresh/" .. owner) + end + for _, kind in ipairs({ + "locked", + "ordinary", + "expired", + "wrong-owner", + "protect", + "air", + "lower-level", + "sturdy", + }) do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local user, target = at(b, 1), at(b, 0) + if kind ~= "ordinary" then + cast(b, kind == "wrong-owner" and 3 or 1, 0, 1) + end + if kind == "expired" then + tick(b) + tick(b) + end + if kind == "protect" then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if kind == "air" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "lower-level" then + target.mon.level = user.mon.level + 1 + end + if kind == "sturdy" then + if native then + target.ability, target.mon.ability = 5, 5 + else + target.abilityOverride = "STURDY" + end + end + cast(b, 1, 0, 4) + snapshot(b, "ohko/" .. kind, { hp = target.mon.hp }) + end + for owner = 0, 3 do + for _, protected in ipairs({ false, true }) do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + if native then + at(b, victim).expProtected = protected + else + at(b, victim).protecting = protected + end + cast(b, owner, victim, 3) + cast(b, owner, victim, 3) + T.eq(timer(b, victim), 2, "two real damaging moves do not consume the timed state") + T.eq(damageCalls, protected and 0 or 2, "real damage hooks respect Protect before native guarantee") + snapshot(b, "damaging/" .. owner .. "/" .. tostring(protected)) + end + end + for _, guard in ipairs({ "substitute", "substitute-zero", "protect", "air", "underground", "underwater" }) do + policy = "delegate" + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local target = at(b, 0) + target.substituteHP = guard == "substitute" and 40 or guard == "substitute-zero" and 0 or nil + if native then + target.expProtected = guard == "protect" or nil + target.semiInvulnerable = ({ air = "ON_AIR", underground = "UNDERGROUND", underwater = "UNDERWATER" })[guard] + else + target.protecting = guard == "protect" or nil + target.hiddenAs = ({ air = "air", underground = "underground", underwater = "underwater" })[guard] + target.invulnerable = target.hiddenAs ~= nil + end + cast(b, 1, 0, 1) + snapshot(b, "guard/" .. guard) + end + stopMoves() + stopDamage() + stopCrit() + stop() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index df392120e..29043d467 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #215 plus [native_gba_party]1. +-- Legacy hash audited against #214; native hash is #216 with command revision 2. local LEGACY = "e33d251961f2a41d" -local NATIVE = "2fd444270c3d90db" +local NATIVE = "cf73a8e083fb8a54" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 1 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 2 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_lock_on.lua b/tests/engine/gen3_lock_on.lua new file mode 100644 index 000000000..98af90ae8 --- /dev/null +++ b/tests/engine/gen3_lock_on.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.lock_on").run(".", "gen2", version) +end +require("tests.modkit").finish("native target-owned Lock-On lifecycle") diff --git a/tests/engine/gen3_lock_on_link.lua b/tests/engine/gen3_lock_on_link.lua new file mode 100644 index 000000000..2c0a6e476 --- /dev/null +++ b/tests/engine/gen3_lock_on_link.lua @@ -0,0 +1,128 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local L, Copy = require("src.battle.Gen3LockOn"), require("src.mods.Merge").deepCopy +local target = { lockOnTurns = 2, lockOnBy = true, lockOnByPos = 0 } +T.eq( + L.signature(target, "host"), + L.signature({ lockOnTurns = 2, lockOnBy = false, lockOnByPos = 1 }, "guest"), + "native ownership hashes in canonical host coordinates" +) +for _, key in ipairs({ "lockOnTurns", "lockOnBy", "lockOnByPos" }) do + local changed = Copy(target) + if key == "lockOnBy" then + changed[key] = false + else + changed[key] = target[key] + 1 + end + T.neq(L.signature(changed), L.signature(target), "native active signature detects " .. key) +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.lock_on").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local oldRules = Copy(current) + oldRules.nativeBattleRevision = 1 + T.eq( + H.checkCompat(current, oldRules), + "subset", + "older native rules cannot battle even with the same custom fingerprint" + ) + T.eq(H.checkCompat(oldRules, current), "subset", "native rule gate is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = oldRules }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses older native rule revision") + T.eq(state.noticeExits, true, "old native rules cannot enter battle setup") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef = 50, 50 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "A", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "B", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.LOCK_ON, pp = 20 }, { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + end + local P, Net, Link = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = Net.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + Link.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "B", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + Link.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "A", seed = 327, verdict = "full", strict = true } + ) + ) + local ra, rb, seenA, seenB = nil, nil, {}, {} + a.onFinish = function(v) + ra = v + end + b.onFinish = function(v) + rb = v + end + ga.stack:push(a) + gb.stack:push(b) + local function capture(battle, seen) + for turn in pairs(battle.localHashes) do + if not seen[turn] then + seen[turn] = { player = battle.player.lockOnTurns, enemy = battle.enemy.lockOnTurns } + end + end + end + local frames = 0 + while (not ra or not rb) and frames < 60000 do + frames = frames + 1 + require("src.core.Input").pressed = { a = true } + -- Drive the real controller: aim once, then select the damaging move. + a.moveIndex = (a.turnCount or 0) > 0 and 2 or 1 + b.moveIndex = (b.turnCount or 0) > 0 and 2 or 1 + ga.stack:update(1 / 60) + capture(a, seenA) + gb.stack:update(1 / 60) + capture(b, seenB) + end + T.check(ra ~= nil and rb ~= nil, "complete native sure-hit link mirror finishes") + T.check(seenA[1] ~= nil and seenB[1] ~= nil, "first native turn is captured on both peers") + if seenA[1] and seenB[1] then + T.eq(seenA[1].player, 1, "host mark survives the first residual") + T.eq(seenA[1].enemy, 1, "remote mark survives the first residual") + T.same(seenA[1], seenB[1], "both actual link roles retain their timed bindings") + end + if seenA[2] and seenB[2] then + T.eq(seenA[2].player, nil, "host mark expires after the next turn") + T.eq(seenA[2].enemy, nil, "remote mark expires after the next turn") + T.same(seenA[2], seenB[2], "both actual roles expire the binding together") + else + T.check(false, "second turn must complete") + end + local compared = 0 + for turn, hash in pairs(a.localHashes) do + if b.localHashes[turn] then + compared = compared + 1 + T.eq(hash, b.localHashes[turn], "complete native turn hash agrees") + T.same(a.localParts[turn], b.localParts[turn], "native owner/duration components agree") + end + end + T.check(compared >= 2, "native mirror compares multiple full turns") + f.close() +end +require("src.core.GameVersion").set("red") +local old = { + player = { name = "PLAYER", isPlayer = true, mon = { hp = 10 } }, + enemy = { name = "ENEMY", isPlayer = false, mon = { hp = 10 } }, +} +require("src.battle.Gen3MoveEffects").primary.LOCK_ON_EFFECT(old, old.player, old.enemy) +T.eq(old.player.lockedOn, old.enemy, "older games keep their existing one-use pointer producer") +T.eq(old.enemy.lockOnTurns, nil, "older games do not acquire a native timer") +T.finish("native Lock-On link mirrors and ownership signatures") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 039d95e58..1c06a3e42 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index c29a70397..c80055602 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "party_codec_hooks" then +if mode == "lock_on" then + rows = dofile(root .. "/tests/enemy_actions/lock_on.lua").run(root, backend, version) +elseif mode == "party_codec_hooks" then rows = dofile(root .. "/tests/enemy_actions/party_codec_hooks.lua").run(root, backend, version) elseif mode == "party_codec" then rows = dofile(root .. "/tests/enemy_actions/party_codec.lua").run(root, backend, version) From 63e29a8dc873354e9bafecc66d7b8180c4ab8dd8 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 39/90] Add portable held-item reads and native cross-engine contracts --- .github/workflows/held-item-contract.yml | 45 +++ docs/api3-portability.md | 2 + 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 | 2 + 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 +++++ 24 files changed, 1564 insertions(+), 64 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 a36ae5f8c..da9eaef9b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -149,3 +149,5 @@ see [Gen-3 critical/type rounding](gen3-critical-order.md). See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for `shiny.roll`, `gender.roll`, native DV/PID semantics and shared contracts. + +[Portable held-item reads](portable-held-items.md) documents shared names and native consumers. 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 2003754ca..a232cd606 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -979,7 +979,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 @@ -991,7 +991,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, @@ -1049,6 +1049,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 @@ -1370,7 +1371,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1432,7 +1433,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1537,9 +1538,9 @@ function BattleState.newPokedudeDemo(game, lesson, rng) 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 = enemyParty - 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" @@ -1726,9 +1727,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1820,7 +1821,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 @@ -1831,7 +1832,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(game.save.party, lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) 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 @@ -1847,7 +1848,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, @@ -1905,7 +1906,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 @@ -2141,7 +2142,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 @@ -3410,7 +3411,10 @@ function BattleState:placeBattler(pos, battler) require("src.battle.Gen3LockOn").replacing(self, pos, self.sides[side].battlers[flank], battler) end 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 @@ -3535,6 +3539,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? @@ -5755,7 +5762,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 @@ -6165,6 +6172,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 @@ -6236,7 +6246,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) @@ -7592,7 +7605,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() @@ -8612,7 +8625,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) @@ -8654,7 +8667,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 @@ -8951,7 +8964,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() @@ -8989,7 +9002,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", { @@ -9362,7 +9375,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 a283668ed..c02e8b182 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -174,7 +174,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 cdc7592ab..a72c98314 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 25b606ea6..b7a808c20 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -244,6 +244,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" or kind == "identitySpecies" then local out = { native = copy(raw), name = raw.name } local registryKind = (kind == "species" or kind == "identitySpecies") and "pokemon" @@ -317,6 +324,7 @@ function R.new(loader, name, generation, api3) 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 end @@ -532,7 +540,20 @@ function R.new(loader, name, generation, api3) visiting[e] = true local raw, before = e.raw, e.before kind = e.kind - if kind == "rollIvs" 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 == "rollIvs" then local aliases = { hp = "hp", atk = "attack", def = "defense", spe = "speed", spa = "spatk", spd = "spdef" } for k, v in pairs(value) do diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 11b3a6fd6..c49a78eac 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -33,6 +33,7 @@ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_men add("hooks", "12", "ui.pc.items") add("hooks", "45", "battle.status_hud_visible") add("hooks", "23", "shiny.roll gender.roll") +add("hooks", "23", "held_item.trigger") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index e2caf9179..46f485fd6 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,6 +17,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([[ + held_item.trigger shiny.roll gender.roll 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 @@ -43,6 +44,7 @@ C.events = names([[ local positions = { ["shiny.roll"] = { "context" }, ["gender.roll"] = { "context" }, + ["held_item.trigger"] = { "context" }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, ["battle.crit"] = { "context" }, 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 49472aa6d5a0d09ca5180fbe7570672119afcdc3 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 40/90] 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 | 192 ++++++++++------ src/battle/EffectRegistry.lua | 4 +- src/battle/GbaItemSchedule.lua | 113 +++++++++ src/battle/HoldItems.lua | 31 ++- src/battle/MoveEffects.lua | 1 + src/battle/Status.lua | 21 +- src/battle/Weather.lua | 21 +- 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 +- 18 files changed, 798 insertions(+), 116 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 da9eaef9b..bcff38735 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -150,4 +150,4 @@ see [Gen-3 critical/type rounding](gen3-critical-order.md). See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for `shiny.roll`, `gender.roll`, native DV/PID semantics and shared contracts. -[Portable held-item reads](portable-held-items.md) documents shared names and native consumers. +[Portable held-item reads](portable-held-items.md) documents shared names and native consumers and GBA 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 a232cd606..32f0beb2e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5645,6 +5645,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" if GameVersion.generation() == 3 then @@ -5890,8 +5897,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 @@ -6287,7 +6294,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"))) @@ -6303,7 +6311,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, @@ -6335,6 +6344,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 @@ -6425,6 +6437,7 @@ function BattleState:endOfTurn() self:holdItemTrigger(self.enemy, "turn") end self:gen3CountersTick() + end self:tickTokens() self:roamerFlees() -- BATTLE ARENA: the third turn is over and both are standing -- judgment @@ -6454,78 +6467,96 @@ 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 - if GameVersion.isGen3() then require("src.battle.Gen3LockOn").tick(self, b) end - -- 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 + if GameVersion.isGen3() then require("src.battle.Gen3LockOn").tick(self, b) end + -- 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 @@ -7712,7 +7743,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 @@ -7988,7 +8029,8 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) +local function performMove(battle, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + local self = battle local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8246,6 +8288,22 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end end +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + if not GameVersion.isGen3() then + return performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + 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, spreadContinuation) + 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 2383e1fc4..fcf61d46e 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -481,6 +481,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 @@ -489,7 +491,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 a72c98314..8ee2f2fb7 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,14 +411,19 @@ 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 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 ee183de9e..b7af98f68 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -317,13 +317,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) @@ -337,7 +354,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/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 103bc697c1f2430b1ccc81ccb9e710881bfc625c 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 41/90] 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 b7af98f68..17fd584a7 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -322,7 +322,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 a8e232900..35fa60f53 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 c27c37a667e8a40613c22506448ab4886fc9b431 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 42/90] 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 7e90de3e90207203cb2af1f5fb2baebd5afae1a9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:02:43 +0100 Subject: [PATCH 43/90] Implement native GBA Baton Pass choices and link continuation --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-baton-pass.md | 51 +++ src/battle/BattleState.lua | 27 +- src/battle/Gen3BatonPass.lua | 385 +++++++++++++++++ src/battle/Gen3MoveEffects.lua | 7 +- src/link/Fingerprint.lua | 2 +- src/link/Gen3BatonChoice.lua | 93 +++++ src/link/LinkBattle.lua | 25 +- src/mods/UIActions.lua | 2 +- src/ui/Gen3PartyMenu.lua | 7 +- src/ui/PartyMenu.lua | 4 + src/ui/Screens.lua | 1 + src/ui/UICommands.lua | 4 +- tests/enemy_actions/baton_pass.lua | 408 +++++++++++++++++++ tests/engine/gen3_baton_pass.lua | 5 + tests/engine/gen3_baton_pass_link.lua | 170 ++++++++ tests/engine/gen3_baton_pass_packets.lua | 101 +++++ tests/engine/gen3_baton_pass_ui.lua | 96 +++++ tests/engine/gen3_battle_protocol.lua | 6 +- tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 21 files changed, 1383 insertions(+), 18 deletions(-) create mode 100644 docs/native-gen3-baton-pass.md create mode 100644 src/battle/Gen3BatonPass.lua create mode 100644 src/link/Gen3BatonChoice.lua create mode 100644 tests/enemy_actions/baton_pass.lua create mode 100644 tests/engine/gen3_baton_pass.lua create mode 100644 tests/engine/gen3_baton_pass_link.lua create mode 100644 tests/engine/gen3_baton_pass_packets.lua create mode 100644 tests/engine/gen3_baton_pass_ui.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index ada3ee457..d1e2c1325 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -24,6 +24,7 @@ on: - 'tests/engine/gen3_party_transport.lua' - 'tests/engine/gen3_lock_on.lua' - 'tests/engine/gen3_lock_on_link.lua' + - 'tests/engine/gen3_baton_pass*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-baton-pass.md b/docs/native-gen3-baton-pass.md new file mode 100644 index 000000000..33a43d971 --- /dev/null +++ b/docs/native-gen3-baton-pass.md @@ -0,0 +1,51 @@ +# Native GBA Baton Pass continuation + +This development branch stacks on #217 and the existing held-item phase work +in #165/#166. Gen1 remains an unchanged, read-only reference. + +The native move selects a healthy, non-egg replacement from its owner's +party, transfers stages and the native Baton Pass volatiles, emits the public +switch event, and applies switch-in effects. NPC selection follows the native +type heuristic, including its bounded RNG call in doubles. Player selection +uses the actual generation-specific party screen and cannot be cancelled. +Queued attacks resolve against the new occupant rather than the retired donor. + +Linked players transmit their actual mid-turn selection using a validated +turn/order/owner packet. Early reliable-ordered packets are buffered; identical +duplicates are idempotent, conflicting or invalid choices reach the controller +failure path. Spectators consume the same choices without opening a personal +picker. Disconnects remain serviced while the party screen owns input. +The picker uses clamped link-party copies rather than the persistent save party. + +Native command revision 3 prevents older rules from entering a full GBA battle. +The raw fixture changes from `cf73a8e083fb8a54` to `c2a03195bc4588f9`, audited +with the unchanged #217 hasher and only the explicit revision token. Native +party revision 1 and older-generation fingerprint goldens remain unchanged. + +Focused commands, using the maintained pinned LuaJIT described in the contract +workflow: + +```sh +LUA=/absolute/path/to/luajit +"$LUA" tests/engine/gen3_baton_pass.lua +"$LUA" tests/engine/gen3_baton_pass_ui.lua +"$LUA" tests/engine/gen3_baton_pass_link.lua +"$LUA" tests/engine/gen3_baton_pass_packets.lua +"$LUA" tests/engine/gen3_battle_protocol.lua +"$LUA" tests/engine/ui_actions.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /absolute/path/to/clean-reference --gen2 . --lua "$LUA" \ + --report /absolute/path/to/report.json +``` + +The shared fixtures execute both real move resolvers and the real NPC selector. +They retain all RNG calls, PP/HP, stages, volatile state, seed-source identities +and public switch events. Reference names live on `mon.nickname`; reference +infatuation has a separate active flag and owner pointer, whereas the host's +pointer is the active state. These are explicit schema mappings. Original +failed comparison reports are retained locally. + +This is work in progress: full feature regression, clean-commit comparisons, +imported-record smoke, combined-fork integration and PR publication are still +pending. CPU fixtures do not certify GPU animation, pixel equality, a complete +playthrough, global RNG/VBlank scheduling or all native side/volatile producers. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 32f0beb2e..fccc5ee08 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5132,6 +5132,9 @@ function BattleState:effectRecord(effect) if GameVersion.isGen3() and effect == "OHKO_EFFECT" then return require("src.battle.Gen3OHKO").record(record, MoveEffects.RECORDS.OHKO_EFFECT) end + if GameVersion.isGen3() and effect == "BATON_PASS_EFFECT" then + return require("src.battle.Gen3BatonPass").record(record, MoveEffects.RECORDS.BATON_PASS_EFFECT) + end return record end @@ -5871,7 +5874,10 @@ function BattleState:abilitySwitchIn(battler) -- An eighth of maximum HP, and a FLYING or LEVITATing Pokemon never lands -- on them at all. local side = self.sideOf and self:sideOf(battler) - if side and side.spikes then + local layers = side and side.spikes and (tonumber(side.spikes) or 1) or 0 + if layers ~= layers then layers = 0 end + layers = GameVersion.isGen3() and math.max(0, math.min(3, math.floor(layers))) or 1 + if side and side.spikes and layers > 0 then local grounded = Abilities.of(battler) ~= "LEVITATE" for _, t in ipairs(battler.curTypes or {}) do if t == "FLYING" then grounded = false end @@ -5879,7 +5885,8 @@ function BattleState:abilitySwitchIn(battler) if grounded then local max = (battler.mon.stats and battler.mon.stats.hp) or battler.mon.hp self:sayNext(Strings("%s is hurt\nby SPIKES!", displayName(battler))) - self:applyDamage(battler, math.max(1, math.floor(max / 8))) + local divisor = ({ 8, 6, 4 })[math.max(1, layers)] + self:applyDamage(battler, math.max(1, math.floor(max / divisor))) self:drainNext() if battler.mon.hp <= 0 then self:onFaint(battler) @@ -5898,7 +5905,7 @@ function BattleState:abilitySwitchIn(battler) self:abilityForecast(battler) local act = Abilities.onSwitchIn(battler) if act then self:abilitySwitchInEffect(battler, act) end - if GameVersion.isGen3() then require("src.battle.GbaItemSchedule").afterAction(self) end + if GameVersion.isGen3() and not self._gen3BatonSwitching then require("src.battle.GbaItemSchedule").afterAction(self) end end -- The visible half of a switch-in ability, split out so TRACE can run the @@ -7557,6 +7564,12 @@ function BattleState:executeAction(user, target, action) -- faint cases are already covered by the HP guard below (#441) if self.result then return end if not (user and user.mon) then return end + if GameVersion.isGen3() then + if user.position ~= nil and self:battlerAt(user.position) ~= user then return end + if target and target.position ~= nil and self:battlerAt(target.position) ~= target then + target = self:battlerAt(target.position) or self:defaultTargetFor(user) + end + end if user.mon.hp <= 0 then return end if not action then return end local explicitTarget = false @@ -7752,7 +7765,7 @@ function BattleState:executeAction(user, target, action) 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 + if not isSwitch and not self._gen3BatonPending then Schedule.afterAction(self) end end -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) self:actNext(function() self:syncShownStatus() end) @@ -8173,7 +8186,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end - user.lastMove = move.id + if not (record and record.gbaBatonPass or GameVersion.isGen3() and move.gen3Effect == 127) then user.lastMove = move.id end -- charge moves: first turn just charges; the text comes from the move -- record (chargeText) and the invulnerability from semiInvulnerable, @@ -8299,9 +8312,9 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima self._gbaMoveFrame = frame if self._gbaActionFrame then self._gbaActionFrame.moved = true end performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) - if not frame.finished and not frame.called then Schedule.moveEnd(self) end + if not frame.finished and not frame.called and not frame.suspended then Schedule.moveEnd(self) end self._gbaMoveFrame = parent - if not isCalled and not parent and not self._gbaActionFrame then Schedule.afterAction(self) end + if not isCalled and not parent and not self._gbaActionFrame and not frame.suspended then Schedule.afterAction(self) end end function BattleState:continueTrapping(user, target) diff --git a/src/battle/Gen3BatonPass.lua b/src/battle/Gen3BatonPass.lua new file mode 100644 index 000000000..15ec719e8 --- /dev/null +++ b/src/battle/Gen3BatonPass.lua @@ -0,0 +1,385 @@ +-- Native Baton Pass owns a resumable choice, not an ordinary switch action. +local M = {} +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") +local Party = require("src.pokemon.Party") +local cache = setmetatable({}, { __mode = "k" }) +local TRANSFER = { + "confusedTurns", + "focusEnergy", + "substituteHP", + "leechSeeded", + "leechSeedSource", + "ingrained", + "perishTurns", + "cursed", + "trapped", + "escapePrevention", +} +function M.signature(user, role) + local out = {} + for _, key in ipairs(TRANSFER) do + local value = user[key] + if key == "leechSeedSource" and type(value) == "table" then + value = value.position + if role == "guest" and type(value) == "number" then + value = value % 2 == 0 and value + 1 or value - 1 + end + end + out[#out + 1] = tostring(value) + end + return ":baton=" .. table.concat(out, ",") +end +function M.party(battle, user) + if battle._gen3BatonParty then + return battle:_gen3BatonParty(user) + end + return battle:benchFor(user.isPlayer, user.position) +end +function M.candidates(battle, user) + local party, excluded = M.party(battle, user), {} + for _, other in battle:activeBattlers() do + excluded[other.mon] = true + end + for pos, action in pairs(battle.pendingActions or {}) do + if pos ~= user.position and type(action) == "table" then + if action.special == "playerSwitch" and action.mon then + excluded[action.mon] = true + end + if action.special == "aiSwitch" and action.index then + local other = battle:battlerAt(pos) + local mon = other and M.party(battle, other)[action.index] + if mon then + excluded[mon] = true + end + end + end + end + for pos, index in pairs(battle.monToSwitchInto or {}) do + local other = battle:battlerAt(pos) + if pos ~= user.position and other then + local mon = M.party(battle, other)[index] + if mon then + excluded[mon] = true + end + end + end + local out = {} + for i, mon in ipairs(party) do + if not excluded[mon] and (mon.hp or 0) > 0 and not Party.isEgg(mon) then + out[#out + 1] = i + end + end + return out, party +end +local function typeRows(battle, attack, defending, value, damage) + for _, row in ipairs((battle.data.type_chart or {}).matchups or {}) do + if row.attacker == attack then + for _, kind in ipairs(defending) do + if row.defender == kind then + value = math.floor(value * row.multiplier / 10) + if damage and value == 0 and row.multiplier ~= 0 then + value = 1 + end + break + end + end + end + end + return value +end +function M.enemyChoice(battle, user, candidates, party) + local pos = user.position or (user.isPlayer and 0 or 1) + local pending = battle.monToSwitchInto or {} + if pending[pos] then + return pending[pos] + end + local opposite = pos % 2 == 0 and pos + 1 or pos - 1 + if battle:isDouble() then + opposite = (math.floor(battle.rng(0, 65535) / 2) % 2) * 2 + (1 - pos % 2) + if not battle:battlerAt(opposite) then + opposite = opposite < 2 and opposite + 2 or opposite - 2 + end + end + local foe = battle:battlerAt(opposite) + if not foe then + return nil + end + local types = foe.curTypes or {} + local t1, t2 = types[1], types[2] or types[1] + local allowed, invalid = {}, {} + for _, index in ipairs(candidates) do + allowed[index] = true + end + while true do + local best, amount = nil, 0 + for i = 1, 6 do + local mon = party[i] + if allowed[i] and not invalid[i] then + local def = battle.data.pokemon[mon.species] or {} + local value = typeRows(battle, t1, def.types or {}, 10) + value = typeRows(battle, t2, def.types or {}, value) + if amount < value then + best, amount = i, value + end + end + end + if not best then + break + end + for _, inst in ipairs(party[best].moves or {}) do + local move = battle.data.moves[inst.id] + if move and typeRows(battle, move.type, types, 10) > 10 then + return best + end + end + invalid[best] = true + end + local best, amount = nil, 0 + for i = 1, 6 do + if allowed[i] then + for _, inst in ipairs(party[i].moves or {}) do + local move = battle.data.moves[inst.id] + if move and (tonumber(move.power) or 0) ~= 1 then + local value = 2 + for _, kind in ipairs(user.curTypes or {}) do + if move.type == kind then + value = math.floor(value * 15 / 10) + break + end + end + value = typeRows(battle, move.type, types, value, true) + if amount < value then + best, amount = i, value + end + end + end + end + end + return best +end +function M.switch(battle, user, index, party) + local mon = party[index] + local pos = user.position or (user.isPlayer and 0 or 1) + battle:restoreMimicked(user) + battle:abilitySwitchOut(user) + local State = require("src.battle.BattleState") + local incoming = State.makeBattler( + battle.data, + mon, + user.isPlayer, + user.isPlayer and not user.ally and battle.game.save or nil, + battle + ) + incoming.ally, incoming.partyIndex = user.ally, index + for key, value in pairs(user.stages or {}) do + incoming.stages[key] = value + end + for _, key in ipairs(TRANSFER) do + incoming[key] = user[key] + end + user.batonPass = {} + battle:placeBattler(pos, incoming) + user.batonPass, user.wantsSwitch = nil, nil + for _, other in battle:activeBattlers() do + if other ~= incoming then + if other.leechSeedSource == user then + other.leechSeedSource = incoming + end + if other.trappedBy == user then + other.trapped, other.trappedBy, other.escapePrevention = nil, nil, nil + end + if other.infatuated == user or not battle:isDouble() then + other.infatuated = nil + end + end + end + battle:syncSides() + Runtime.emit("battle.battler_switched", { + battle = battle, + side = battle:sideOf(incoming), + battler = incoming, + previous = user, + battlerId = pos, + partyIndex = index, + reason = "baton_pass", + }) + if incoming.isPlayer then + battle:markParticipant() + end + battle:sayNext( + incoming.isPlayer and battle:sendOutText(incoming.name) + or Strings("%s sent\nout %s!", battle.opponentName or battle:trainerLabel_(), incoming.name) + ) + battle:actNext(function() + battle:startGrowIn(incoming) + require("src.core.Sound").playCry(battle.data, mon.species) + end) + -- State changes precede the following action; their presentation stays queued. + local before = battle._gen3BatonSwitching + battle._gen3BatonSwitching = true + battle:abilitySwitchIn(incoming) + battle._gen3BatonSwitching = before + if mon.hp > 0 then + require("src.battle.HeldItems").onEntry(battle, incoming) + end + return incoming +end +function M.select(battle, pending, index) + if battle._gen3BatonPending ~= pending or pending.choice or battle.result then + return false + end + if battle:battlerAt(pending.user.position) ~= pending.user then + return false + end + local current, party = M.candidates(battle, pending.user) + local valid = false + for _, i in ipairs(current) do + if i == index and party[i] == pending.offered[i] then + valid = true + end + end + if not valid then + return false + end + pending.choice = index + if pending.onSelect then + pending.onSelect(index) + end + return true +end +function M.await(battle, pending) + if battle._gen3BatonPending ~= pending then + return + end + if battle.result then + battle._gen3BatonPending = nil + return + end + if pending.choice then + battle._gen3BatonPending = nil + pending.ctx.user = M.switch(battle, pending.user, pending.choice, pending.party) + if pending.frame then + pending.frame.finished = true + require("src.battle.GbaItemSchedule").moveEnd(battle) + require("src.battle.GbaItemSchedule").afterAction(battle) + end + else + battle:actNext(function() + M.await(battle, pending) + end) + end +end +function M.picker(battle, pending) + return battle:buildScreen("PartyMenu", { + battle = battle, + party = pending.party, + forceSwitch = true, + keepOpen = true, + noCancel = true, + onUpdate = function(screen) + if battle._gen3BatonPoll then + battle:_gen3BatonPoll() + end + if battle.result or battle._gen3BatonPending ~= pending then + screen.closed = true + if battle.game.stack:top() == screen then + battle.game.stack:pop() + end + if battle._gen3BatonPending == pending then + battle._gen3BatonPending = nil + end + end + end, + onSwitch = function(mon, screen) + local index + for i, candidate in ipairs(pending.party) do + if candidate == mon then + index = i + break + end + end + if M.select(battle, pending, index) then + screen.closed = true + battle.game.stack:pop() + elseif screen.say then + screen:say(Strings("Choose another\nPOKéMON.")) + end + end, + onCancel = function() + if battle._gen3BatonPending == pending and not pending.choice then + battle:uiNext(function() + return M.picker(battle, pending) + end) + end + end, + }) +end +function M.perform(ctx) + local battle, user = ctx.battle, ctx.user + local candidates, party = M.candidates(battle, user) + if #candidates == 0 then + battle:cancelMoveAnim() + battle:sayNext(Strings("But, it failed!")) + return + end + local offered = {} + for _, i in ipairs(candidates) do + offered[i] = party[i] + end + local pending = { user = user, party = party, candidates = candidates, offered = offered, ctx = ctx } + if type(battle.batonPassChooser) == "function" then + local ok, chosen = pcall(battle.batonPassChooser, user.isPlayer and "player" or "enemy", candidates) + local index = candidates[1] + for _, i in ipairs(candidates) do + if ok and i == tonumber(chosen) then + index = i + end + end + ctx.user = M.switch(battle, user, index, party) + return + end + if + not battle._gen3BatonRequest and (not user.isPlayer or user.ally or battle.interactiveChoices == false) + then + local index = not user.isPlayer and M.enemyChoice(battle, user, candidates, party) or nil + local chosen = candidates[1] + for _, i in ipairs(candidates) do + if index == i then + chosen = i + end + end + ctx.user = M.switch(battle, user, chosen, party) + return + end + battle._gen3BatonPending = pending + pending.frame = battle._gbaMoveFrame + if pending.frame then + pending.frame.suspended = true + end + local remote = battle._gen3BatonRequest and battle:_gen3BatonRequest(pending) or false + if not remote then + battle:uiNext(function() + return M.picker(battle, pending) + end) + end + battle:actNext(function() + M.await(battle, pending) + end) +end +function M.record(base, vanilla) + if not base or base.run ~= vanilla.run or base.perform then + return base + end + if cache[base] then + return cache[base] + end + local record = {} + for key, value in pairs(base) do + record[key] = value + end + record.kind, record.run, record.perform, record.gbaBatonPass = "full", nil, M.perform, true + cache[base] = record + return record +end +return M diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index f4a41fe68..a7d9ad24c 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -379,7 +379,12 @@ end -- BATON PASS: the stat stages and the volatiles go with the switch. The -- switch itself is BattleState's; this records what must survive it. -P.BATON_PASS_EFFECT = function(battle, user) +P.BATON_PASS_EFFECT = function(battle, user, target, move, moveInst) + if require("src.core.GameVersion").isGen3() then + require("src.battle.Gen3BatonPass").perform( + require("src.battle.EffectRegistry").makeCtx(battle,user,target,move,moveInst)) + return {} + end user.batonPass = { stages = user.stages, confusedTurns = user.confusedTurns, diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index a551cb592..29d75de5b 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 2 or nil + return require("src.core.GameVersion").isGen3() and 3 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/Gen3BatonChoice.lua b/src/link/Gen3BatonChoice.lua new file mode 100644 index 000000000..3638e98d1 --- /dev/null +++ b/src/link/Gen3BatonChoice.lua @@ -0,0 +1,93 @@ +-- One ordered, validated mid-turn replacement choice on each native peer. +local M = {} +local function integer(n, lo, hi) + return type(n) == "number" and n >= lo and n <= hi and n % 1 == 0 +end +local function key(turn, serial, actor) + return turn .. "/" .. serial .. "/" .. actor +end +function M.attach(battle, opts) + local state = { choices = {}, role = opts.role, spectator = opts.spectator, invalid = opts.invalid } + battle._gen3BatonLink = state + battle._gen3BatonParty = function(_, user) + return user.isPlayer and opts.mine or opts.theirs + end + local function actor(user) + return user.isPlayer and opts.role or (opts.role == "host" and "guest" or "host") + end + local function take(pending, packet) + if not require("src.battle.Gen3BatonPass").select(battle, pending, packet.index) then + state.invalid("invalid Baton Pass selection") + end + end + state.take = take + battle._gen3BatonRequest = function(_, pending) + local turn = battle.turnCount or 0 + if state.turn ~= turn then + state.turn, state.serial, state.choices = turn, 0, {} + end + state.serial = state.serial + 1 + pending.turn, pending.serial, pending.actor = turn, state.serial, actor(pending.user) + local remote = opts.spectator or not pending.user.isPlayer + if remote then + local packet = state.choices[key(turn, pending.serial, pending.actor)] + if packet then + take(pending, packet) + end + else + pending.onSelect = function(index) + opts.send({ + type = "baton_pass", + turn = turn, + serial = pending.serial, + actor = pending.actor, + index = index, + }) + end + end + return remote + end +end +function M.receive(battle, packet, sender) + local state = battle._gen3BatonLink + if not state or battle.result or battle.linkEnded then + return false + end + local turn = battle.turnCount or 0 + local owner = packet.actor + if + not integer(packet.turn, 1, math.max(1, turn)) + or packet.turn ~= turn + or not integer(packet.serial, 1, 64) + or not integer(packet.index, 1, 6) + or owner ~= "host" and owner ~= "guest" + or not state.spectator and owner == state.role + or state.spectator and sender ~= owner + then + state.invalid("invalid Baton Pass choice packet") + return false + end + if state.turn ~= turn then + state.turn, state.serial, state.choices = turn, 0, {} + end + local k = key(packet.turn, packet.serial, owner) + local previous = state.choices[k] + if previous then + if previous.index ~= packet.index then + state.invalid("conflicting Baton Pass choice") + end + return previous.index == packet.index + end + state.choices[k] = { index = packet.index } + local pending = battle._gen3BatonPending + if + pending + and pending.turn == packet.turn + and pending.serial == packet.serial + and pending.actor == owner + then + state.take(pending, packet) + end + return true +end +return M diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 30ce322f4..bee674572 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -162,6 +162,7 @@ local function activeStr(b, role) tostring(b.mon.status), tostring(b.mon.item), stageStr(b), ppStr(b.mon)) .. toxicSignature(b.mon) .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3LockOn").signature(b, role) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3BatonPass").signature(b, role) or "") end local function benchStr(party) @@ -394,6 +395,13 @@ function LinkBattle.new(game, net, opts) end end + if require("src.core.GameVersion").isGen3() then + require("src.link.Gen3BatonChoice").attach(self, { + role=role, mine=myParty, theirs=theirParty, send=send, + invalid=function() endAsDraw(self, Strings("Link choice mismatch!")) end, + }) + end + -- both actions in hand: resolve the turn identically on both machines local function resolveLockstep(s, myMsg, theirMsg) if myMsg.kind == "run" or theirMsg.kind == "run" then @@ -578,12 +586,14 @@ function LinkBattle.new(game, net, opts) end local baseUpdate = self.update - self.update = function(s, dt) + local function poll(s) net:update() for _, msg in ipairs(net:poll()) do if msg.type == "action" then s.remoteAction = msg tryResolve(s) + elseif msg.type == "baton_pass" then + require("src.link.Gen3BatonChoice").receive(s, msg) elseif msg.type == "hash" then s.remoteHashes[msg.turn or 0] = msg.value s.remoteParts[msg.turn or 0] = msg.parts @@ -612,6 +622,10 @@ function LinkBattle.new(game, net, opts) if net.closed and not s.linkEnded and not s.result then endAsDraw(s) end + end + self._gen3BatonPoll = poll + self.update = function(s, dt) + poll(s) -- Both early returns below skip baseUpdate, which is where the -- presentational clock normally advances -- so tick it here, in the same -- order baseUpdate would (fx, then the queue). Without this an @@ -781,6 +795,13 @@ function LinkBattle.newSpectator(game, net, opts) end) end + if require("src.core.GameVersion").isGen3() then + require("src.link.Gen3BatonChoice").attach(self, { + role="host", mine=hostParty, theirs=guestParty, spectator=true, + invalid=function() endSpectate(self, "Invalid Baton Pass choice.") end, + }) + end + local function resolveSpecTurn(s, hostMsg, guestMsg) if hostMsg.kind == "run" or guestMsg.kind == "run" then endSpectate(s, "The match ended.") @@ -888,6 +909,8 @@ function LinkBattle.newSpectator(game, net, opts) s.hostMsg, s.guestMsg = nil, nil resolveSpecTurn(s, h, g) end + elseif inner.type == "baton_pass" then + require("src.link.Gen3BatonChoice").receive(s, inner, msg.side) elseif inner.type == "bye" or inner.type == "forfeit" then if not s.result then endSpectate(s, "The match ended.") end end diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua index 533730961..63a046979 100644 --- a/src/mods/UIActions.lua +++ b/src/mods/UIActions.lua @@ -112,7 +112,7 @@ function Actions.new(provider, loader, builders) -- 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, + party = refs.party or 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, diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 855f2c805..c4ca3a8a7 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -135,6 +135,9 @@ function Gen3PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.battle = opts.battle self.forceSwitch = opts.forceSwitch + self.noCancel = opts.noCancel + self.onUpdate = opts.onUpdate + self._party = opts.party self.pickOnly = opts.pickOnly -- FireRed's POKé DUDE drives the ordinary party screen with synthetic -- input. The screen stays the cartridge one; only its input source changes. @@ -168,7 +171,7 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() return self._party 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 @@ -179,6 +182,7 @@ function Gen3PartyMenu:slots() return #self:party() + 1 end -- whatever the presentation left on top. Closing twice was always a bug; it -- simply never happened before. function Gen3PartyMenu:close() + if self.noCancel then return end if self.closed then return end self.closed = true self.game.stack:pop() @@ -1048,6 +1052,7 @@ function Gen3PartyMenu:cancel() end function Gen3PartyMenu:update(dt) + if self.onUpdate then self.onUpdate(self); if self.closed then return end end -- 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 -- clock, which is indistinguishable on screen and cannot drift. diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4b487d623..189442a43 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -437,6 +437,8 @@ function PartyMenu.new(game, opts) -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch + self.noCancel = opts.noCancel + self.onUpdate = opts.onUpdate self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies self.swapFrom = nil @@ -861,11 +863,13 @@ end function PartyMenu:cancel() if self.submenu then self.submenu = nil; return end + if self.noCancel then return end self.game.stack:pop() if self.onCancel then self.onCancel() end end function PartyMenu:update(dt) + if self.onUpdate then self.onUpdate(self); if self.closed then return end end -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..5c4108fbe 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -115,6 +115,7 @@ local GEN3_ALIASES = { PartyMenu = { id = "Gen3PartyMenu", opts = { onCancel = true, onSwitch = true, battle = true, forceSwitch = true, pickOnly = true, keepOpen = true, + party = true, noCancel = true, onUpdate = true, -- TM/HM teaching. Reported from play: "when teaching a move its falling -- back to the gen1 party menu instead of emerald". It was declined here -- until Emerald's screen could answer ABLE / NOT ABLE off the learnset, diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua index 437932a88..bfc9b7de3 100644 --- a/src/ui/UICommands.lua +++ b/src/ui/UICommands.lua @@ -93,7 +93,9 @@ function Commands.describe(game, screen, ids) end end if kind:match("^party_") then + if screen.noCancel then out.canBack = false end local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + refs.party = party for i, mon in ipairs(party or {}) do refs["mon:" .. i] = mon end @@ -116,7 +118,7 @@ function Commands.describe(game, screen, ids) if kind == "party_ds" and screen.select then row(out, "confirm", ids) end - row(out, "back", ids) + if not screen.noCancel then row(out, "back", ids) end end end elseif kind == "text" then diff --git a/tests/enemy_actions/baton_pass.lua b/tests/enemy_actions/baton_pass.lua new file mode 100644 index 000000000..b1fe985cc --- /dev/null +++ b/tests/enemy_actions/baton_pass.lua @@ -0,0 +1,408 @@ +local M = {} +local STATS = { "attack", "defense", "speed", "spatk", "spdef", "accuracy", "evasion" } +local FLAGS = { + { "confusionTurns", "confusedTurns" }, + { "focusEnergy", "focusEnergy" }, + { "substituteHP", "substituteHP" }, + { "expSeeded", "leechSeeded" }, + { "expIngrain", "ingrained" }, + { "expPerishTurns", "perishTurns" }, + { "expCursed", "cursed" }, + { "expTrapped", "trapped" }, + { "escapePrevention", "escapePrevention" }, + { "expLockedOn", "lockOnTurns" }, + { "expLockedOnById", "lockOnByPos" }, +} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local function configure(data, _, ids, rom, names) + local id = native and "BATON_PASS" or "MOVE_226" + ids.BATON_PASS, names[226] = id, "BATON_PASS" + if native then + local move = { + effect = 127, + type = 0, + power = 0, + accuracy = 0, + pp = 20, + target = 16, + flags = 0, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[226], rom[226], data.gen3Pokemon._moveNames[226] = move, move, "BATON_PASS" + data.gen3Pokemon._types[4] = { 0, 0 } + data.gen3Pokemon._abilities[4] = { 0, 0 } + else + data.moves[id] = { + id = id, + index = 226, + name = "BATON_PASS", + effect = "BATON_PASS_EFFECT", + gen3Effect = 127, + power = 0, + type = "NORMAL", + accuracy = 0, + pp = 20, + target = 16, + protectAffected = false, + } + data.pokemon.CHARMANDER.types = { "NORMAL" } + data.pokemon.CHARMANDER.abilities = {} + end + local Copy = require("src.mods.Merge").deepCopy + for _, row in ipairs({ { 1, "BULBASAUR", "GRASS", 12 }, { 7, "SQUIRTLE", "WATER", 11 } }) do + if native then + data.gen3Pokemon._types[row[1]] = { row[4], row[4] } + data.gen3Pokemon._abilities[row[1]] = { 0, 0 } + else + local mon = Copy(data.pokemon.CHARMANDER) + mon.id, mon.index, mon.dex, mon.types = row[2], row[1], row[1], { row[3] } + data.pokemon[row[2]] = mon + end + end + for _, row in ipairs({ + { 52, "EMBER", "FIRE", 10, 40 }, + { 55, "WATER_GUN", "WATER", 11, 40 }, + { 75, "RAZOR_LEAF", "GRASS", 12, 55 }, + { 49, "SONICBOOM", "NORMAL", 0, 1 }, + }) do + local moveId = native and row[2] or ("MOVE_%03d"):format(row[1]) + ids[row[2]], names[row[1]] = moveId, row[2] + if native then + local move = { + type = row[4], + power = row[5], + effect = 0, + accuracy = 100, + pp = 10, + secondaryChance = 0, + target = 0, + priority = 0, + flags = 0, + } + data.gen3Moves._rom[row[1]], rom[row[1]], data.gen3Pokemon._moveNames[row[1]] = move, move, row[2] + else + data.moves[moveId] = { + id = moveId, + index = row[1], + name = row[2], + type = row[3], + power = row[5], + pp = 10, + accuracy = 100, + effect = "NO_ADDITIONAL_EFFECT", + } + end + end + if not native then + for _, kind in ipairs({ "FIRE", "WATER", "GRASS" }) do + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = kind, defender = kind, multiplier = 5 } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + return f +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local events, draws = {}, {} + local stop = f.e.api.events:on("battle.battler_switched", function(e) + events[#events + 1] = { name = e.battler.mon.nickname, old = e.previous.mon.nickname, reason = e.reason } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = id < 2 and 1 or 2 } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local a = actor(Copy(at(b, id % 2).mon), id) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 226, 33 }, { 20, 10 }, { 20, 10 } + a.type1, a.type2, a.ability, a.mon.ability = 0, nil, 0, 0 + else + a.mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + end + end + local function party(side) + local out = { at(b, side).mon } + if double then + out[2] = at(b, side + 2).mon + end + for i = 1, 2 do + local m = Copy(out[1]) + m.nickname = "BENCH_" .. side .. "_" .. i + out[#out + 1] = m + end + return out + end + if native then + b.playerParty, b.foeParty = party(0), party(1) + else + b.game.save.party, b.enemyParty = party(0), party(1) + end + b.rng = function(lo, hi) + draws[#draws + 1] = { lo = lo, hi = hi, value = hi } + return hi + end + return b + end + local function view(a) + local stages, flags = {}, {} + for _, k in ipairs(STATS) do + local key = native and ({ spatk = "spAtk", spdef = "spDef" })[k] or k + stages[k] = (a.stages or {})[key or k] or 0 + end + for _, pair in ipairs(FLAGS) do + flags[pair[2]] = a[native and pair[1] or pair[2]] + end + return { + name = a.mon.nickname, + stages = stages, + flags = flags, + hp = a.mon.hp, + last = a.lastMove and (native and a.lastMove or f.data.moves[a.lastMove].index), + disabled = a.disabledTurns or a.expDisabledTurns, + taunt = a.tauntTurns or a.expTauntedTurns, + } + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "first", + "last", + "invalid", + "confusion", + "volatiles", + "none", + "interactive", + "egg", + "reserved", + "bindings", + "spikes1", + "spikes2", + "spikes3", + }) do + events, draws = {}, {} + local b = fresh(double) + local user = at(b, id) + local old = user + local party = native and (id % 2 == 0 and b.playerParty or b.foeParty) + or (id % 2 == 0 and b.game.save.party or b.enemyParty) + local offset = double and 2 or 1 + user.stages.attack, user.stages.defense = 2, -1 + user.lastMove = native and 33 or f.ids.TACKLE + if kind == "confusion" then + user[native and "confusionTurns" or "confusedTurns"] = 3 + end + if kind == "volatiles" then + for _, pair in ipairs(FLAGS) do + if pair[2] ~= "confusedTurns" and pair[2] ~= "lockOnByPos" then + user[native and pair[1] or pair[2]] = pair[2] == "perishTurns" and 3 + or pair[2] == "substituteHP" and 25 + or true + end + end + user[native and "expLockedOn" or "lockOnTurns"] = 2 + user[native and "expLockedOnBy" or "lockOnBy"] = native and "enemy" or false + user[native and "expLockedOnById" or "lockOnByPos"] = 1 + end + if kind == "none" then + for i = offset + 1, #party do + party[i].hp = 0 + end + end + if kind == "egg" then + party[offset + 1].isEgg = true + end + if kind == "reserved" and double then + b.monToSwitchInto = { [id < 2 and id + 2 or id - 2] = offset + 1 } + end + if kind == "bindings" then + local foe = at(b, id % 2 == 0 and 1 or 0) + user[native and "expSeeded" or "leechSeeded"] = true + user[native and "expSeedSource" or "leechSeedSource"] = foe + foe[native and "expSeedSource" or "leechSeedSource"] = user + foe[native and "expSeeded" or "leechSeeded"] = true + foe[native and "expInfatuatedWith" or "infatuated"] = user + if native then + foe.expInfatuated = true + end + end + local layers = tonumber(kind:match("^spikes(%d)$")) + if layers then + local side = native and (id % 2 == 0 and b.playerSide or b.enemySide) or b:sideOf(user) + side.spikes = layers + end + local selected = kind == "last" and #party or kind == "invalid" and 99 or offset + 1 + local interactive = kind == "interactive" + b.interactiveChoices = interactive + if not interactive then + b.batonPassChooser = function() + return selected + end + end + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 226, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, user, user.curMoves[1]) + end + local before = { actor = view(at(b, id)), pp = native and old.mon.pp[1] or old.mon.moves[1].pp } + if interactive and id % 2 == 0 then + if native then + require("src.core.game3.battle.engine").resumeChoice( + b, + require("src.core.game3.battle.adapter").new(b), + #party + ) + else + local pending = b._gen3BatonPending + T.check(pending ~= nil, "native move pauses for party choice") + T.eq(at(b, id), old, "no replacement before the choice") + T.check( + require("src.battle.Gen3BatonPass").select(b, pending, #party), + "explicit last eligible member is accepted" + ) + require("src.battle.Gen3BatonPass").await(b, pending) + end + end + T.eq(native and old.mon.pp[1] or old.mon.moves[1].pp, 19, "Baton Pass spends donor PP once") + if kind ~= "none" then + T.check(at(b, id) ~= old, "native Baton Pass replaces its own slot") + T.eq(at(b, id).stages.attack, 2, "attack stage is inherited") + T.eq(at(b, id).stages.defense, -1, "defense stage is inherited") + end + rows[#rows + 1] = { + double = double, + id = id, + kind = kind, + before = before, + after = view(at(b, id)), + events = Copy(events), + draws = Copy(draws), + oldLast = view(old).last, + seedFrom = ((at(b, id)[native and "expSeedSource" or "leechSeedSource"] or {}).mon or {}).nickname, + foeSeedFrom = (( + at(b, id % 2 == 0 and 1 or 0)[native and "expSeedSource" or "leechSeedSource"] or {} + ).mon or {}).nickname, + foeInfatuated = at(b, id % 2 == 0 and 1 or 0)[native and "expInfatuated" or "infatuated"] ~= nil, + } + end + end + end + local typeIds = { NORMAL = 0, FIRE = 10, WATER = 11, GRASS = 12 } + for _, double in ipairs({ false, true }) do + for _, id in ipairs(double and { 1, 3 } or { 1 }) do + for _, types in ipairs({ + { "NORMAL" }, + { "FIRE" }, + { "WATER" }, + { "GRASS" }, + { "FIRE", "GRASS" }, + { "WATER", "GRASS" }, + }) do + for _, moves in ipairs({ + { "EMBER", "WATER_GUN" }, + { "RAZOR_LEAF", "EMBER" }, + { "SONICBOOM", "TACKLE" }, + { "BATON_PASS", "TACKLE" }, + }) do + events, draws = {}, {} + local b, selected = fresh(double) + local user = at(b, id) + for pos = 0, double and 2 or 0, 2 do + local foe = at(b, pos) + if native then + foe.type1, foe.type2 = typeIds[types[1]], types[2] and typeIds[types[2]] + else + foe.curTypes = Copy(types) + end + end + local party = native and b.foeParty or b.enemyParty + local offset = double and 2 or 1 + for j = 1, 2 do + local mon = party[offset + j] + mon.species = native and (j == 1 and 1 or 7) or (j == 1 and "BULBASAUR" or "SQUIRTLE") + if native then + local number = ({ + EMBER = 52, + WATER_GUN = 55, + RAZOR_LEAF = 75, + SONICBOOM = 49, + TACKLE = 33, + BATON_PASS = 226, + })[moves[j]] + mon.moves, mon.pp, mon.maxPp = { number }, { 0 }, { 10 } + else + mon.moves = { { id = f.ids[moves[j]], pp = 0 } } + end + end + if native then + selected = require("src.core.game3.battle.engine").mostSuitableMon( + b, + require("src.core.game3.battle.adapter").new(b), + id + ) + else + local candidates, ownParty = require("src.battle.Gen3BatonPass").candidates(b, user) + selected = require("src.battle.Gen3BatonPass").enemyChoice(b, user, candidates, ownParty) + end + rows[#rows + 1] = { + kind = "native_ai", + double = double, + id = id, + types = types, + moves = moves, + selected = selected, + draws = Copy(draws), + } + end + end + end + end + stop() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_baton_pass.lua b/tests/engine/gen3_baton_pass.lua new file mode 100644 index 000000000..50161a543 --- /dev/null +++ b/tests/engine/gen3_baton_pass.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, v in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.baton_pass").run(".", "gen2", v) +end +require("tests.modkit").finish("native Baton Pass command and continuation") diff --git a/tests/engine/gen3_baton_pass_link.lua b/tests/engine/gen3_baton_pass_link.lua new file mode 100644 index 000000000..0ca811e3d --- /dev/null +++ b/tests/engine/gen3_baton_pass_link.lua @@ -0,0 +1,170 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +local Input = require("src.core.Input") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef = 50, 50 + T.link.prepare(f.data) + local games = {} + for i, name in ipairs({ "HOST", "GUEST", "VIEWER" }) do + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = name, level = 30 }) + game.stack.states = game.stack.list + games[i] = game + local mon = game.save.party[1] + mon.nickname = name:sub(1, 1) .. "_ALPHA" + mon.moves = + { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + for j = 2, 3 do + game.save.party[j] = Copy(mon) + game.save.party[j].nickname = name:sub(1, 1) .. (j == 2 and "_BETA" or "_GAMMA") + game.save.party[j].moves = { { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + end + end + local Net, P, Link = require("src.link.Net"), require("src.link.Protocol"), require("src.link.LinkBattle") + local na, nb = Net.loopbackPair() + local inbox, packets = {}, {} + local sn = { + update = function() end, + poll = function() + local out = inbox + inbox = {} + return out + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + if packet.type == "baton_pass" then + packets[#packets + 1] = Copy(packet) + end + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(games[1].save.party), P.packParty(games[2].save.party) + local a = assert( + Link.newHost( + games[1], + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + Link.newGuest( + games[2], + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(Link.newSpectator(games[3], sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + local battles, chosen, intentId = { a, b, s }, {}, 0 + for i, battle in ipairs(battles) do + games[i].stack:push(battle) + battle.player.stages.attack, battle.enemy.stages.attack = 2, 2 + end + local seen = {} + for frame = 1, 30000 do + for i, game in ipairs(games) do + f.result.loader.game = game + local top = game.stack:top() + if top ~= battles[i] then + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + T.check(i < 3, "spectator never opens a personal party chooser") + intentId = intentId + 1 + local ok, why = + f.e.api.ui:submit({ id = intentId, revision = view.revision, kind = "select", index = 3 }) + assert(ok, why) + chosen[i] = (chosen[i] or 0) + 1 + end + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[1] and b.localHashes[1] and not seen[1] then + seen[1] = true + for i, battle in ipairs(battles) do + T.eq( + battle.player.mon.nickname, + (i == 2 and "G" or "H") .. "_GAMMA", + "selected non-first local replacement survives the complete turn" + ) + T.eq( + battle.enemy.mon.nickname, + (i == 2 and "H" or "G") .. "_GAMMA", + "selected remote replacement survives the complete turn" + ) + T.eq(battle.player.stages.attack, 2, "local stages pass through wire continuation") + T.eq(battle.enemy.stages.attack, 2, "remote stages pass through wire continuation") + end + end + if a.localHashes[2] and b.localHashes[2] then + break + end + end + T.check(seen[1], "both native peers complete the Baton Pass turn") + T.eq(chosen[1], 1, "host makes exactly one real UI selection") + T.eq(chosen[2], 1, "guest makes exactly one real UI selection") + T.eq(chosen[3], nil, "spectator consumes transmitted choices") + T.eq(#packets, 2, "both owners send their selected replacement") + for _, packet in ipairs(packets) do + T.eq(packet.index, 3, "wire retains the non-first selection") + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired full turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all native state components agree") + end + T.eq(a.result, nil, "host does not silently terminate on a choice mismatch") + T.eq(b.result, nil, "guest does not silently terminate on a choice mismatch") + T.eq(s.result, nil, "spectator continues after both real choices") + T.eq(games[1].save.party[1].moves[1].pp, 20, "link choices do not mutate the persistent save party") + -- A disconnect must still be polled while the native party screen owns input. + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "DROP", level = 30 }) + game.save.party = Copy(games[1].save.party) + game.stack.states = game.stack.list + local nc, nd = Net.loopbackPair() + local dropped = assert( + Link.newHost( + game, + nc, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + game.stack:push(dropped) + dropped.player.curStats.speed, dropped.enemy.curStats.speed = 200, 100 + nd:send({ type = "action", kind = "move", slot = 1 }) + local picker + f.result.loader.game = game + for _ = 1, 15000 do + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + picker = game.stack:top() + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + T.check(picker ~= nil, "real linked picker is active before disconnect") + assert(picker) + nc.closed = true + Input.pressed = {} + game.stack:update(1 / 60) + T.eq(dropped.result, "draw", "link loss is handled while picker owns the stack") + T.eq(game.stack:top(), dropped, "disconnect retires the native picker") + T.eq(dropped._gen3BatonPending, nil, "disconnect clears the pending continuation") + T.eq(dropped.player.mon.nickname, "H_ALPHA", "disconnect never silently selects the first bench") + f.close() +end +T.finish("native Baton Pass link choices and spectator continuation") diff --git a/tests/engine/gen3_baton_pass_packets.lua b/tests/engine/gen3_baton_pass_packets.lua new file mode 100644 index 000000000..174441f42 --- /dev/null +++ b/tests/engine/gen3_baton_pass_packets.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +local C, B = require("src.link.Gen3BatonChoice"), require("src.battle.Gen3BatonPass") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local function fresh(spectator) + local b = f.fresh("TACKLE", { enemy = true }).battle + b:syncSides() + b.turnCount = 1 + local mine, theirs = { b.player.mon }, { b.enemy.mon } + for i = 2, 3 do + mine[i], theirs[i] = Copy(mine[1]), Copy(theirs[1]) + end + local errors, sent = {}, {} + C.attach(b, { + role = "host", + spectator = spectator, + mine = mine, + theirs = theirs, + invalid = function(why) + errors[#errors + 1] = why + end, + send = function(packet) + sent[#sent + 1] = packet + end, + }) + return b, errors, sent, mine, theirs + end + local function pending(b, user) + local candidates, party = B.candidates(b, user) + local p = { user = user, party = party, candidates = candidates, offered = {} } + for _, i in ipairs(candidates) do + p.offered[i] = party[i] + end + b._gen3BatonPending = p + return p + end + local packet = { type = "baton_pass", turn = 1, serial = 1, actor = "guest", index = 3 } + local b, errors = fresh() + T.check(C.receive(b, packet), "reliable early choice can arrive before native command") + local p = pending(b, b.enemy) + T.eq(b:_gen3BatonRequest(p), true, "remote actor waits for a wire choice") + T.eq(p.choice, 3, "early non-first choice is consumed without fallback") + T.check(C.receive(b, Copy(packet)), "identical retransmission is idempotent") + T.eq(#errors, 0, "valid early and duplicate choices remain accepted") + local conflict = Copy(packet) + conflict.index = 2 + T.eq(C.receive(b, conflict), false, "conflicting replay is refused") + T.eq(#errors, 1, "conflicting replay reaches the controller failure path") + T.eq(p.choice, 3, "replay does not change the accepted mon") + for _, edit in ipairs({ + { "turn", 0 }, + { "turn", 2 }, + { "serial", 0 }, + { "serial", 65 }, + { "serial", 1.5 }, + { "index", 0 }, + { "index", 7 }, + { "index", 0 / 0 }, + { "actor", "host" }, + { "actor", "other" }, + { "index", "3" }, + }) do + local bad = Copy(packet) + bad[edit[1]] = edit[2] + local battle, invalid = fresh() + T.eq(C.receive(battle, bad), false, "malformed or unauthorized choice is refused") + T.eq(#invalid, 1, "malformed choice cannot leave the controller waiting") + end + local battle, invalid = fresh(true) + T.eq(C.receive(battle, packet, "host"), false, "spectator authenticates the relayed actor") + T.eq(#invalid, 1, "wrong relay side ends spectator choice processing") + local spec, bad = fresh(true) + T.check(C.receive(spec, packet, "guest"), "spectator buffers authenticated choice") + local remote = pending(spec, spec.enemy) + T.eq(spec:_gen3BatonRequest(remote), true, "spectator never needs a personal picker") + T.eq(remote.choice, 3, "spectator resumes with the real remote selection") + T.eq(#bad, 0, "authenticated spectator choice has no controller error") + local localBattle, localErrors, sent = fresh() + local localPending = pending(localBattle, localBattle.player) + T.eq(localBattle:_gen3BatonRequest(localPending), false, "local owner needs a picker") + T.check(B.select(localBattle, localPending, 3), "local selection is validated before sending") + T.eq(#sent, 1, "exactly one native choice is sent") + T.same( + sent[1], + { type = "baton_pass", turn = 1, serial = 1, actor = "host", index = 3 }, + "wire carries turn, order, owner and actual choice" + ) + T.eq(B.select(localBattle, localPending, 2), false, "local callback cannot send twice") + T.eq(#localErrors, 0, "valid local choice has no controller error") + local stale, failures, _, _, party = fresh() + local waiting = pending(stale, stale.enemy) + stale:_gen3BatonRequest(waiting) + party[3].hp = 0 + C.receive(stale, packet) + T.eq(waiting.choice, nil, "wire cannot choose a newly fainted replacement") + T.eq(#failures, 1, "stale native choice reaches controller failure") + f.close() +end +T.finish("native Baton Pass packet validation and buffered choices") diff --git a/tests/engine/gen3_baton_pass_ui.lua b/tests/engine/gen3_baton_pass_ui.lua new file mode 100644 index 000000000..9e8e2ff4d --- /dev/null +++ b/tests/engine/gen3_baton_pass_ui.lua @@ -0,0 +1,96 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "ALPHA" + mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + mon.stats.hp, mon.hp = 400, 400 + game.save.party[2], game.save.party[3] = Copy(mon), Copy(mon) + game.save.party[2].nickname, game.save.party[3].nickname = "BETA", "GAMMA" + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.moves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.enemy.mon.stats.hp, b.enemy.mon.hp = 400, 400 + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + b.player.stages.attack = 2 + f.e.choose(function() + return { move = "TACKLE" } + end) + -- A real queued turn, with its slower attack already waiting on the donor slot. + game.stack.states[1] = b + b:resolveTurn(b.player.curMoves[1]) + local ui, screen + local function frame() + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + for _ = 1, 10000 do + local top = game.stack:top() + if top ~= b then + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + ui, screen = view, top + break + end + end + frame() + end + T.check(ui ~= nil, "Baton Pass opens the actual native party screen") + assert(ui, "party chooser never opened") + T.eq(b.player.mon, mon, "donor remains active until a choice") + T.eq(mon.moves[1].pp, 19, "PP is spent before native party selection") + T.eq(ui.canBack, false, "public UI cannot cancel a forced Baton Pass choice") + local back, why = f.e.api.ui:submit({ id = 1, revision = ui.revision, kind = "back" }) + T.eq(back, nil, "public back intent is refused") + T.check(type(why) == "string", "back refusal explains the unavailable operation") + screen:cancel() + T.eq(game.stack:top(), screen, "native B/cancel leaves forced picker open") + local pending = b._gen3BatonPending + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 1), + false, + "current active donor is not eligible" + ) + game.save.party[3].hp = 0 + local stale = f.e.api.ui:submit({ id = 2, revision = ui.revision, kind = "select", index = 3 }) + T.eq(stale, nil, "party mutation invalidates an old UI offer") + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 3), + false, + "fainted replacement is refused even by native continuation" + ) + game.save.party[3].hp = 400 + local current = assert(f.e.api.ui:snapshot()) + local accepted, error = + f.e.api.ui:submit({ id = 3, revision = current.revision, kind = "select", index = 3 }) + T.eq(accepted, true, "public UI chooses the last eligible member: " .. tostring(error)) + T.eq(game.stack:top(), b, "selection returns to the battle queue") + for _ = 1, 15000 do + if b.phase == "menu" and not b._gen3BatonPending then + break + end + frame() + end + T.eq(b.player.mon, game.save.party[3], "queued continuation installs the selected Gamma") + T.eq(b.player.stages.attack, 2, "queued native switch retains donor stages") + T.eq(mon.hp, 400, "slower attack does not damage the retired donor") + T.check(game.save.party[3].hp < 400, "slower queued attack hits the current occupant") + T.eq(mon.moves[1].pp, 19, "resuming the chooser does not spend PP twice") + T.eq(b.turnCount, 1, "Baton Pass does not create an extra turn") + T.eq(b._gen3BatonPending, nil, "choice state is cleared after the turn") + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 2), + false, + "completed choice cannot be replayed" + ) + T.eq(b.player.mon.nickname, "GAMMA", "replayed callback cannot replace the selected mon") + f.close() +end +T.finish("native Baton Pass party UI and queued turn continuation") diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 29043d467..5701c7005 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #216 with command revision 2. +-- Legacy hash audited against #214; native hash is #217 with command revision 3. local LEGACY = "e33d251961f2a41d" -local NATIVE = "cf73a8e083fb8a54" +local NATIVE = "c2a03195bc4588f9" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 2 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 3 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 1c06a3e42..181526432 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index c80055602..b5c24e5b7 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "lock_on" then +if mode == "baton_pass" then + rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version) +elseif mode == "lock_on" then rows = dofile(root .. "/tests/enemy_actions/lock_on.lua").run(root, backend, version) elseif mode == "party_codec_hooks" then rows = dofile(root .. "/tests/enemy_actions/party_codec_hooks.lua").run(root, backend, version) From 4db98efc73d23d53ff3b9a83f705e100f93e63cd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:07:08 +0100 Subject: [PATCH 44/90] Defer nested Baton Pass item phases and hash transferred native stages --- src/battle/BattleState.lua | 2 +- src/battle/Gen3BatonPass.lua | 15 +++- tests/enemy_actions/baton_pass.lua | 17 +++-- tests/engine/gen3_baton_pass_composition.lua | 74 ++++++++++++++++++++ tests/engine/gen3_baton_pass_packets.lua | 14 ++++ 5 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 tests/engine/gen3_baton_pass_composition.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index fccc5ee08..54de2c0d7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8307,7 +8307,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end local Schedule = require("src.battle.GbaItemSchedule") local parent = self._gbaMoveFrame - local frame = {} + local frame = { parent = parent } if parent then parent.called = true end self._gbaMoveFrame = frame if self._gbaActionFrame then self._gbaActionFrame.moved = true end diff --git a/src/battle/Gen3BatonPass.lua b/src/battle/Gen3BatonPass.lua index 15ec719e8..c7d50f934 100644 --- a/src/battle/Gen3BatonPass.lua +++ b/src/battle/Gen3BatonPass.lua @@ -28,6 +28,9 @@ function M.signature(user, role) end out[#out + 1] = tostring(value) end + for _, key in ipairs({ "spatk", "spdef" }) do + out[#out + 1] = tostring((user.stages or {})[key] or 0) + end return ":baton=" .. table.concat(out, ",") end function M.party(battle, user) @@ -260,7 +263,11 @@ function M.await(battle, pending) battle._gen3BatonPending = nil pending.ctx.user = M.switch(battle, pending.user, pending.choice, pending.party) if pending.frame then - pending.frame.finished = true + local frame = pending.frame + while frame do + frame.finished = true + frame = frame.parent + end require("src.battle.GbaItemSchedule").moveEnd(battle) require("src.battle.GbaItemSchedule").afterAction(battle) end @@ -354,8 +361,10 @@ function M.perform(ctx) end battle._gen3BatonPending = pending pending.frame = battle._gbaMoveFrame - if pending.frame then - pending.frame.suspended = true + local frame = pending.frame + while frame do + frame.suspended = true + frame = frame.parent end local remote = battle._gen3BatonRequest and battle:_gen3BatonRequest(pending) or false if not remote then diff --git a/tests/enemy_actions/baton_pass.lua b/tests/enemy_actions/baton_pass.lua index b1fe985cc..9b238866a 100644 --- a/tests/enemy_actions/baton_pass.lua +++ b/tests/enemy_actions/baton_pass.lua @@ -215,6 +215,7 @@ function M.run(root, backend, version) "spikes1", "spikes2", "spikes3", + "called", }) do events, draws = {}, {} local b = fresh(double) @@ -280,13 +281,17 @@ function M.run(root, backend, version) id, id, 226, - 1, + kind ~= "called" and 1 or nil, require("src.core.game3.battle.adapter").new(b), b, - {} + { called = kind == "called" } ) else - b:executeAction(user, user, user.curMoves[1]) + if kind == "called" then + b:performMove(user, user, user.curMoves[1], true) + else + b:executeAction(user, user, user.curMoves[1]) + end end local before = { actor = view(at(b, id)), pp = native and old.mon.pp[1] or old.mon.moves[1].pp } if interactive and id % 2 == 0 then @@ -307,7 +312,11 @@ function M.run(root, backend, version) require("src.battle.Gen3BatonPass").await(b, pending) end end - T.eq(native and old.mon.pp[1] or old.mon.moves[1].pp, 19, "Baton Pass spends donor PP once") + T.eq( + native and old.mon.pp[1] or old.mon.moves[1].pp, + kind == "called" and 20 or 19, + "Baton Pass spends donor PP once; called moves do not spend their own PP" + ) if kind ~= "none" then T.check(at(b, id) ~= old, "native Baton Pass replaces its own slot") T.eq(at(b, id).stages.attack, 2, "attack stage is inherited") diff --git a/tests/engine/gen3_baton_pass_composition.lua b/tests/engine/gen3_baton_pass_composition.lua new file mode 100644 index 000000000..b6a528e77 --- /dev/null +++ b/tests/engine/gen3_baton_pass_composition.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local B = require("src.battle.Gen3BatonPass") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "override", "delegate", "called" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local c = f.fresh("TACKLE") + local b, donor = c.battle, c.user + b:syncSides() + b.game.save.party[2] = Copy(donor.mon) + b.game.save.party[2].nickname = "BENCH" + donor.mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + donor.curMoves = donor.mon.moves + donor.stages.defense = 2 + local calls, phases = 0, {} + local original = b.holdItemTrigger + b.holdItemTrigger = function(self, who, phase) + phases[#phases + 1] = phase + return original(self, who, phase) + end + local record = Copy(b.data.move_effects.BATON_PASS_EFFECT) + if mode == "override" then + record.run = function() + calls = calls + 1 + return {} + end + b.data.move_effects.BATON_PASS_EFFECT = record + elseif mode == "delegate" then + local builtin = record.run + record.run = function(ctx) + calls = calls + 1 + return builtin(ctx) + end + b.data.move_effects.BATON_PASS_EFFECT = record + else + b.data.move_effects.CALL_BATON = { + kind = "full", + callsMove = function() + calls = calls + 1 + return f.ids.BATON_PASS + end, + } + b.data.moves[f.ids.TACKLE].effect = "CALL_BATON" + end + local action = donor.curMoves[mode == "called" and 2 or 1] + b:performMove(donor, donor, action) + T.eq(calls, 1, "authored effect is called exactly once") + if mode == "override" then + T.eq(b.player, donor, "authored override is not replaced by native behavior") + T.eq(b._gen3BatonPending, nil, "authored override does not open an unwanted picker") + else + local pending = b._gen3BatonPending + T.check(pending ~= nil, "delegated or called native command pauses for selection") + T.eq(#phases, 0, "native item phases do not run before nested choice completion") + T.check(B.select(b, assert(pending), 2), "nested selection accepts the intended replacement") + B.await(b, pending) + T.eq(b.player.mon, b.game.save.party[2], "native continuation switches after authored composition") + T.eq(b.player.stages.defense, 2, "authored composition retains native transfer") + T.eq( + table.concat(phases, ","), + "entry,moveEnd,moveEnd,action,action", + "nested completion runs each native item phase once" + ) + T.eq( + donor.mon.moves[1].pp, + mode == "called" and 20 or 19, + "composition preserves called-move PP semantics" + ) + T.eq(donor.mon.moves[2].pp, mode == "called" and 9 or 10, "only the calling move spends its own PP") + end + f.close() + end +end +T.finish("native Baton Pass authored effects and called-move scheduling") diff --git a/tests/engine/gen3_baton_pass_packets.lua b/tests/engine/gen3_baton_pass_packets.lua index 174441f42..f386016ad 100644 --- a/tests/engine/gen3_baton_pass_packets.lua +++ b/tests/engine/gen3_baton_pass_packets.lua @@ -2,8 +2,22 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Copy = require("src.mods.Merge").deepCopy local C, B = require("src.link.Gen3BatonChoice"), require("src.battle.Gen3BatonPass") +for _, stat in ipairs({ "spatk", "spdef" }) do + local changed = { stages = { [stat] = 2 } } + T.neq(B.signature(changed), B.signature({}), "native active hashes include transferred " .. stat) +end +T.eq( + B.signature({ leechSeedSource = { position = 0 } }, "host"), + B.signature({ leechSeedSource = { position = 1 } }, "guest"), + "seed-source ownership hashes in host coordinates" +) for _, version in ipairs({ "firered", "emerald" }) do local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current, old = H.hello(f.game, "battle"), H.hello(f.game, "battle") + old.nativeBattleRevision = 2 + T.eq(H.checkCompat(current, old), "subset", "pre-choice native rules cannot enter a battle") + T.eq(H.checkCompat(old, current), "subset", "pre-choice native guard is symmetric") local function fresh(spectator) local b = f.fresh("TACKLE", { enemy = true }).battle b:syncSides() From da3666f12e7ee0eebd0525c38c73421cb485a366 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:09:42 +0100 Subject: [PATCH 45/90] Document native Baton Pass contract and regression evidence --- docs/native-gen3-baton-pass.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/native-gen3-baton-pass.md b/docs/native-gen3-baton-pass.md index 33a43d971..8b001f417 100644 --- a/docs/native-gen3-baton-pass.md +++ b/docs/native-gen3-baton-pass.md @@ -31,6 +31,7 @@ LUA=/absolute/path/to/luajit "$LUA" tests/engine/gen3_baton_pass_ui.lua "$LUA" tests/engine/gen3_baton_pass_link.lua "$LUA" tests/engine/gen3_baton_pass_packets.lua +"$LUA" tests/engine/gen3_baton_pass_composition.lua "$LUA" tests/engine/gen3_battle_protocol.lua "$LUA" tests/engine/ui_actions.lua python3 tools/enemy-action-contract-check/check.py \ @@ -45,7 +46,32 @@ infatuation has a separate active flag and owner pointer, whereas the host's pointer is the active state. These are explicit schema mappings. Original failed comparison reports are retained locally. -This is work in progress: full feature regression, clean-commit comparisons, -imported-record smoke, combined-fork integration and PR publication are still -pending. CPU fixtures do not certify GPU animation, pixel equality, a complete +Validation at clean runtime `4db98efc73d23d53ff3b9a83f705e100f93e63cd`: + +- 905 focused Baton Pass checks pass: 654 command, 42 actual party UI, 72 link/ + spectator/disconnect, 95 packet/signature/compatibility and 42 authored-effect/ + called-move checks. The 154 native protocol and 3,002 existing UI checks pass. +- All 88 workers / 60,132 paired observations equal clean Gen1 + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new mode has 156 complete + observations per profile, including actual commands, resumed selections, + called moves, all four slots, seeds/infatuation, excluded/reserved members, + one/two/three Spikes layers and the real NPC type selector with empty PP. +- Full feature regression is 110/119 engine suites, SDK 3/3 and B30. The exact + Counter of all 25 FAIL lines across nine inherited suites equals clean + prerequisite `c27c37a667e8a40613c22506448ab4886fc9b431` (105/114). Their + independent repairs are present in the combined fork. +- The same public canonical selector on the clean prerequisite spends PP and + sets `wantsSwitch`, but leaves the donor active. Its failing assertion and + raw state are retained. The initial Baton implementation also fails the + later nested-choice item-phase checks; the fix suspends ancestor frames. +- Actual locally imported FireRed/Emerald records select canonical + `BATON_PASS` through the public enemy-action mod, then exercise the real + party screen and queued turn with the third member selected. Both profiles + retain stages and spend native PP once. This is a CPU-only imported-record + smoke; no ROM/cache assets or saves are distributed. + +Evidence is retained under `tmp/baton-pass-qa/`, including complete failed and +accepted reports. Combined-fork acceptance and hosted workflow status are +recorded separately in the combined testing document and the published PR. +CPU fixtures do not certify GPU animation, pixel equality, a complete playthrough, global RNG/VBlank scheduling or all native side/volatile producers. From be164ba8ad4e1196fa7bcb4cc39c364fcc3418ba Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:11:06 +0100 Subject: [PATCH 46/90] Preserve live link rosters alongside owner-specific party choices --- src/ui/Gen3PartyMenu.lua | 7 ++++++- tests/engine/gen3_baton_pass_ui.lua | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index c4ca3a8a7..c77f5c871 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -138,6 +138,9 @@ function Gen3PartyMenu.new(game, opts) self.noCancel = opts.noCancel self.onUpdate = opts.onUpdate self._party = opts.party + -- An ordinary linked screen follows its battle's live roster. A distinct + -- owner-specific picker (for example an ally) retains its explicit party. + if self.battle and self._party == self.battle.playerParty then self._party = nil end self.pickOnly = opts.pickOnly -- FireRed's POKé DUDE drives the ordinary party screen with synthetic -- input. The screen stays the cartridge one; only its input source changes. @@ -171,7 +174,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self._party or self.game.save.party or {} end +function Gen3PartyMenu:party() + return self._party or (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/tests/engine/gen3_baton_pass_ui.lua b/tests/engine/gen3_baton_pass_ui.lua index 9e8e2ff4d..93a242b28 100644 --- a/tests/engine/gen3_baton_pass_ui.lua +++ b/tests/engine/gen3_baton_pass_ui.lua @@ -91,6 +91,17 @@ for _, version in ipairs({ "firered", "emerald" }) do "completed choice cannot be replayed" ) T.eq(b.player.mon.nickname, "GAMMA", "replayed callback cannot replace the selected mon") + local initial = { game.save.party[1] } + local owner = { playerParty = initial } + local Menu = require("src.ui.Gen3PartyMenu") + local linked = Menu.new(game, { battle = owner, party = initial }) + T.eq(linked:party(), initial, "ordinary link picker starts with the live battle roster") + owner.playerParty = { game.save.party[2], game.save.party[3] } + T.eq(linked:party(), owner.playerParty, "ordinary link picker follows a replaced live roster") + local distinct = { game.save.party[3] } + local ally = Menu.new(game, { battle = owner, party = distinct, forceSwitch = true }) + T.eq(ally:party(), distinct, "owner-specific picker retains its distinct party") + T.eq(#ally:party(), 1, "owner-specific roster never expands into the other trainer's party") f.close() end T.finish("native Baton Pass party UI and queued turn continuation") From d5ad56afccca21d107f21b5b561b908fa650e59e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:12:06 +0100 Subject: [PATCH 47/90] Follow late-installed live battle rosters outside forced choices --- src/ui/Gen3PartyMenu.lua | 8 +++++--- tests/engine/gen3_baton_pass_ui.lua | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index c77f5c871..b8f6ed64a 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -138,9 +138,6 @@ function Gen3PartyMenu.new(game, opts) self.noCancel = opts.noCancel self.onUpdate = opts.onUpdate self._party = opts.party - -- An ordinary linked screen follows its battle's live roster. A distinct - -- owner-specific picker (for example an ally) retains its explicit party. - if self.battle and self._party == self.battle.playerParty then self._party = nil end self.pickOnly = opts.pickOnly -- FireRed's POKé DUDE drives the ordinary party screen with synthetic -- input. The screen stays the cartridge one; only its input source changes. @@ -175,6 +172,11 @@ function Gen3PartyMenu.new(game, opts) end function Gen3PartyMenu:party() + -- The battle may install or replace its clamped roster after opening the + -- screen. Forced owner-specific choices retain their explicit party. + if self.battle and not self.forceSwitch and self.battle.playerParty then + return self.battle.playerParty + end return self._party or (self.battle and self.battle.playerParty) or self.game.save.party or {} end diff --git a/tests/engine/gen3_baton_pass_ui.lua b/tests/engine/gen3_baton_pass_ui.lua index 93a242b28..9c9e49994 100644 --- a/tests/engine/gen3_baton_pass_ui.lua +++ b/tests/engine/gen3_baton_pass_ui.lua @@ -102,6 +102,10 @@ for _, version in ipairs({ "firered", "emerald" }) do local ally = Menu.new(game, { battle = owner, party = distinct, forceSwitch = true }) T.eq(ally:party(), distinct, "owner-specific picker retains its distinct party") T.eq(#ally:party(), 1, "owner-specific roster never expands into the other trainer's party") + local lateOwner = {} + local late = Menu.new(game, { battle = lateOwner, party = initial }) + lateOwner.playerParty = distinct + T.eq(late:party(), distinct, "ordinary picker follows a roster installed after screen creation") f.close() end T.finish("native Baton Pass party UI and queued turn continuation") From e8d65156d62605c52203c88a6f3f9c82e2f67e42 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:14:14 +0100 Subject: [PATCH 48/90] Record final Baton Pass feature and combined-fork acceptance --- docs/native-gen3-baton-pass.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/native-gen3-baton-pass.md b/docs/native-gen3-baton-pass.md index 8b001f417..1fd31e16e 100644 --- a/docs/native-gen3-baton-pass.md +++ b/docs/native-gen3-baton-pass.md @@ -46,9 +46,9 @@ infatuation has a separate active flag and owner pointer, whereas the host's pointer is the active state. These are explicit schema mappings. Original failed comparison reports are retained locally. -Validation at clean runtime `4db98efc73d23d53ff3b9a83f705e100f93e63cd`: +Validation at clean runtime `d5ad56afccca21d107f21b5b561b908fa650e59e`: -- 905 focused Baton Pass checks pass: 654 command, 42 actual party UI, 72 link/ +- 915 focused Baton Pass checks pass: 654 command, 52 actual party UI, 72 link/ spectator/disconnect, 95 packet/signature/compatibility and 42 authored-effect/ called-move checks. The 154 native protocol and 3,002 existing UI checks pass. - All 88 workers / 60,132 paired observations equal clean Gen1 @@ -60,6 +60,11 @@ Validation at clean runtime `4db98efc73d23d53ff3b9a83f705e100f93e63cd`: Counter of all 25 FAIL lines across nine inherited suites equals clean prerequisite `c27c37a667e8a40613c22506448ab4886fc9b431` (105/114). Their independent repairs are present in the combined fork. +- Combined runtime `86b12d7b7a816a2782ba5cfba804b103630eda5b` passes all + 231/231 engine suites, SDK 3/3 and B30, plus the same clean-reference + comparison and imported-record smoke. Its existing 4,849 party-navigation + checks remain green. Ordinary link screens follow live/late-installed battle + rosters; forced owner-specific choices retain the explicit party. - The same public canonical selector on the clean prerequisite spends PP and sets `wantsSwitch`, but leaves the donor active. Its failing assertion and raw state are retained. The initial Baton implementation also fails the From bde1d79c704a322eb20188968b2cf85952106f42 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:42:42 +0100 Subject: [PATCH 49/90] Implement native GBA Encore state, planning and execution --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-encore.md | 48 +++ src/battle/BattleState.lua | 37 +- src/battle/Gen3Encore.lua | 237 +++++++++++ src/battle/Gen3MoveEffects.lua | 3 + src/link/Fingerprint.lua | 2 +- src/link/LinkBattle.lua | 9 + src/mods/BattleIntents.lua | 1 + tests/enemy_actions/encore.lua | 425 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_encore.lua | 5 + tests/engine/gen3_encore_continuation.lua | 134 ++++++ tests/engine/gen3_encore_link.lua | 90 ++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 15 files changed, 995 insertions(+), 9 deletions(-) create mode 100644 docs/native-gen3-encore.md create mode 100644 src/battle/Gen3Encore.lua create mode 100644 tests/enemy_actions/encore.lua create mode 100644 tests/engine/gen3_encore.lua create mode 100644 tests/engine/gen3_encore_continuation.lua create mode 100644 tests/engine/gen3_encore_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index d1e2c1325..f0c26f789 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -25,6 +25,7 @@ on: - 'tests/engine/gen3_lock_on.lua' - 'tests/engine/gen3_lock_on_link.lua' - 'tests/engine/gen3_baton_pass*.lua' + - 'tests/engine/gen3_encore*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-encore.md b/docs/native-gen3-encore.md new file mode 100644 index 000000000..d07f8bc8e --- /dev/null +++ b/docs/native-gen3-encore.md @@ -0,0 +1,48 @@ +# Native GBA Encore + +This branch stacks on #218. Gen1 is an unchanged read-only reference. + +Encore records its target's last known move and original slot. It rejects +Struggle, Encore, Mirror Move, unknown/removed moves, exhausted slots and an +already-active Encore after the native accuracy check. A successful command +draws `0..3` and stores three to six residual phases. Known authored moves do +not require a cartridge index. Existing custom handlers and older-generation +policies retain their execution paths. + +Turn planning uses the forced move's priority while preserving a chosen +target. An Encore applied after planning also changes the queued action, +recomputing its target with native bounded draws and redirection. Execution +spends the actual learned slot's PP, preserves called-move PP rules and emits +the actual move through public events. Forced FIGHT returns a native move +instance rather than an unrecognized nested descriptor. + +Actual residual processing cancels Encore on expiry, depleted PP or a changed +original slot. It does not transfer through Baton Pass. Link state components +include move, slot and duration. Native command revision 4 refuses older battle +rules before party exchange; the native party codec revision stays unchanged. +The raw fixture changes from `c2a03195bc4588f9` to `8be423c61a4477a2`, audited +with the unchanged #218 hasher and only the explicit revision token. + +Focused commands: + +```sh +LUA=/absolute/path/to/pinned/luajit +"$LUA" tests/engine/gen3_encore.lua +"$LUA" tests/engine/gen3_encore_continuation.lua +"$LUA" tests/engine/gen3_encore_link.lua +"$LUA" tests/engine/gen3_battle_protocol.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /absolute/path/to/clean-reference --gen2 . --lua "$LUA" \ + --report /absolute/path/to/report.json +``` + +The shared matrix retains every bounded RNG call, PP/HP for all battlers, +move/slot/timer state and public move events, including their actual target. +The reference's `expEncore*` fields and numeric move IDs are mapped explicitly +to the host's native fields and fixture registry IDs. Failed/raw reports remain +under `tmp/encore-qa/`; the checker never edits source code or filters calls. + +Development acceptance is in progress. Full regression, clean-commit engine +comparisons, imported-record smoke, combined-fork acceptance and publication +remain to be verified. These CPU fixtures do not certify a complete playthrough, +GPU/pixel equality, global RNG/VBlank scheduling or every other native producer. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 54de2c0d7..1111ef541 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3990,6 +3990,7 @@ function BattleState:fightLockedAction(battler) end -- ENCORE: the same move again, whether the Pokemon wants to or not. Only -- if it still has the move -- an ENCOREd move that ran out of PP frees it. + if GameVersion.isGen3() then return require("src.battle.Gen3Encore").menu(battler) end if battler.encoreTurns and battler.encoreMove then for _, m in ipairs(battler.curMoves or battler.mon.moves or {}) do if m.id == battler.encoreMove and (m.pp or 0) > 0 then @@ -5135,6 +5136,9 @@ function BattleState:effectRecord(effect) if GameVersion.isGen3() and effect == "BATON_PASS_EFFECT" then return require("src.battle.Gen3BatonPass").record(record, MoveEffects.RECORDS.BATON_PASS_EFFECT) end + if GameVersion.isGen3() and effect == "ENCORE_EFFECT" then + return require("src.battle.Gen3Encore").record(record, MoveEffects.RECORDS.ENCORE_EFFECT) + end return record end @@ -5524,6 +5528,10 @@ function BattleState:resolveTurn(playerAction) -- nil in a double battle whose left-hand foe has fainted with nothing on -- the bench to replace it; `actionFor` asks each surviving slot for its own local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) + if GameVersion.isGen3() then + local Encore = require("src.battle.Gen3Encore") + playerAction, enemyAction = Encore.plan(self.player, playerAction), Encore.plan(self.enemy, enemyAction) + end self.turnCount = (self.turnCount or 0) + 1 if GameVersion.isGen3() then require("src.battle.Gen3FollowMe").clear(self) end -- BATTLE ARENA: arenaTurnCounter @@ -5595,6 +5603,7 @@ function BattleState:resolveTurn(playerAction) local b = self:battlerAt(pos) if b and b.mon and (b.mon.hp or 0) > 0 then local action = self:actionFor(b, playerAction, enemyAction) + if GameVersion.isGen3() then action = require("src.battle.Gen3Encore").plan(b, action) end entries[#entries + 1] = { battler = b, move = orderMove(action, self.data), action = action } end @@ -6512,7 +6521,10 @@ end function BattleState:gen3BattlerCountersTick(b, scheduled) if b and b.mon and b.mon.hp > 0 then - if GameVersion.isGen3() then require("src.battle.Gen3LockOn").tick(self, b) end + if GameVersion.isGen3() then + require("src.battle.Gen3Encore").tick(self, b) + require("src.battle.Gen3LockOn").tick(self, b) + end -- 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 @@ -6531,7 +6543,7 @@ function BattleState:gen3BattlerCountersTick(b, scheduled) self:sayNext(Strings("%s's taunt\nwore off!", displayName(b))) end end - if b.encoreTurns then + if not GameVersion.isGen3() and b.encoreTurns then b.encoreTurns = b.encoreTurns - 1 if b.encoreTurns <= 0 then b.encoreTurns, b.encoreMove = nil, nil @@ -7580,6 +7592,17 @@ function BattleState:executeAction(user, target, action) target = Targeting.redirect(self, user, nil, selected)[1] or self:defaultTargetFor(user) end end + local encoreTargets + if GameVersion.isGen3() then + local chosen = self:moveDef(action) + local changed + action, changed = require("src.battle.Gen3Encore").forced(user, action) + if changed and self:isDouble() then + explicitTarget = false + encoreTargets = require("src.battle.Gen3Encore").targets(self,user,self:moveDef(action),chosen) + target = encoreTargets[1] or self:defaultTargetFor(user) + end + end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to @@ -7737,7 +7760,7 @@ function BattleState:executeAction(user, target, action) spreadAnimation = targetKind == Targeting.BOTH or targetKind == Targeting.FOES_AND_ALLY local chosen = explicitTarget and target or (self.chosenTargets or {})[user.position] or target - local got = explicitTarget and Targeting.actionTargets(self, user, def, chosen) + local got = encoreTargets or explicitTarget and Targeting.actionTargets(self, user, def, chosen) or Targeting.resolve(self, user, def, chosen) if #got > 0 then list = got end end @@ -8305,6 +8328,14 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima if not GameVersion.isGen3() then return performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) end + if not isCalled then + local chosen = self:moveDef(moveInst) + local changed + moveInst, changed = require("src.battle.Gen3Encore").forced(user, moveInst) + if changed and self:isDouble() then + target = require("src.battle.Gen3Encore").targets(self,user,self:moveDef(moveInst),chosen)[1] or target + end + end local Schedule = require("src.battle.GbaItemSchedule") local parent = self._gbaMoveFrame local frame = { parent = parent } diff --git a/src/battle/Gen3Encore.lua b/src/battle/Gen3Encore.lua new file mode 100644 index 000000000..4332355ec --- /dev/null +++ b/src/battle/Gen3Encore.lua @@ -0,0 +1,237 @@ +-- Native Encore owns a move, its original slot and a residual-phase timer. +local E = {} +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +local function moves(user) + return user.curMoves or user.mon and user.mon.moves or {} +end +local function slotOf(user, id) + for i, move in ipairs(moves(user)) do + if i > 4 then + break + end + if move.id == id then + return i, move + end + end +end +local function clear(user) + user.encoreTurns, user.encoreMove, user.encoreSlot = nil, nil, nil +end +function E.apply(battle, _, target) + local last = target.lastMove + local def = last and battle.data.moves[last] + local number = def and tonumber(def.index or def.numId) + or ({ STRUGGLE = 165, ENCORE = 227, MIRROR_MOVE = 119 })[last] + local slot, move = slotOf(target, last) + if + not def + or number == 0 + or number == 165 + or number == 227 + or number == 119 + or (tonumber(target.encoreTurns) or 0) > 0 + or not slot + or (tonumber(move.pp) or 0) <= 0 + then + return { Strings("But, it failed!") }, false + end + target.encoreMove, target.encoreSlot = move.id, slot + target.encoreTurns = battle.rng(0, 3) % 4 + 3 + local name = target.isPlayer and target.name or ("Enemy " .. target.name) + return { Strings("%s got\nan ENCORE!", name) }, true +end +function E.forced(user, action) + if + not action + or action.special + or not user + or (tonumber(user.encoreTurns) or 0) <= 0 + or not user.encoreMove + or user.charging + or user.thrashTurns and user.thrashTurns > 0 + or user.rageMove + then + return action, false + end + local _, forced = slotOf(user, user.encoreMove) + if not forced then + return action, false + end + if action.id == forced.id and require("src.battle.EnemyMoveChoice").slot(action) == forced then + return action, false + end + return forced, action.id ~= forced.id +end +function E.menu(user) + if (tonumber(user.encoreTurns) or 0) <= 0 or not user.encoreMove then + return nil + end + local _, move = slotOf(user, user.encoreMove) + return move +end +function E.plan(user, action) + local forced = E.forced(user, action) + if not forced or forced == action or forced.id == action.id then + return action + end + -- The planner changes move priority now, while retaining the chosen target + -- and slot descriptor. Execution later binds PP to the actual Encore slot. + local out = {} + for key, value in pairs(action) do + out[key] = value + end + out.id, out.pp = forced.id, action.pp + return out +end +function E.targets(battle, user, move, chosen) + local Targeting, Abilities = require("src.battle.Targeting"), require("src.battle.Abilities") + local id = user.position + local left, kind = id % 2 == 0 and 1 or 0, tonumber(move.target) or 0 + local center, followActive = require("src.battle.Gen3FollowMe").center(battle, user) + local function present(pos) + local b = battle:battlerAt(pos) + return b and b.mon and b.mon.hp > 0 and b + end + local function partner(pos) + return pos < 2 and pos + 2 or pos - 2 + end + local function flank() + return battle.rng(0, 1) == 1 and left or left + 2 + end + local target + if kind == Targeting.SELECTED then + if center then + target = center.position + else + for _ = 1, 256 do + local rolled = battle.rng(0, 3) % 4 + if battle.linkRole == "guest" then + rolled = rolled % 2 == 0 and rolled + 1 or rolled - 1 + end + if rolled % 2 ~= id % 2 and present(rolled) then + target = rolled + break + end + end + target = target or left + if + Abilities.normalizeType(move.type) == "ELECTRIC" + and Abilities.of(battle:battlerAt(target)) ~= "LIGHTNING_ROD" + then + for pos = left, left + 2, 2 do + if Abilities.of(battle:battlerAt(pos)) == "LIGHTNING_ROD" then + target = partner(target) + break + end + end + end + end + elseif kind == Targeting.RANDOM then + target = center and center.position or flank() + elseif + kind == Targeting.DEPENDS + or kind == Targeting.BOTH + or kind == Targeting.FOES_AND_ALLY + or kind == Targeting.OPPONENTS_FIELD + then + target = left + else + target = id + end + if center and kind == Targeting.SELECTED then + return { center } + end + if + not followActive + and Abilities.normalizeType(move.type) == "ELECTRIC" + and ((tonumber(move.power) or 0) ~= 0 or kind ~= Targeting.USER) + and Abilities.of(battle:battlerAt(target)) ~= "LIGHTNING_ROD" + then + for _, pos in ipairs(battle._actionTargetOrder or { 0, 1, 2, 3 }) do + if pos % 2 ~= id % 2 and pos ~= target and Abilities.of(battle:battlerAt(pos)) == "LIGHTNING_ROD" then + target = pos + return present(target) and { present(target) } or {} + end + end + end + if math.floor((tonumber(chosen and chosen.target) or 0) / Targeting.RANDOM) % 2 == 1 then + target = flank() + end + if not present(target) then + if target % 2 ~= id % 2 then + target = partner(target) + else + target = present(left) and left or left + 2 + end + end + if kind == Targeting.BOTH or kind == Targeting.FOES_AND_ALLY then + return Targeting.resolve(battle, user, move, present(target)) + end + return present(target) and { present(target) } or {} +end +function E.perform(ctx) + local hit, why = ctx.battle:accuracyRoll(ctx.move, ctx.user, ctx.target) + if not hit then + ctx.battle:cancelMoveAnim() + if why == "protected" then + ctx.say(Strings("%s\nprotected itself!", ctx.displayName(ctx.target))) + elseif why ~= "absorbed" then + ctx.say(Strings("%s's\nattack missed!", ctx.displayName(ctx.user))) + end + return + end + local messages, success = E.apply(ctx.battle, ctx.user, ctx.target) + if not success then + ctx.battle:cancelMoveAnim() + end + for _, message in ipairs(messages) do + ctx.say(message) + end +end +function E.record(base, vanilla) + if + not base + or base.run ~= vanilla.run + or base.perform + or base.accuracyChecked ~= vanilla.accuracyChecked + then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.kind, out.perform = "full", E.perform + cache[base] = out + return out +end +function E.tick(battle, user) + if (tonumber(user.encoreTurns) or 0) <= 0 then + return + end + local slot = user.encoreSlot or slotOf(user, user.encoreMove) + local move = slot and moves(user)[slot] + if not move or move.id ~= user.encoreMove then + clear(user) + return + end + user.encoreTurns = user.encoreTurns - 1 + if user.encoreTurns <= 0 or (tonumber(move.pp) or 0) <= 0 then + clear(user) + local name = user.isPlayer and user.name or ("Enemy " .. user.name) + battle:sayNext(Strings("%s's ENCORE\nended!", name)) + end +end +function E.signature(user) + return ":encore=" + .. tostring(user.encoreTurns) + .. "," + .. tostring(user.encoreMove) + .. "," + .. tostring(user.encoreSlot) +end +return E diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index a7d9ad24c..f00d0b46b 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -314,6 +314,9 @@ P.ENDURE_EFFECT = protection("enduring", -- ENCORE: the target must repeat its last move for a few turns. P.ENCORE_EFFECT = function(battle, user, target) + if require("src.core.GameVersion").isGen3() then + return require("src.battle.Gen3Encore").apply(battle, user, target) + end if target.encoreTurns or not target.lastMove then return { Strings("But, it failed!") } end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 29d75de5b..409303559 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 3 or nil + return require("src.core.GameVersion").isGen3() and 4 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index bee674572..559562785 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -163,6 +163,7 @@ local function activeStr(b, role) stageStr(b), ppStr(b.mon)) .. toxicSignature(b.mon) .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3LockOn").signature(b, role) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3BatonPass").signature(b, role) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Encore").signature(b) or "") end local function benchStr(party) @@ -431,6 +432,10 @@ function LinkBattle.new(game, net, opts) s:act(function() local theirAction = decodeTheirAction(s, theirMsg) + if require("src.core.GameVersion").isGen3() then + local Encore=require("src.battle.Gen3Encore") + myAction,theirAction=Encore.plan(s.player,myAction),Encore.plan(s.enemy,theirAction) + end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, playerAction = myAction, enemyAction = theirAction, @@ -832,6 +837,10 @@ function LinkBattle.newSpectator(game, net, opts) and decodeWireAction(s, hostMsg, s.player) or nil local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" and decodeWireAction(s, guestMsg, s.enemy) or nil + if require("src.core.GameVersion").isGen3() then + local Encore=require("src.battle.Gen3Encore") + hostAction,guestAction=Encore.plan(s.player,hostAction),Encore.plan(s.enemy,guestAction) + end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, playerAction = hostAction, enemyAction = guestAction, diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua index 5daa69311..d6523c276 100644 --- a/src/mods/BattleIntents.lua +++ b/src/mods/BattleIntents.lua @@ -98,6 +98,7 @@ local BATTLER_FLAGS = { "bideTurns", "encoreTurns", "encoreMove", + "encoreSlot", "bwSkyDropped", "bwChoiceLock", "bwImprison", diff --git a/tests/enemy_actions/encore.lua b/tests/enemy_actions/encore.lua new file mode 100644 index 000000000..207d28d56 --- /dev/null +++ b/tests/enemy_actions/encore.lua @@ -0,0 +1,425 @@ +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ { 227, "ENCORE", 90 }, { 119, "MIRROR_MOVE", 9 }, { 700, "MOD_CUSTOM", 0 } }) do + local number, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = name .. "_EFFECT", + gen3Effect = effect, + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + protectAffected = true, + } + if name == "MOD_CUSTOM" then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end + ) +end +function M.run(root, backend, version) + local source = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local draws, events = {}, {} + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target and e.target.mon.nickname, move = e.move.id } + end) + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function at(b, id) + return source and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, word) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not source then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if source then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not source then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if source then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 227, 10, 119 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.ENCORE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + { id = f.ids.MIRROR_MOVE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local value = lo == 0 and hi == 3 and word or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function number(id) + if source then + return tonumber(id) + end + return id == "UNKNOWN" and 999 + or id == f.ids.MOD_CUSTOM and 700 + or id and f.data.moves[id] and f.data.moves[id].index + end + local function view(a) + local pp = {} + for i = 1, 4 do + pp[i] = source and a.mon.pp[i] or a.mon.moves[i].pp + end + return { + turns = a[source and "expEncoreTurns" or "encoreTurns"], + move = number(a[source and "expEncoreMove" or "encoreMove"]), + slot = a[source and "expEncoreSlot" or "encoreSlot"], + hp = a.mon.hp, + pp = pp, + last = number(a[source and "lastMoveId" or "lastMove"]), + } + end + local function last(a, n) + a[source and "lastMoveId" or "lastMove"] = source and n + or n + and ({ + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [227] = f.ids.ENCORE, + [119] = f.ids.MIRROR_MOVE, + [165] = f.ids.STRUGGLE, + [999] = "UNKNOWN", + [700] = f.ids.MOD_CUSTOM, + })[n] + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if source then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function tick(b) + if source then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function record(b, label, uid, tid) + local field = {} + for id = 0, b.double and 3 or 1 do + field[#field + 1] = { name = at(b, id).mon.nickname, hp = at(b, id).mon.hp } + end + rows[#rows + 1] = { + label = label, + user = view(at(b, uid)), + target = view(at(b, tid)), + draws = Copy(draws), + events = Copy(events), + field = field, + } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for word = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "no_last", + "unknown", + "empty_pp", + "already", + "zero_counter", + "mirror", + "struggle", + "encore", + "protected", + "substitute", + "hidden", + "custom", + }) do + draws, events = {}, {} + local b = fresh(double, word) + local target = at(b, tid) + last(target, 33) + if kind == "no_last" then + last(target, nil) + end + if kind == "unknown" then + last(target, 999) + end + if kind == "mirror" then + last(target, 119) + end + if kind == "struggle" then + last(target, 165) + end + if kind == "encore" then + last(target, 227) + end + if kind == "empty_pp" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + end + if kind == "custom" then + last(target, 700) + if source then + target.mon.moves[1] = 700 + else + target.curMoves[1].id = f.ids.MOD_CUSTOM + end + end + if kind == "already" or kind == "zero_counter" then + target[source and "expEncoreTurns" or "encoreTurns"] = kind == "already" and 4 or 0 + end + if kind == "protected" then + target[source and "expProtected" or "protecting"] = true + end + if kind == "substitute" then + target.substituteHP = 25 + end + if kind == "hidden" then + if source then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + cast(b, uid, tid, 2) + if kind == "ordinary" or kind == "substitute" or kind == "zero_counter" or kind == "custom" then + T.eq( + target[source and "expEncoreTurns" or "encoreTurns"], + word + 3, + "native Encore duration consumes its own four-value roll" + ) + T.eq( + target[source and "expEncoreSlot" or "encoreSlot"], + 1, + "Encore records the original move slot" + ) + end + record(b, (double and "double" or "single") .. "/" .. uid .. "/" .. word .. "/" .. kind, uid, tid) + end + end + for _, kind in ipairs({ "expiry", "pp_depletion", "slot_replaced", "slot_swapped", "forced", "called" }) do + draws, events = {}, {} + local b = fresh(double, 3) + local target = at(b, tid) + last(target, 33) + cast(b, uid, tid, 2) + record(b, kind .. "/applied/" .. uid, uid, tid) + if kind == "pp_depletion" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + elseif kind == "slot_replaced" then + if source then + target.mon.moves[1] = 10 + else + target.curMoves[1].id = f.ids.SCRATCH + end + elseif kind == "slot_swapped" then + if source then + target.mon.moves[1], target.mon.moves[3] = target.mon.moves[3], target.mon.moves[1] + else + target.curMoves[1], target.curMoves[3] = target.curMoves[3], target.curMoves[1] + end + elseif kind == "forced" or kind == "called" then + cast(b, tid, uid, 3, kind == "called") + record(b, kind .. "/executed/" .. uid, uid, tid) + end + for step = 1, 6 do + tick(b) + record(b, kind .. "/residual/" .. step .. "/" .. uid, uid, tid) + end + T.eq( + target[source and "expEncoreTurns" or "encoreTurns"], + nil, + "Encore eventually clears through actual residuals" + ) + end + end + end + for uid = 0, 3 do + for _, kind in ipairs({ + "selected", + "follow_me", + "lightning_rod", + "self", + "random", + "chosen_random", + "absent_left", + "depends", + }) do + draws, events = {}, {} + local b = fresh(true, 0) + local foe = uid % 2 == 0 and uid + 1 or uid - 1 + local user = at(b, uid) + last(user, 33) + cast(b, foe, uid, 2) + local tackle = source and f.data.gen3Moves._rom[33] or f.data.moves[f.ids.TACKLE] + local scratch = source and f.data.gen3Moves._rom[10] or f.data.moves[f.ids.SCRATCH] + local oldType, oldTarget, oldChosen = tackle.type, tackle.target, scratch.target + tackle.target = kind == "self" and 16 or kind == "random" and 4 or kind == "depends" and 1 or 0 + if kind == "chosen_random" then + scratch.target = 4 + end + local opposite = uid % 2 == 0 and 1 or 0 + local right = opposite + 2 + if kind == "follow_me" then + if source then + (uid % 2 == 0 and b.enemySide or b.playerSide).expFollowMeId = right + else + b.sides[uid % 2 == 0 and 2 or 1]._gen3FollowMePosition = right + end + end + if kind == "lightning_rod" then + tackle.type = source and 13 or "ELECTRIC" + local holder = at(b, right) + if source then + holder.ability, holder.mon.ability = 31, 31 + else + holder.abilityOverride = "LIGHTNING_ROD" + end + end + if kind == "absent_left" then + at(b, opposite).mon.hp = 0 + b.absent = b.absent or {} + b.absent[opposite] = true + end + local count = 0 + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + count = count + 1 + value = count == 1 and uid or count == 2 and ((uid + 2) % 4) or right + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + cast(b, uid, foe, 3) + record(b, "retarget/" .. uid .. "/" .. kind, uid, foe) + tackle.type, tackle.target, scratch.target = oldType, oldTarget, oldChosen + end + end + for _, uid in ipairs({ 1, 3 }) do + draws, events = {}, {} + local b = fresh(true, 3) + last(at(b, uid), 33) + cast(b, 0, uid, 2) + f.e.choose(function() + return { move = "SCRATCH", slot = 3, target = 2 } + end) + if source then + local action = require("src.core.game3.battle.commands").enemyAction(b, uid) + T.eq(action.move, 10, "public mod supplies the requested alternative before native planning") + local Engine = require("src.core.game3.battle.engine") + local ad = require("src.core.game3.battle.adapter").new(b) + for _, row in ipairs(Engine.planTurnActions(b, ad, { [uid] = action })) do + if row.kind == "move" then + Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + local action = b:enemyAction(at(b, uid)) + T.eq(action.id, f.ids.SCRATCH, "public mod supplies the requested alternative before native planning") + action = require("src.battle.Gen3Encore").plan(at(b, uid), action) + b:executeAction(at(b, uid), at(b, 0), action) + end + f.e.choose(nil) + T.eq(events[#events].move, "TACKLE", "native planning enforces Encore after the public choice") + T.eq(events[#events].target, "P2", "early Encore planning retains the explicitly chosen target") + record(b, "planned_public/" .. uid, uid, 0) + end + noCrit() + stop() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 5701c7005..2c5ca17c4 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #217 with command revision 3. +-- Legacy hash audited against #214; native hash is #218 with command revision 4. local LEGACY = "e33d251961f2a41d" -local NATIVE = "c2a03195bc4588f9" +local NATIVE = "8be423c61a4477a2" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 3 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 4 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_encore.lua b/tests/engine/gen3_encore.lua new file mode 100644 index 000000000..a563c04d7 --- /dev/null +++ b/tests/engine/gen3_encore.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.encore").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Encore production, execution and expiry") diff --git a/tests/engine/gen3_encore_continuation.lua b/tests/engine/gen3_encore_continuation.lua new file mode 100644 index 000000000..a901d4249 --- /dev/null +++ b/tests/engine/gen3_encore_continuation.lua @@ -0,0 +1,134 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local Input = require("src.core.Input") +for _, version in ipairs({ "firered", "emerald" }) do + for _, selection in ipairs({ "fight", "late_move", "late_priority" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "PLAYER" + mon.moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.ENCORE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + mon.hp, mon.stats.hp = 400, 400 + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.nickname = "ENEMY" + b.enemy.mon.hp, b.enemy.mon.stats.hp = 400, 400 + b.enemy.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.player.encoreMove, b.player.encoreSlot, b.player.encoreTurns = f.ids.TACKLE, 1, 4 + b.player.lastMove = f.ids.TACKLE + b.phase, b.queue, b.frame, b.menuIndex, b.moveIndex = "menu", {}, 0, 1, 1 + if selection ~= "fight" then + b.phase = "moveSelect" + end + if selection == "late_priority" then + f.data.moves[f.ids.TACKLE].priority = -1 + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + end + game.stack.list[1] = b + f.e.choose(function() + return { move = "SCRATCH" } + end) + local used = {} + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, move = e.move.id } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local api = f.e.api.battle + local before = assert(api:snapshot()) + local raw = { id = 1, revision = before.revision, kind = selection == "fight" and "menu" or "move" } + if selection == "fight" then + raw.choice = "fight" + else + raw.slot = 3 + end + if selection == "late_move" then + b.player.encoreSlot = 2 + local accepted, why = api:submit(raw) + T.eq(accepted, nil, "a changed Encore slot invalidates the previously offered intent") + T.check(type(why) == "string", "stale Encore context returns an explanation") + b.player.encoreSlot = 1 + raw.revision = assert(api:snapshot()).revision + end + local accepted, why = api:submit(raw) + T.eq(accepted, true, "public command reaches native Encore continuation: " .. tostring(why)) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "Encore returns to the normal native menu") + T.eq(b.turnCount, 1, "forced continuation consumes exactly one turn") + T.eq(mon.moves[1].pp, 9, "only the forced learned slot spends PP") + T.eq(mon.moves[3].pp, 10, "the previously selected alternative keeps its PP") + T.eq(b.player.encoreTurns, 3, "real end-of-turn processing ages Encore once") + T.check(b.enemy.mon.hp < 400, "the forced move actually damages the opponent") + T.eq(#used, 2, "both native actions execute once") + if selection == "late_priority" then + T.eq( + used[1].user, + "ENEMY", + "turn order uses the forced move priority rather than the superseded choice" + ) + end + local playerMoves = 0 + for _, e in ipairs(used) do + if e.user == mon.nickname then + playerMoves = playerMoves + 1 + T.eq(e.move, "TACKLE", "public event names the actual forced move") + end + end + T.eq(playerMoves, 1, "the player emits one actual move event") + off() + f.close() + end + for _, mode in ipairs({ "override", "delegate" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.target.lastMove = f.ids.TACKLE + c.user.mon.moves = { { id = f.ids.ENCORE, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local custom = Copy(b.data.move_effects.ENCORE_EFFECT) + local builtin, calls = custom.run, 0 + custom.run = function(ctx) + calls = calls + 1 + return mode == "delegate" and builtin(ctx) or { "CUSTOM" } + end + b.data.move_effects.ENCORE_EFFECT = custom + b.rng = function(_, hi) + return hi + end + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(calls, 1, "authored Encore handler runs exactly once") + T.eq(c.user.mon.moves[1].pp, 9, "authored handler keeps native PP ownership") + T.eq( + c.target.encoreTurns, + mode == "delegate" and 6 or nil, + "authored handler retains override or native delegation" + ) + T.eq(c.target.encoreSlot, mode == "delegate" and 1 or nil, "native delegation retains slot binding") + f.close() + end +end +local E = require("src.battle.Gen3Encore") +for _, key in ipairs({ "encoreTurns", "encoreMove", "encoreSlot" }) do + T.neq(E.signature({ [key] = 1 }), E.signature({}), "native hashes include " .. key) +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local target = { name = "TARGET", isPlayer = true, lastMove = "LEGACY" } + require("src.battle.Gen3MoveEffects").primary.ENCORE_EFFECT({}, {}, target) + T.eq(target.encoreTurns, 3, "older games keep their existing Encore duration") + T.eq(target.encoreSlot, nil, "older games do not acquire native slot semantics") +end +T.finish("native Encore public commands, queued turns and legacy policies") diff --git a/tests/engine/gen3_encore_link.lua b/tests/engine/gen3_encore_link.lua new file mode 100644 index 000000000..9e90a55f9 --- /dev/null +++ b/tests/engine/gen3_encore_link.lua @@ -0,0 +1,90 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 3 + T.eq(H.checkCompat(current, older), "subset", "pre-Encore rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.ENCORE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 0 and 1 or turn == 1 and 2 or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.eq(a.enemy.encoreMove, f.ids.TACKLE, "host retains the remote forced move") + T.eq(b.player.encoreMove, f.ids.TACKLE, "guest retains its own forced move") + T.eq(a.enemy.encoreSlot, 1, "host retains the native original slot") + T.eq(b.player.encoreSlot, 1, "guest retains the native original slot") + T.eq(a.enemy.encoreTurns, b.player.encoreTurns, "native duration agrees after a real full turn") + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the native Encore application turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 7, "remote and local forced Tackle consume their real slot three times") + T.eq(guest.mon.moves[2].pp, 10, "Encore prevents the slower already-selected Encore action") + T.eq(guest.mon.moves[3].pp, 10, "Encore prevents the next independently selected Scratch action") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Encore real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 181526432..76516c2e7 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index b5c24e5b7..ad77a6795 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "baton_pass" then +if mode == "encore" then + rows = dofile(root .. "/tests/enemy_actions/encore.lua").run(root, backend, version) +elseif mode == "baton_pass" then rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version) elseif mode == "lock_on" then rows = dofile(root .. "/tests/enemy_actions/lock_on.lua").run(root, backend, version) From a17ff34cd267c3edd26ae3f93b524053f14f34b8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:49:14 +0100 Subject: [PATCH 50/90] Declare the prepared native order input for Encore planning contracts --- docs/native-gen3-encore.md | 7 +++++++ tests/enemy_actions/encore.lua | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/docs/native-gen3-encore.md b/docs/native-gen3-encore.md index d07f8bc8e..aaf5cd511 100644 --- a/docs/native-gen3-encore.md +++ b/docs/native-gen3-encore.md @@ -42,6 +42,13 @@ The reference's `expEncore*` fields and numeric move IDs are mapped explicitly to the host's native fields and fixture registry IDs. Failed/raw reports remain under `tmp/encore-qa/`; the checker never edits source code or filters calls. +The two public-choice/planning cases start with the same explicit prepared +Quick Claw word. The combined fork's C-validated native AI-order controller +prepares that word earlier than Gen1's standalone command; the original failed +comparison retains that extra `0..65535` call. This is an input-state boundary, +not proof that the engines' complete selection/RNG orchestration agrees. The +comparison retains every subsequent bounded draw and records the prepared word. + Development acceptance is in progress. Full regression, clean-commit engine comparisons, imported-record smoke, combined-fork acceptance and publication remain to be verified. These CPU fixtures do not certify a complete playthrough, diff --git a/tests/enemy_actions/encore.lua b/tests/enemy_actions/encore.lua index 207d28d56..e77c956d0 100644 --- a/tests/enemy_actions/encore.lua +++ b/tests/enemy_actions/encore.lua @@ -393,6 +393,10 @@ function M.run(root, backend, version) local b = fresh(true, 3) last(at(b, uid), 33) cast(b, 0, uid, 2) + -- This contract starts at the prepared selection boundary. The combined + -- fork owns an earlier cartridge-faithful Quick Claw word; Gen1's standalone + -- command does not prepare it. Give both engines the same explicit input. + b.randomTurnNumber, b._nativeOrderReady = 65535, true f.e.choose(function() return { move = "SCRATCH", slot = 3, target = 2 } end) @@ -416,6 +420,7 @@ function M.run(root, backend, version) T.eq(events[#events].move, "TACKLE", "native planning enforces Encore after the public choice") T.eq(events[#events].target, "P2", "early Encore planning retains the explicitly chosen target") record(b, "planned_public/" .. uid, uid, 0) + rows[#rows].preparedTurnWord = b.randomTurnNumber end noCrit() stop() From 6998a429a99dcc94d3f7c8b9a337ddfbcdaea3ba Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:57:33 +0100 Subject: [PATCH 51/90] Preserve declared turn actions and planned move PP bindings --- docs/native-gen3-encore.md | 4 +- src/battle/BattleState.lua | 3 +- src/battle/EnemyMoveChoice.lua | 9 +++ src/battle/Gen3Encore.lua | 7 +-- src/link/LinkBattle.lua | 6 +- tests/enemy_actions/encore.lua | 71 ++++++++++++++--------- tests/engine/gen3_encore_continuation.lua | 10 ++++ 7 files changed, 71 insertions(+), 39 deletions(-) diff --git a/docs/native-gen3-encore.md b/docs/native-gen3-encore.md index aaf5cd511..7f7932c15 100644 --- a/docs/native-gen3-encore.md +++ b/docs/native-gen3-encore.md @@ -15,6 +15,8 @@ recomputing its target with native bounded draws and redirection. Execution spends the actual learned slot's PP, preserves called-move PP rules and emits the actual move through public events. Forced FIGHT returns a native move instance rather than an unrecognized nested descriptor. +`battle.turn_started` retains the original declared action. A planned action +also retains its PP binding if Encore is cleared before execution. Actual residual processing cancels Encore on expiry, depleted PP or a changed original slot. It does not transfer through Baton Pass. Link state components @@ -42,7 +44,7 @@ The reference's `expEncore*` fields and numeric move IDs are mapped explicitly to the host's native fields and fixture registry IDs. Failed/raw reports remain under `tmp/encore-qa/`; the checker never edits source code or filters calls. -The two public-choice/planning cases start with the same explicit prepared +The public-choice/planning cases start with the same explicit prepared Quick Claw word. The combined fork's C-validated native AI-order controller prepares that word earlier than Gen1's standalone command; the original failed comparison retains that extra `0..65535` call. This is an input-state boundary, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1111ef541..3df819a43 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5528,6 +5528,7 @@ function BattleState:resolveTurn(playerAction) -- nil in a double battle whose left-hand foe has fainted with nothing on -- the bench to replace it; `actionFor` asks each surviving slot for its own local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) + local selectedPlayerAction, selectedEnemyAction = playerAction, enemyAction if GameVersion.isGen3() then local Encore = require("src.battle.Gen3Encore") playerAction, enemyAction = Encore.plan(self.player, playerAction), Encore.plan(self.enemy, enemyAction) @@ -5550,7 +5551,7 @@ function BattleState:resolveTurn(playerAction) end Runtime.emit("battle.turn_started", { battle = self, turn = self.turnCount, - playerAction = playerAction, enemyAction = enemyAction, + playerAction = selectedPlayerAction, enemyAction = selectedEnemyAction, }) local pMove = orderMove(playerAction, self.data) local eMove = orderMove(enemyAction, self.data) diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index 0244e6009..a6cf7a10d 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -82,6 +82,15 @@ end function M.slot(action) return slots[action] or action end +function M.withMove(action, id) + local copy = shallow(action) + copy.id = id + if nativeViews[action] then + nativeViews[copy] = shallow(nativeViews[action]) + end + publicReferences[copy] = publicReferences[action] + return bind(copy, M.slot(action)) +end function M.exhausted(action) return slots[action] ~= nil and action.pp <= 0 end diff --git a/src/battle/Gen3Encore.lua b/src/battle/Gen3Encore.lua index 4332355ec..90142ede3 100644 --- a/src/battle/Gen3Encore.lua +++ b/src/battle/Gen3Encore.lua @@ -77,12 +77,7 @@ function E.plan(user, action) end -- The planner changes move priority now, while retaining the chosen target -- and slot descriptor. Execution later binds PP to the actual Encore slot. - local out = {} - for key, value in pairs(action) do - out[key] = value - end - out.id, out.pp = forced.id, action.pp - return out + return require("src.battle.EnemyMoveChoice").withMove(action, forced.id) end function E.targets(battle, user, move, chosen) local Targeting, Abilities = require("src.battle.Targeting"), require("src.battle.Abilities") diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 559562785..8395fcd41 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -432,13 +432,14 @@ function LinkBattle.new(game, net, opts) s:act(function() local theirAction = decodeTheirAction(s, theirMsg) + local selectedMine,selectedTheirs=myAction,theirAction if require("src.core.GameVersion").isGen3() then local Encore=require("src.battle.Gen3Encore") myAction,theirAction=Encore.plan(s.player,myAction),Encore.plan(s.enemy,theirAction) end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, - playerAction = myAction, enemyAction = theirAction, + playerAction = selectedMine, enemyAction = selectedTheirs, }) if myAction and theirAction then -- the tie-break roll is shared: the guest inverts it so both @@ -837,13 +838,14 @@ function LinkBattle.newSpectator(game, net, opts) and decodeWireAction(s, hostMsg, s.player) or nil local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" and decodeWireAction(s, guestMsg, s.enemy) or nil + local selectedHost,selectedGuest=hostAction,guestAction if require("src.core.GameVersion").isGen3() then local Encore=require("src.battle.Gen3Encore") hostAction,guestAction=Encore.plan(s.player,hostAction),Encore.plan(s.enemy,guestAction) end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, - playerAction = hostAction, enemyAction = guestAction, + playerAction = selectedHost, enemyAction = selectedGuest, }) if hostAction and guestAction then local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction) diff --git a/tests/enemy_actions/encore.lua b/tests/enemy_actions/encore.lua index e77c956d0..36b88b2b7 100644 --- a/tests/enemy_actions/encore.lua +++ b/tests/enemy_actions/encore.lua @@ -389,38 +389,51 @@ function M.run(root, backend, version) end end for _, uid in ipairs({ 1, 3 }) do - draws, events = {}, {} - local b = fresh(true, 3) - last(at(b, uid), 33) - cast(b, 0, uid, 2) - -- This contract starts at the prepared selection boundary. The combined - -- fork owns an earlier cartridge-faithful Quick Claw word; Gen1's standalone - -- command does not prepare it. Give both engines the same explicit input. - b.randomTurnNumber, b._nativeOrderReady = 65535, true - f.e.choose(function() - return { move = "SCRATCH", slot = 3, target = 2 } - end) - if source then - local action = require("src.core.game3.battle.commands").enemyAction(b, uid) - T.eq(action.move, 10, "public mod supplies the requested alternative before native planning") - local Engine = require("src.core.game3.battle.engine") - local ad = require("src.core.game3.battle.adapter").new(b) - for _, row in ipairs(Engine.planTurnActions(b, ad, { [uid] = action })) do - if row.kind == "move" then - Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + for _, state in ipairs({ "active", "cleared_after_planning" }) do + draws, events = {}, {} + local b = fresh(true, 3) + last(at(b, uid), 33) + cast(b, 0, uid, 2) + -- This contract starts at the prepared selection boundary. The combined + -- fork owns an earlier cartridge-faithful Quick Claw word; Gen1's standalone + -- command does not prepare it. Give both engines the same explicit input. + b.randomTurnNumber, b._nativeOrderReady = 65535, true + local function cancel() + if state == "cleared_after_planning" then + local actor = at(b, uid) + actor[source and "expEncoreTurns" or "encoreTurns"] = nil + actor[source and "expEncoreMove" or "encoreMove"] = nil + actor[source and "expEncoreSlot" or "encoreSlot"] = nil end end - else - local action = b:enemyAction(at(b, uid)) - T.eq(action.id, f.ids.SCRATCH, "public mod supplies the requested alternative before native planning") - action = require("src.battle.Gen3Encore").plan(at(b, uid), action) - b:executeAction(at(b, uid), at(b, 0), action) + f.e.choose(function() + return { move = "SCRATCH", slot = 3, target = 2 } + end) + if source then + local action = require("src.core.game3.battle.commands").enemyAction(b, uid) + T.eq(action.move, 10, "public mod supplies the requested alternative before native planning") + local Engine = require("src.core.game3.battle.engine") + local ad = require("src.core.game3.battle.adapter").new(b) + local planned = Engine.planTurnActions(b, ad, { [uid] = action }) + cancel() + for _, row in ipairs(planned) do + if row.kind == "move" then + Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + local action = b:enemyAction(at(b, uid)) + T.eq(action.id, f.ids.SCRATCH, "public mod supplies the requested alternative before native planning") + action = require("src.battle.Gen3Encore").plan(at(b, uid), action) + cancel() + b:executeAction(at(b, uid), at(b, 0), action) + end + f.e.choose(nil) + T.eq(events[#events].move, "TACKLE", "native planning enforces Encore after the public choice") + T.eq(events[#events].target, "P2", "early Encore planning retains the explicitly chosen target") + record(b, "planned_public/" .. state .. "/" .. uid, uid, 0) + rows[#rows].preparedTurnWord = b.randomTurnNumber end - f.e.choose(nil) - T.eq(events[#events].move, "TACKLE", "native planning enforces Encore after the public choice") - T.eq(events[#events].target, "P2", "early Encore planning retains the explicitly chosen target") - record(b, "planned_public/" .. uid, uid, 0) - rows[#rows].preparedTurnWord = b.randomTurnNumber end noCrit() stop() diff --git a/tests/engine/gen3_encore_continuation.lua b/tests/engine/gen3_encore_continuation.lua index a901d4249..73beeadb0 100644 --- a/tests/engine/gen3_encore_continuation.lua +++ b/tests/engine/gen3_encore_continuation.lua @@ -34,6 +34,10 @@ for _, version in ipairs({ "firered", "emerald" }) do return { move = "SCRATCH" } end) local used = {} + local declared + local offStart = f.e.api.events:on("battle.turn_started", function(e) + declared = e.playerAction.id + end) local off = f.e.api.events:on("battle.move_used", function(e) used[#used + 1] = { user = e.user.mon.nickname, move = e.move.id } end) @@ -72,6 +76,11 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(b.player.encoreTurns, 3, "real end-of-turn processing ages Encore once") T.check(b.enemy.mon.hp < 400, "the forced move actually damages the opponent") T.eq(#used, 2, "both native actions execute once") + T.eq( + declared, + selection == "fight" and "TACKLE" or "SCRATCH", + "turn-start event retains the original declared choice" + ) if selection == "late_priority" then T.eq( used[1].user, @@ -88,6 +97,7 @@ for _, version in ipairs({ "firered", "emerald" }) do end T.eq(playerMoves, 1, "the player emits one actual move event") off() + offStart() f.close() end for _, mode in ipairs({ "override", "delegate" }) do From 313eb7a1841844aad124e1c60088dcdd20755574 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:04:14 +0100 Subject: [PATCH 52/90] Record native Encore contract and combined-fork acceptance --- docs/native-gen3-encore.md | 46 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/native-gen3-encore.md b/docs/native-gen3-encore.md index 7f7932c15..2e6469621 100644 --- a/docs/native-gen3-encore.md +++ b/docs/native-gen3-encore.md @@ -51,7 +51,45 @@ comparison retains that extra `0..65535` call. This is an input-state boundary, not proof that the engines' complete selection/RNG orchestration agrees. The comparison retains every subsequent bounded draw and records the prepared word. -Development acceptance is in progress. Full regression, clean-commit engine -comparisons, imported-record smoke, combined-fork acceptance and publication -remain to be verified. These CPU fixtures do not certify a complete playthrough, -GPU/pixel equality, global RNG/VBlank scheduling or every other native producer. +## Validation + +Clean feature runtime `6998a429a99dcc94d3f7c8b9a337ddfbcdaea3ba` passes: + +- **637 focused Encore checks**: 480 command/lifecycle, 99 public-command/ + queue/authored-policy/legacy checks, and 58 actual link/compatibility checks. + The native protocol suite also passes 154 checks. +- **92 workers / 61,356 paired observations** agree with clean Gen1 + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new mode retains 612 complete + observations per profile, including all four slots, all duration draws, + native failure guards, custom moves without cartridge indices, actual + residual expiry/slot edits, called moves, late retargeting, public choices + and cancellation after planning. No compared observations or calls are removed. +- Full ROM-free feature run: **113/122 engine suites**, SDK 3/3 and B30. + The exact Counter of all **25 inherited FAIL lines across nine suites** equals + clean #218 (110/119); the combined fork carries their independent repairs. +- The same public canonical command on unchanged #218 returns three phases + and no slot binding, failing both native assertions. Its raw state/failures + remain in `tmp/encore-qa/negative-command.log`. +- Both link roles complete three ordinary native turns: Tackle, a faster + Encore overriding the queued opposing Encore, then an independently selected + Scratch overridden again. Actual PP ownership and every paired completed + turn hash/state component agree. Older rules are refused even with an equal + custom fingerprint. +- Actual local FireRed/Emerald records pass four CPU-only scenarios: the public + canonical enemy selector applies Encore, then public FIGHT or a separately + selected move runs a real turn. The bound Tackle slot spends PP once and the + actual residual scheduler ages the timer. No ROM/cache assets or saves are + distributed, and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Clean combined runtime `ae385bae803e8a94eb4b2c73e2c1425d1a68868d` passes all +**234/234 engine suites**, SDK 3/3, B30, the same 92/61,356 comparisons and the +same imported-record smoke. Integration preserves the existing native AI-order +word preparation and both preselected double-battle enemy actions. Its first +unprepared-input mismatch is retained in `tmp/encore-contracts-first.json`. + +These CPU fixtures do not certify a complete playthrough, GPU/pixel equality, +global RNG/VBlank orchestration, or every other native state producer and its +interactions (including legacy repeated-move controllers). The broader native +alignment goal remains open. Publication and hosted-CI status are recorded in +the PR and the combined testing document. From 55f4301ab6ef190051b1f6d420f22636a61a1809 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:29:29 +0100 Subject: [PATCH 53/90] Implement native GBA Disable identity and execution lifecycle --- .github/workflows/enemy-action-contract.yml | 1 + src/battle/BattleCommands.lua | 4 +- src/battle/BattleState.lua | 24 +- src/battle/Gen3AI.lua | 4 +- src/battle/Gen3Disable.lua | 193 +++++++++ src/battle/Status.lua | 9 +- src/battle/TrainerAI.lua | 2 +- src/link/Fingerprint.lua | 2 +- src/link/LinkBattle.lua | 1 + src/mods/BattleIntents.lua | 2 + src/mods/BattleSnapshots.lua | 2 +- src/mods/Builtins.lua | 9 +- tests/enemy_actions/disable.lua | 431 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_disable.lua | 5 + tests/engine/gen3_disable_continuation.lua | 196 +++++++++ tests/engine/gen3_disable_link.lua | 92 ++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 19 files changed, 966 insertions(+), 23 deletions(-) create mode 100644 src/battle/Gen3Disable.lua create mode 100644 tests/enemy_actions/disable.lua create mode 100644 tests/engine/gen3_disable.lua create mode 100644 tests/engine/gen3_disable_continuation.lua create mode 100644 tests/engine/gen3_disable_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index f0c26f789..4e0a8c1bf 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -26,6 +26,7 @@ on: - 'tests/engine/gen3_lock_on_link.lua' - 'tests/engine/gen3_baton_pass*.lua' - 'tests/engine/gen3_encore*.lua' + - 'tests/engine/gen3_disable*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua index 9c0778a86..7a1559729 100644 --- a/src/battle/BattleCommands.lua +++ b/src/battle/BattleCommands.lua @@ -57,7 +57,7 @@ function Methods.chooseMove(self, index) self.moveIndex = index local mv = moves[self.moveIndex] local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then + if require("src.battle.Gen3Disable").blocked(chooser, mv, self.moveIndex) then self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) self.phase = "messages" self.afterQueue = "menu" @@ -175,7 +175,7 @@ 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 + if require("src.battle.Gen3Disable").blocked(actor, move, slot) then return "move is disabled" end if (move.pp or 0) <= 0 then diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3df819a43..87c382e62 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4042,7 +4042,7 @@ function BattleState:playerHasPP(who) who = who or self:menuBattler() if not (who and who.curMoves) then return false end for i, mv in ipairs(who.curMoves) do - if mv.pp > 0 and who.disabledSlot ~= i then return true end + if mv.pp > 0 and not require("src.battle.Gen3Disable").blocked(who, mv, i) then return true end end return false end @@ -5130,6 +5130,12 @@ function BattleState:effectRecord(effect) end local effects = self.data.move_effects local record = effects and effects[effect] or not effects and MoveEffects.RECORDS[effect] + if GameVersion.isGen3() and not effects then + record = require("src.battle.Gen3Disable").builtin(effect, record) + end + if GameVersion.isGen3() and effect == "DISABLE_EFFECT" then + return require("src.battle.Gen3Disable").record(record, MoveEffects.RECORDS.DISABLE_EFFECT) + end if GameVersion.isGen3() and effect == "OHKO_EFFECT" then return require("src.battle.Gen3OHKO").record(record, MoveEffects.RECORDS.OHKO_EFFECT) end @@ -6523,6 +6529,7 @@ end function BattleState:gen3BattlerCountersTick(b, scheduled) if b and b.mon and b.mon.hp > 0 then if GameVersion.isGen3() then + require("src.battle.Gen3Disable").tick(self, b) require("src.battle.Gen3Encore").tick(self, b) require("src.battle.Gen3LockOn").tick(self, b) end @@ -7733,12 +7740,13 @@ function BattleState:executeAction(user, target, action) return end if action.special == "bide" then - if self:statusInterrupt(user, target) then return end + local move = GameVersion.isGen3() and require("src.battle.Gen3Disable").bideMove(self, user) or nil + if self:statusInterrupt(user, target, move) then return end self:continueBide(user, target) return end - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, action) then return end -- ONE MOVE, POSSIBLY SEVERAL TARGETS. -- -- The cartridge prints "X used SURF!" once and then resolves the move @@ -7887,7 +7895,7 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. -function BattleState:statusInterrupt(user, target) +function BattleState:statusInterrupt(user, target, move) -- TRUANT, before the conditions. SLAKING and SLAKOTH move every OTHER -- turn: the cartridge sets a flag after a move and reads it before the -- next one, which is why this is a toggle rather than "every second turn @@ -7901,8 +7909,12 @@ function BattleState:statusInterrupt(user, target) end user.truantLoafing = true end - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) + local canMove, msgs, selfHit, reason = Status.beforeMove(user, self.rng, self, move) for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end + if reason == "gen3_disabled" then + require("src.battle.Gen3Disable").cancel(user) + return true + end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a @@ -12731,7 +12743,7 @@ function BattleState:drawTextAreaInner() local sel = chooser.curMoves[self.moveIndex] if sel then local def = self.data.moves[sel.id] - if chooser.disabledSlot == self.moveIndex then + if require("src.battle.Gen3Disable").blocked(chooser, sel, self.moveIndex) then Font.draw(Strings("disabled!"), 8, 80) elseif def then Font.draw(Strings("TYPE/"), 8, 72) diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index a9ae8df58..f4a1c1bf4 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -652,14 +652,14 @@ end OPS["if_any_move_disabled_or_encored"] = function(vm, args) local b = who(vm, args[1]) if not b then return end - local hit = (args[2] == 0) and b.disabledSlot ~= nil + local hit = (args[2] == 0) and require("src.battle.Gen3Disable").has(b) or (args[2] == 1) and b.encoreTurns ~= nil if hit then return args[#args] end end OPS["if_curr_move_disabled_or_encored"] = function(vm, args) local b = vm.user - local hit = (args[1] == 0) and b and b.disabledSlot == vm.moveIndex + local hit = (args[1] == 0) and b and require("src.battle.Gen3Disable").blocked(b, (b.curMoves or {})[vm.moveIndex], vm.moveIndex) or (args[1] == 1) and b and b.encoreTurns ~= nil if hit then return args[#args] end end diff --git a/src/battle/Gen3Disable.lua b/src/battle/Gen3Disable.lua new file mode 100644 index 000000000..dad4e6ac4 --- /dev/null +++ b/src/battle/Gen3Disable.lua @@ -0,0 +1,193 @@ +-- GBA Disable follows a move identity, not its current menu slot. +local D = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local builtins = setmetatable({}, { __mode = "k" }) +local records = setmetatable({}, { __mode = "k" }) +local function moves(user) + return user.curMoves or user.mon and user.mon.moves or {} +end +function D.slot(user) + if not user then + return nil + end + if V.isGen3() and user.disabledMove then + for i, move in ipairs(moves(user)) do + if i > 4 then + break + end + if move.id == user.disabledMove then + return i + end + end + return nil + end + return user.disabledSlot +end +function D.blocked(user, move, slot) + if not user then + return false + end + if V.isGen3() and user.disabledMove then + return move ~= nil and move.id == user.disabledMove + end + return slot ~= nil and user.disabledSlot == slot +end +function D.has(user) + return user ~= nil and (not not (V.isGen3() and user.disabledMove) or user.disabledSlot ~= nil) +end +local function clear(user) + user.disabledMove, user.disabledTurns, user.disabledSlot = nil, nil, nil +end +function D.apply(battle, _, target) + local slot + for i, move in ipairs(moves(target)) do + if i > 4 then + break + end + if target.lastMove and move.id == target.lastMove then + slot = i + break + end + end + local move = slot and moves(target)[slot] + if D.has(target) or not move or (tonumber(move.pp) or 0) <= 0 then + return { Strings("But, it failed!") }, false + end + target.disabledMove, target.disabledTurns = move.id, battle.rng(0, 3) % 4 + 2 + return { Strings("%s's\n%s was\ndisabled!", target.name, battle.data.moves[move.id].name) }, true +end +function D.run(ctx) + if V.isGen3() then + return D.apply(ctx.battle, ctx.user, ctx.target) + end + return require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT.run(ctx) +end +function D.perform(ctx) + local hit, why = ctx.battle:accuracyRoll(ctx.move, ctx.user, ctx.target) + if not hit then + ctx.battle:cancelMoveAnim() + if why == "protected" then + ctx.say(Strings("%s\nprotected itself!", ctx.displayName(ctx.target))) + elseif why ~= "absorbed" then + ctx.say(Strings("%s's\nattack missed!", ctx.displayName(ctx.user))) + end + return + end + local messages, success = D.apply(ctx.battle, ctx.user, ctx.target) + if not success then + ctx.battle:cancelMoveAnim() + end + for _, message in ipairs(messages) do + ctx.say(message) + end +end +-- Adapt records per dataset before mods capture/delegate their builtin run. +-- The legacy module's shared records remain untouched, including on reload. +function D.builtin(id, base) + if not base or not V.isGen3() then + return base + end + if id ~= "DISABLE_EFFECT" and id ~= "HAZE_EFFECT" and id ~= "TRANSFORM_EFFECT" then + return base + end + if builtins[base] then + return builtins[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + if id == "DISABLE_EFFECT" then + out.run = D.run + elseif id == "HAZE_EFFECT" then + out.run = function(ctx) + local saved = {} + if V.isGen3() then + for pos = 0, 3 do + local b = ctx.battle.battlerAt and ctx.battle:battlerAt(pos) + or (pos == 0 and ctx.battle.player or pos == 1 and ctx.battle.enemy) + if b and b.disabledMove then + saved[b] = { b.disabledMove, b.disabledTurns, b.disabledSlot } + end + end + end + local result, success = base.run(ctx) + -- Native Haze preserves Disable. Other legacy Haze behavior is separate. + for b, state in pairs(saved) do + b.disabledMove, b.disabledTurns, b.disabledSlot = unpack(state) + end + return result, success + end + else + out.run = function(ctx) + local before = ctx.user.curMoves + local result, success = base.run(ctx) + if V.isGen3() and ctx.user.disabledMove and ctx.user.curMoves ~= before then + clear(ctx.user) + end + return result, success + end + end + builtins[base] = out + return out +end +function D.record(base, vanilla) + if + not base + or (base.run ~= vanilla.run and base.run ~= D.run) + or base.perform + or base.accuracyChecked ~= vanilla.accuracyChecked + then + return base + end + if records[base] then + return records[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.kind, out.perform = "full", D.perform + records[base] = out + return out +end +function D.tick(battle, user) + if not user.disabledMove then + return + end + if not D.slot(user) then + clear(user) + return + end + user.disabledTurns = (user.disabledTurns or 1) - 1 + if user.disabledTurns <= 0 then + clear(user) + battle:sayNext(Strings("%s's\ndisabled no more!", user.name)) + end +end +function D.cancel(user) + user.bideTurns = nil + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + user.charging, user.chargeReady, user.invulnerable, user.hiddenAs = nil, nil, nil, nil + user.rampMove, user.rampRun, user.uproarTurns = nil, nil, nil + user.lastMove = nil +end +function D.bideMove(battle, user) + local function bide(id) + local def = id and battle.data.moves[id] + return def and (tonumber(def.index) == 117 or def.effect == "BIDE_EFFECT") + end + if bide(user.lastMove) then + return { id = user.lastMove } + end + for _, move in ipairs(moves(user)) do + if bide(move.id) then + return move + end + end +end +function D.signature(user) + return ":disable=" .. tostring(user.disabledMove) .. "," .. tostring(user.disabledTurns) +end +return D diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 17fd584a7..8fd084699 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -243,7 +243,7 @@ end -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. -function Status.beforeMove(battler, rng, battle) +function Status.beforeMove(battler, rng, battle, move) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -275,7 +275,12 @@ function Status.beforeMove(battler, rng, battle) msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) return false, msgs end - if battler.disabledTurns then + if require("src.core.GameVersion").isGen3() and battler.disabledMove then + if require("src.battle.Gen3Disable").blocked(battler, move) then + msgs[#msgs + 1] = Strings("%s's\nmove is disabled!", name(battler)) + return false, msgs, false, "gen3_disabled" + end + elseif battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 4247a725a..debe8b331 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -337,7 +337,7 @@ function TrainerAI.chooseMove(battler, rng, battle) local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP local usable = {} for i, mv in ipairs(battler.curMoves) do - if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then + if not require("src.battle.Gen3Disable").blocked(battler, mv, i) and (unlimited or mv.pp > 0) then table.insert(usable, mv) end end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 409303559..953d78735 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 4 or nil + return require("src.core.GameVersion").isGen3() and 5 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 8395fcd41..fce669d22 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -164,6 +164,7 @@ local function activeStr(b, role) .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3LockOn").signature(b, role) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3BatonPass").signature(b, role) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Encore").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Disable").signature(b) or "") end local function benchStr(party) diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua index d6523c276..2a5473db6 100644 --- a/src/mods/BattleIntents.lua +++ b/src/mods/BattleIntents.lua @@ -86,6 +86,8 @@ local BATTLE_FLAGS = { } local BATTLER_FLAGS = { "disabledSlot", + "disabledMove", + "disabledTurns", "tauntTurns", "tormented", "lastMove", diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua index 1fce0a816..4c2c108ba 100644 --- a/src/mods/BattleSnapshots.lua +++ b/src/mods/BattleSnapshots.lua @@ -369,7 +369,7 @@ local function build(game, battle, covered, 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 + row.disabled = require("src.battle.Gen3Disable").blocked(actor, move, 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) diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 470f67ad7..3c30ae7b6 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -7,7 +7,8 @@ -- tables, or an edit through one dataset (hot reload, a test loading -- twice) reaches the other and the module's own statics. Functions ride -- the copy by reference, so handlers keep their identity and the merged --- value stays equal to the vanilla one -- the mod-free merge is a no-op. +-- value stays equal to vanilla. Native GBA command adapters are installed +-- per dataset before mods capture builtin handlers for delegation. -- Modules are required lazily -- the loader must not drag the battle and -- script stacks in with it at require time. local Logger = require("src.core.Logger") @@ -83,9 +84,10 @@ end -- the write verbs copy their payload before it lands; centralized here so -- the isolation holds for every registrant instead of leaning on each -- module to hand over fresh tables -local function isolate(registry) +local function isolate(registry, adapt) return setmetatable({ register = function(_, id, value, owner) + if adapt then value = adapt(id, value) end return registry:register(id, Merge.deepCopy(value), owner) end, override = function(_, id, value, owner) @@ -99,7 +101,8 @@ end function Builtins.install(content, data) for _, entry in ipairs(REGISTRANTS) do - local registry = content[entry.name] and isolate(content[entry.name]) + local adapt = entry.name == "move_effects" and require("src.battle.Gen3Disable").builtin or nil + local registry = content[entry.name] and isolate(content[entry.name], adapt) if registry then if entry.install then local modules, complete = {}, true diff --git a/tests/enemy_actions/disable.lua b/tests/enemy_actions/disable.lua new file mode 100644 index 000000000..21349afee --- /dev/null +++ b/tests/enemy_actions/disable.lua @@ -0,0 +1,431 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ + { 50, "DISABLE", 86 }, + { 114, "HAZE", 25 }, + { 144, "TRANSFORM", 57 }, + { 117, "BIDE", 26 }, + { 700, "MOD_CUSTOM", 0 }, + }) do + local number, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = name .. "_EFFECT", + gen3Effect = effect, + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + protectAffected = true, + } + if name == "MOD_CUSTOM" then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local source = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local draws, events = {}, {} + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target and e.target.mon.nickname, move = e.move.id } + end) + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function at(b, id) + return source and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, word) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not source then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if source then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not source then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if source then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 50, 10, 114 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.DISABLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + { id = f.ids.HAZE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local value = lo == 0 and hi == 3 and word or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local nums = { + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [50] = f.ids.DISABLE, + [114] = f.ids.HAZE, + [144] = f.ids.TRANSFORM, + [700] = f.ids.MOD_CUSTOM, + [999] = "UNKNOWN", + [117] = f.ids.BIDE, + } + local function number(id) + if source then + return tonumber(id) + end + for num, name in pairs(nums) do + if name == id then + return num + end + end + end + local function last(a, num) + a[source and "lastMoveId" or "lastMove"] = source and num or nums[num] + end + local function setMove(a, slot, num) + if source then + a.mon.moves[slot] = num + else + a.curMoves[slot].id = nums[num] + end + end + local function setDisabled(a, num, turns) + a[source and "expDisabledMove" or "disabledMove"] = source and num or nums[num] + a[source and "expDisableTurns" or "disabledTurns"] = turns + end + local function view(a) + local pp, learned = {}, {} + for i = 1, 4 do + pp[i] = source and a.mon.pp[i] or a.curMoves[i].pp + learned[i] = number(source and a.mon.moves[i] or a.curMoves[i].id) + end + return { + turns = a[source and "expDisableTurns" or "disabledTurns"], + move = number(a[source and "expDisabledMove" or "disabledMove"]), + hp = a.mon.hp, + pp = pp, + learned = learned, + last = number(a[source and "lastMoveId" or "lastMove"]), + confusion = a[source and "confusionTurns" or "confusedTurns"], + bideTurns = a.bideTurns, + bideDamage = a.bideDamage, + } + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if source then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function tick(b) + if source then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function record(b, label) + local field = {} + for id = 0, b.double and 3 or 1 do + field[#field + 1] = view(at(b, id)) + end + -- Compare the real read-only native admission checks, not a duplicate + -- restriction formula or Gen1's snapshot (which omits disabled here). + local admission, phase, choosing = {}, b.phase, b.choosingSlot + b.phase, b.choosingSlot = "moveSelect", 0 + for slot = 1, 4 do + if source then + admission[slot] = require("src.core.game3.battle.commands").selectionError(b, slot, 0) == nil + else + admission[slot] = require("src.battle.BattleCommands").reason(b, { kind = "move", slot = slot }) + == nil + end + end + b.phase, b.choosingSlot = phase, choosing + rows[#rows + 1] = + { label = label, field = field, admission = admission, draws = Copy(draws), events = Copy(events) } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for word = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "no_last", + "unknown", + "empty_pp", + "already", + "zero_counter", + "protected", + "substitute", + "hidden", + "custom", + "duplicate", + }) do + draws, events = {}, {} + local b = fresh(double, word) + local target = at(b, tid) + last(target, 33) + if kind == "no_last" then + last(target, nil) + end + if kind == "unknown" then + last(target, 999) + end + if kind == "empty_pp" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + end + if kind == "custom" then + setMove(target, 1, 700) + last(target, 700) + end + if kind == "duplicate" then + setMove(target, 3, 33) + end + if kind == "already" or kind == "zero_counter" then + setDisabled(target, 10, kind == "already" and 4 or 0) + end + if kind == "protected" then + target[source and "expProtected" or "protecting"] = true + end + if kind == "substitute" then + target.substituteHP = 25 + end + if kind == "hidden" then + if source then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + cast(b, uid, tid, 2) + if kind == "ordinary" or kind == "substitute" or kind == "custom" or kind == "duplicate" then + T.eq( + target[source and "expDisableTurns" or "disabledTurns"], + word + 2, + "native Disable draws two to five residual phases" + ) + T.eq( + number(target[source and "expDisabledMove" or "disabledMove"]), + kind == "custom" and 700 or 33, + "Disable owns the last used learned move" + ) + end + record(b, (double and "double" or "single") .. "/" .. uid .. "/" .. word .. "/" .. kind) + end + end + for _, kind in ipairs({ + "expiry", + "pp_depletion", + "removed", + "swapped", + "blocked", + "confused", + "different", + "called", + "duplicate_blocked", + "haze", + "transform", + "bide", + "locked", + }) do + draws, events = {}, {} + local b = fresh(double, 3) + local target = at(b, tid) + last(target, 33) + if kind == "bide" then + setMove(target, 1, 117) + last(target, 117) + target.bideTurns, target.bideDamage = 2, 73 + end + cast(b, uid, tid, 2) + record(b, kind .. "/applied/" .. uid) + if kind == "pp_depletion" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + elseif kind == "removed" then + setMove(target, 1, 10) + elseif kind == "swapped" then + if source then + target.mon.moves[1], target.mon.moves[3] = target.mon.moves[3], target.mon.moves[1] + else + target.curMoves[1], target.curMoves[3] = target.curMoves[3], target.curMoves[1] + end + elseif kind == "haze" then + cast(b, tid, uid, 4) + elseif kind == "transform" then + setMove(target, 4, 144) + cast(b, tid, uid, 4) + elseif kind == "bide" then + if source then + cast(b, tid, uid, 1) + else + b:executeAction(target, at(b, uid), { special = "bide" }) + end + elseif + kind == "blocked" + or kind == "confused" + or kind == "called" + or kind == "different" + or kind == "duplicate_blocked" + or kind == "locked" + then + local slot = kind == "different" and 3 or 1 + if kind == "confused" then + target[source and "confusionTurns" or "confusedTurns"] = 4 + end + if kind == "duplicate_blocked" then + setMove(target, 3, 33) + slot = 3 + end + if kind == "locked" then + target.bideTurns, target.bideDamage = 2, 73 + if source then + target.twoTurnMove, target.expRolloutTimer, target.expFuryCutter, target.expRampageTurns = + 33, 3, 4, 2 + target.expLockedMove, target.expLockedSlot, target.expUproarTurns, target.uproar = + 33, 1, 2, true + else + target.charging, target.rampMove, target.rampRun = target.curMoves[1], f.ids.TACKLE, 3 + target.thrashMove, target.thrashTurns, target.uproarTurns = target.curMoves[1], 2, 2 + end + end + cast(b, tid, uid, slot, kind == "called") + if kind == "locked" then + for _, key in + ipairs( + source + and { + "twoTurnMove", + "expRolloutTimer", + "expFuryCutter", + "expRampageTurns", + "expLockedMove", + "expLockedSlot", + "expUproarTurns", + "uproar", + } + or { "charging", "rampMove", "rampRun", "thrashMove", "thrashTurns", "uproarTurns" } + ) + do + T.eq(target[key], nil, "native disabled interruption cancels multi-turn state: " .. key) + end + end + end + record(b, kind .. "/executed/" .. uid) + for step = 1, 5 do + tick(b) + record(b, kind .. "/residual/" .. step .. "/" .. uid) + end + T.eq( + target[source and "expDisabledMove" or "disabledMove"], + nil, + "native Disable clears through actual lifecycle" + ) + T.eq( + target[source and "expDisableTurns" or "disabledTurns"], + nil, + "native Disable clears its timer too" + ) + end + end + end + stop() + noCrit() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 2c5ca17c4..6b3b169b8 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #218 with command revision 4. +-- Legacy hash audited against #214; native hash is #219 with command revision 5. local LEGACY = "e33d251961f2a41d" -local NATIVE = "8be423c61a4477a2" +local NATIVE = "da62fe7b50f40107" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 4 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 5 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_disable.lua b/tests/engine/gen3_disable.lua new file mode 100644 index 000000000..468afe952 --- /dev/null +++ b/tests/engine/gen3_disable.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.disable").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Disable production, interruption and residual expiry") diff --git a/tests/engine/gen3_disable_continuation.lua b/tests/engine/gen3_disable_continuation.lua new file mode 100644 index 000000000..bcb9df863 --- /dev/null +++ b/tests/engine/gen3_disable_continuation.lua @@ -0,0 +1,196 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local V, D = require("src.core.GameVersion"), require("src.battle.Gen3Disable") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states, f.result.loader.game = game.stack.list, game + game.save.options.ruleset = "gen3_emerald" + local mon = game.save.party[1] + mon.nickname, mon.moves = + "PLAYER", { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.DISABLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + mon.hp, mon.stats.hp = 400, 400 + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.player.lastMove = f.ids.TACKLE + b.enemy.mon.nickname, b.enemy.mon.hp, b.enemy.mon.stats.hp = "ENEMY", 400, 400 + b.enemy.curMoves = { { id = f.ids.DISABLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.phase, b.queue, b.frame, b.moveIndex = "moveSelect", {}, 0, 1 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + b.rng = function(_, hi) + return hi + end + game.stack.list[1] = b + local used, declared = {}, nil + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = e.move.id + end) + local offStart = f.e.api.events:on("battle.turn_started", function(e) + declared = e.playerAction.id + end) + f.e.choose(function() + return { move = "DISABLE" } + end) + local api = f.e.api.battle + local snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, false, "selected attack is initially available") + local ok, why = api:submit({ id = 1, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(ok, true, "public command queues the ordinary selected move: " .. tostring(why)) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "same-turn interruption returns to the native menu") + T.eq(b.turnCount, 1, "Disable interruption consumes one full turn") + T.eq(mon.moves[1].pp, 10, "already queued disabled action spends no PP") + T.eq(b.enemy.curMoves[1].pp, 9, "Disable itself spends its real slot PP") + T.eq(b.player.lastMove, nil, "cancellation clears last executed move") + T.eq(b.player.disabledMove, f.ids.TACKLE, "same-turn Disable uses the previous executed move") + T.eq(b.player.disabledTurns, 4, "five-phase timer ages once at end of turn") + T.same(used, { "DISABLE" }, "cancelled action emits no move-used event") + T.eq(declared, "TACKLE", "turn-start still reports the original declaration") + off() + offStart() + b.phase = "moveSelect" + snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, true, "public snapshot reports native Disable") + T.eq(snapshot.moves[3].disabled, true, "duplicate learned move is also disabled") + T.eq(snapshot.moves[4].disabled, false, "a different learned move remains available") + local timer, rng = b.player.disabledTurns, b.rng + b.rng = function() + error("read-only admission drew RNG") + end + for _, slot in ipairs({ 1, 3 }) do + local accepted, reason = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = slot }) + T.eq(accepted, nil, "public command rejects every disabled duplicate") + T.eq(reason, "move is disabled", "native refusal explains the current restriction") + end + T.eq(b.player.disabledTurns, timer, "snapshot and admission never age the timer") + b.rng = rng + b:swapMoves(1, 4, b.player) + local accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(accepted, nil, "a real move reorder invalidates the old offered command") + snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, false, "reordered replacement is available") + T.eq(snapshot.moves[4].disabled, true, "Disable follows the moved identity") + b.player.disabledTurns = timer - 1 + accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(accepted, nil, "changed timer invalidates the old offered context") + snapshot = assert(api:snapshot()) + b.player.disabledMove = f.ids.SCRATCH + accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 3 }) + T.eq(accepted, nil, "changed disabled identity invalidates the old offered context") + local ai = require("src.battle.Gen3AI").OPS + local vm = { user = b.player, target = b.enemy, moveIndex = 1 } + T.eq( + ai.if_curr_move_disabled_or_encored(vm, { 0, 123 }), + 123, + "native AI current-move predicate reads identity" + ) + T.eq( + ai.if_any_move_disabled_or_encored(vm, { 1, 0, 124 }), + 124, + "native AI any-move predicate reads native marker" + ) + b.player.curMoves[1].id = f.ids.TACKLE + T.eq(D.slot(b.player), nil, "removal does not retain a stale slot") + T.eq( + ai.if_curr_move_disabled_or_encored(vm, { 0, 123 }), + nil, + "native AI current predicate sees removal immediately" + ) + T.eq( + ai.if_any_move_disabled_or_encored(vm, { 1, 0, 124 }), + 124, + "native marker remains until its residual phase" + ) + b:gen3BattlerCountersTick(b.player) + T.eq(b.player.disabledMove, nil, "real native residual removes the now unknown move") + local raw = require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT + T.neq(f.data.move_effects.DISABLE_EFFECT.run, raw.run, "native adapter is installed per dataset") + f.close() + for _, mode in ipairs({ "override", "delegate" }) do + -- Capture the actual public builtin during mod load, not after installation. + local entry = 'return function(mod) local base = assert(mod.content.move_effects:get("DISABLE_EFFECT")); local run = base.run; base.run=function(ctx) ' + .. (mode == "delegate" and "return run(ctx)" or 'return {"CUSTOM"}') + .. ' end; assert(mod.content.move_effects:override("DISABLE_EFFECT", base)); mod.exports.api=mod end' + local g = require("tests.enemy_actions.disable").fixture(".", "gen2", version, entry) + local c = g.fresh("TACKLE", { enemy = true }) + c.battle:syncSides() + c.target.lastMove = g.ids.TACKLE + c.user.curMoves = { { id = g.ids.DISABLE, pp = 10 } } + c.user.mon.moves = c.user.curMoves + c.battle.rng = function(_, hi) + return hi + end + c.battle:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq( + c.target.disabledMove, + mode == "delegate" and g.ids.TACKLE or nil, + "public override retains its native delegate or its own policy" + ) + T.eq( + c.target.disabledTurns, + mode == "delegate" and 5 or nil, + "public builtin delegate uses native duration" + ) + T.eq(c.user.curMoves[1].pp, 9, "public override retains real PP ownership") + T.eq( + raw.run, + require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT.run, + "loads never mutate shared legacy statics" + ) + g.close() + end + for _, mode in ipairs({ "switch", "baton" }) do + local g = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + local c = g.fresh("TACKLE") + local battle, donor = c.battle, c.user + battle:syncSides() + local bench = Copy(donor.mon) + bench.nickname = "BENCH" + battle.game.save.party[2] = bench + donor.disabledMove, donor.disabledTurns = g.ids.TACKLE, 4 + if mode == "switch" then + battle:switchPlayerInto(0, bench) + else + require("src.battle.Gen3BatonPass").switch(battle, donor, 2, battle.game.save.party) + end + T.eq(battle.player.mon, bench, "actual " .. mode .. " replaces the active Pokemon") + T.eq(battle.player.disabledMove, nil, "actual " .. mode .. " never transfers Disable identity") + T.eq(battle.player.disabledTurns, nil, "actual " .. mode .. " never transfers Disable duration") + g.close() + end +end +for _, key in ipairs({ "disabledMove", "disabledTurns" }) do + T.neq(D.signature({ [key] = 1 }), D.signature({}), "native link signature includes " .. key) +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + local raw = require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT + T.eq(D.builtin("DISABLE_EFFECT", raw), raw, "other generations keep the original builtin identity") + local target = { name = "LEGACY", curMoves = { { id = "A", pp = 1 }, { id = "B", pp = 1 } } } + raw.run({ + battle = { + data = { moves = { A = { name = "A" }, B = { name = "B" } } }, + rng = function(_, hi) + return hi + end, + }, + target = target, + }) + T.eq(target.disabledSlot, 2, "older games retain random usable-slot selection") + T.eq(target.disabledTurns, 8, "older games retain their legacy duration") + T.eq(target.disabledMove, nil, "native identity does not leak to older games") +end +T.finish("native Disable public admission, queued execution and builtin delegation") diff --git a/tests/engine/gen3_disable_link.lua b/tests/engine/gen3_disable_link.lua new file mode 100644 index 000000000..9a3e59bd8 --- /dev/null +++ b/tests/engine/gen3_disable_link.lua @@ -0,0 +1,92 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 4 + T.eq(H.checkCompat(current, older), "subset", "pre-Disable rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.DISABLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 0 and 1 or turn == 1 and (i == 1 and 2 or 1) or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.eq(a.enemy.disabledMove, f.ids.TACKLE, "host retains the remote disabled move") + T.eq(b.player.disabledMove, f.ids.TACKLE, "guest retains its own disabled move") + T.eq( + a.enemy.disabledTurns, + b.player.disabledTurns, + "native Disable timer agrees after a real full turn" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the native Disable application turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 9, "disabled already-selected Tackle spends no extra PP") + T.eq(guest.mon.moves[2].pp, 10, "the guest never selects Disable") + T.eq(guest.mon.moves[3].pp, 9, "an independently selected different move executes normally") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Disable real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 76516c2e7..15aa4a121 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index ad77a6795..7acd3cd27 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "encore" then +if mode == "disable" then + rows = dofile(root .. "/tests/enemy_actions/disable.lua").run(root, backend, version) +elseif mode == "encore" then rows = dofile(root .. "/tests/enemy_actions/encore.lua").run(root, backend, version) elseif mode == "baton_pass" then rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version) From 58b3187e2d5d7bc7b4d4ad65aeb7aeccee05551e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:31:55 +0100 Subject: [PATCH 54/90] Document native Disable contracts and validation boundaries --- docs/native-gen3-disable.md | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/native-gen3-disable.md diff --git a/docs/native-gen3-disable.md b/docs/native-gen3-disable.md new file mode 100644 index 000000000..87bc0814b --- /dev/null +++ b/docs/native-gen3-disable.md @@ -0,0 +1,111 @@ +# Native GBA Disable and portable move eligibility + +GBA Disable now follows the target's last executed learned move, including +known authored moves without cartridge indices. Previously it randomly selected +a positive-PP slot, drew 1–8 phases and aged the counter on attempted actions. +That could disagree with both the public mod snapshot and an already queued move. + +The native producer checks accuracy first, then rejects an existing disabled +move, an unknown last move or a learned slot with no PP. It draws `0..3 + 2` +for 2–5 residual phases. `disabledMove` owns the native host ID; the affected +slots are read from the current move list. Reordering follows the identity, +every duplicate is restricted, and removing the identity clears Disable in its +next residual phase. Reaching zero PP does not prematurely end Disable. + +A disabled queued action stops before announcement, `battle.move_used`, confusion +and PP consumption. The interruption clears the last executed move and native +multi-turn state, including the existing Bide continuation path. Called moves +retain their native bypass and consume no PP from the called slot. End-of-turn +processing ages Disable before Encore. Switches and Baton Pass do not transfer it. + +Move selection, `mod.battle:snapshot()`, public command admission, the move-list +hint and native AI Disable predicates now read the live identity. Snapshot and +admission are read-only; identity/timer changes invalidate previously offered +commands. Legacy authored `disabledSlot` states retain their previous policy. + +Builtin adaptation happens per dataset before mods load: a mod can capture and +delegate `mod.content.move_effects:get("DISABLE_EFFECT").run`. An explicit +custom handler remains authoritative, and shared legacy module records are never +mutated. Haze preserves native Disable; a successful legacy Transform clears it, +even when the copied move list still includes the disabled ID. These adapters +only address Disable's lifecycle: they do not certify all Haze/Transform rules. +Older-generation builtins and their slot/countdown behavior stay unchanged. + +## Lockstep compatibility + +Native GBA command revision is **5**, with native party codec revision **1**. +The active state hash includes the disabled move and timer. Both link roles +refuse older command revisions before party exchange. + +The raw native fixture changes from `8be423c61a4477a2` to `da62fe7b50f40107`. +It was independently calculated with the unchanged #219 hasher and only the +explicit revision token changed from 4 to 5. Handler functions serialize as +opaque markers, so per-load builtin delegation adapters do not silently add a +function-byte digest. No older-generation golden was reblessed. + +## Validation + +The clean feature runtime is `55f4301ab6ef190051b1f6d420f22636a61a1809`. +Gen1 is an unchanged, clean read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. No Gen1 development or publication +is required or performed. + +- **930 focused checks**: 768 command/lifecycle, 108 public-command/delegation/ + switch and 54 actual link/compatibility checks. The protocol suite also passes. +- **96 workers / 62,976 paired observations** agree. The new Disable mode adds + 810 observations per FireRed/Emerald profile, retaining every bounded RNG + call/value, each battler's HP/PP/learned moves/last move, actual move events, + timer and move identity. It covers all four battler slots and duration words, + failure guards, custom/duplicate moves, removal/reordering, PP depletion, + confusion/called/queued execution, Bide and multi-turn interruption, + Haze/Transform, and actual residual expiry. +- Eligibility observations compare Gen1's real native `Commands.selectionError` + against the host's real read-only `BattleCommands.reason`. Gen1's GBA snapshot + omits `disabled`; inventing a source value or comparing an omitted field as + false would not prove native selection agreement. +- Full feature run: **116/125 engine suites**, SDK **3/3**, B30 passed. + The exact Counter of all **25 inherited FAIL lines across nine suites** equals + clean #219 (113/122). The full command correctly retains its nonzero exit. + Independent repairs for those suites belong to the combined test branch. +- The unchanged #219 public-command negative control fails both new assertions: + it retains random slot 2 and duration 8, rather than the last move and duration + 5. The raw negative output is retained. +- Two actual link roles complete three turns: ordinary Tackle, a faster Disable + cancelling the already selected Tackle without PP, then an independent Scratch. + Every completed paired hash and state component agrees. +- Four CPU-only scenarios with actual local FireRed/Emerald imports pass: + canonical public enemy-action selection, preapplied and same-turn Disable, + public move submission, full native turn/result queues and residual expiry. + Disable's real imported accuracy is retained; the controlled hit roll is 1. + No cartridge caches, ROMs or saves are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Reproduce from the feature checkout, using the maintained LuaJIT pin documented +in the workflow (`a4c064539ea931274d407e1249b4bb575424a0f4`): + +```sh +nice -n 19 "$LUA" tests/engine/gen3_disable.lua +nice -n 19 "$LUA" tests/engine/gen3_disable_continuation.lua +nice -n 19 "$LUA" tests/engine/gen3_disable_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/disable-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Private local evidence is retained under `tmp/disable-qa/`, including the full +feature log, complete comparison report, exact baseline FAIL-Counter audit, +negative control and imported smoke. Earlier exploratory/failed logs are retained. + +## Remaining boundaries + +This is a native command/lifecycle and mod eligibility contract, not complete +battle equivalence. The broader canceller ordering, legacy Rage/other locked-move +controllers, all Haze/Transform rules, whole-game RNG/VBlank orchestration, +animations, live UI pixels and a full cartridge playthrough need separate work. +The existing prepared Quick Claw boundary in the public Encore planning cases +remains documented in [the Encore contract](native-gen3-encore.md); no RNG calls +or differing observations are filtered from this checker. From 6a3a3155bb2d3433a8c587333d11870ed10f60eb Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:32:24 +0100 Subject: [PATCH 55/90] Record complete Disable acceptance in the combined fork --- docs/native-gen3-disable.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/native-gen3-disable.md b/docs/native-gen3-disable.md index 87bc0814b..5c9e46630 100644 --- a/docs/native-gen3-disable.md +++ b/docs/native-gen3-disable.md @@ -109,3 +109,17 @@ animations, live UI pixels and a full cartridge playthrough need separate work. The existing prepared Quick Claw boundary in the public Encore planning cases remains documented in [the Encore contract](native-gen3-encore.md); no RNG calls or differing observations are filtered from this checker. + +## Combined fork acceptance + +The tested combined runtime `abd621282715d072b421e7b30f3f5c60628d771d` +passes **237/237 engine suites**, SDK **3/3**, B30, the same **96 workers / +62,976 paired observations**, and all four imported CPU smoke scenarios. +Both comparison reports identify a clean host at their respective runtime. +The integration preserves the existing GBA item/residual scheduling and native +AI-order preparation. Later commits only record validation/publication. + +Combined evidence is retained in `tmp/disable-full.log`, +`tmp/disable-contracts.json`, `tmp/disable-imported.log` and +`tmp/disable-accepted-evidence.json`. The source checkout remains clean at the +pinned Gen1 revision. Hosted CI status is reported separately in the PR. From 5ecfee4d7e317f3b7175bdf9f1bbc59b163e67c5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:53:21 +0100 Subject: [PATCH 56/90] Align ordinary native GBA multi-hit execution and RNG boundaries --- .github/workflows/enemy-action-contract.yml | 1 + src/battle/BattleState.lua | 9 +- src/battle/Damage.lua | 1 + src/battle/EffectRegistry.lua | 49 ++- src/battle/Gen3Builtins.lua | 7 + src/battle/Gen3MultiHit.lua | 36 ++ src/battle/Gen3Secondary.lua | 1 + src/link/Fingerprint.lua | 2 +- src/mods/Builtins.lua | 2 +- tests/enemy_actions/multi_hit.lua | 380 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_multi_hit.lua | 5 + tests/engine/gen3_multi_hit_composition.lua | 142 +++++++ tests/engine/gen3_multi_hit_link.lua | 92 +++++ tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 16 files changed, 714 insertions(+), 25 deletions(-) create mode 100644 src/battle/Gen3Builtins.lua create mode 100644 src/battle/Gen3MultiHit.lua create mode 100644 tests/enemy_actions/multi_hit.lua create mode 100644 tests/engine/gen3_multi_hit.lua create mode 100644 tests/engine/gen3_multi_hit_composition.lua create mode 100644 tests/engine/gen3_multi_hit_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 4e0a8c1bf..ede247bd8 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -27,6 +27,7 @@ on: - 'tests/engine/gen3_baton_pass*.lua' - 'tests/engine/gen3_encore*.lua' - 'tests/engine/gen3_disable*.lua' + - 'tests/engine/gen3_multi_hit*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 87c382e62..82bd99e53 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5131,7 +5131,7 @@ function BattleState:effectRecord(effect) local effects = self.data.move_effects local record = effects and effects[effect] or not effects and MoveEffects.RECORDS[effect] if GameVersion.isGen3() and not effects then - record = require("src.battle.Gen3Disable").builtin(effect, record) + record = require("src.battle.Gen3Builtins").record(effect, record) end if GameVersion.isGen3() and effect == "DISABLE_EFFECT" then return require("src.battle.Gen3Disable").record(record, MoveEffects.RECORDS.DISABLE_EFFECT) @@ -8351,7 +8351,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end local Schedule = require("src.battle.GbaItemSchedule") local parent = self._gbaMoveFrame - local frame = { parent = parent } + local frame = { parent = parent, move = self:moveDef(moveInst) } if parent then parent.called = true end self._gbaMoveFrame = frame if self._gbaActionFrame then self._gbaActionFrame.moved = true end @@ -8443,7 +8443,7 @@ function BattleState:applyDamage(target, dmg, fromMove, adjustment) -- Pokemon standing on a single HP instead of fainting. Asked here rather -- than at the faint check because the cartridge's band changes the DAMAGE, -- and everything downstream -- Bide's tally, Rage, the exp award -- reads - -- what was actually dealt. +-- what was actually dealt. -- ENDURE first: it is a certainty where the band is a roll, so a Pokemon -- that used ENDURE this turn never needs the band at all. local mayEndure = fromMove == true or selfHit @@ -8474,7 +8474,8 @@ function BattleState:applyDamage(target, dmg, fromMove, adjustment) target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target))) end - return dealt + -- Native multi-hit continuation also needs the actual Endure decision. + return dealt, endured end -- --------------------------------------------------------------------- diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c02e8b182..d52a19490 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -104,6 +104,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data, battle) if ruleset.critStages then local stage = require("src.battle.Gen3Critical").stage(battle, attacker, highCrit) local den = CRIT_STAGE_DEN[stage] or 16 + if battle and require("src.core.GameVersion").isGen3() then return rng(0, den - 1) == 0 end return rng(1, den) == 1 end local function shl(x) return math.min(255, x * 2) end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index fcf61d46e..e012e4d43 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -96,10 +96,17 @@ local function hitCount(ctx, record) return dist[r + 1] end +local function ordinaryDamage(battle, ctx, record) + return battle:computeDamage(ctx.user, ctx.target, ctx.move, + { rng = battle.rng, explode = (record and record.explode) or nil, + powerMultiplier = record and record.powerMultiplier and record.powerMultiplier(ctx) or nil, + highCrit = record and record.highCrit or nil }) +end + -- The damaging pipeline, extracted from the performMove monolith: every --- stage keeps the original's exact check order and rng consumption --- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> --- damage choice -> hits -> messages -> after-damage -> secondary run). +-- stage keeps its generation's check order and RNG consumption. GBA draws +-- hit count after accuracy and computes ordinary damage separately per hit; +-- older generations retain their pre-accuracy count and shared damage roll. function EffectRegistry.runDamaging(battle, ctx, record) local user, target = ctx.user, ctx.target local move, moveInst = ctx.move, ctx.moveInst @@ -165,7 +172,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end - local hits = hitCount(ctx, record) + local hits = not gba and hitCount(ctx, record) or 1 if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end @@ -184,6 +191,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end + if gba then hits = hitCount(ctx, record) end + -- damage per hit local dmg, info if move.id == "COUNTER" then @@ -220,13 +229,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end dmg, info = chosen, extra or { crit = false, typeMult = 10 } else - dmg, info = battle:computeDamage(user, target, move, - { rng = battle.rng, explode = (record and record.explode) or nil, - -- a record whose EFFECT decides the power or the crit rate this - -- turn rather than the move's own byte - powerMultiplier = record and record.powerMultiplier - and record.powerMultiplier(ctx) or nil, - highCrit = record and record.highCrit or nil }) + dmg, info = ordinaryDamage(battle, ctx, record) end if battle.arenaOutcome then @@ -289,10 +292,27 @@ function EffectRegistry.runDamaging(battle, ctx, record) local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end + if gba and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" + and not require("src.battle.Gen3MultiHit").sleepTalk(battle)) then break end if record and record.perHitDamage then local nextDamage, nextInfo = record.perHitDamage(ctx, h, dmg, info) if nextDamage == nil then break end dmg, info = nextDamage, nextInfo or info + elseif gba and h > 1 and require("src.battle.Gen3MultiHit").ordinary(move) + and not (record and record.chooseDamage) then + dmg, info = ordinaryDamage(battle, ctx, record) + if info.typeMult == 0 or info.missed then + ctx.gbaNoEffect = true + if info.typeMult == 0 then + if not (info.ability and battle.abilityAbsorb and battle:abilityAbsorb(target, move)) then + battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target))) + end + else + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) + end + if record and record.onMiss then record.onMiss(ctx, info.typeMult == 0 and "immune" or "floored") end + break + end end local hitRow if h == 1 then @@ -334,7 +354,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- ...and the one applyDamage a Gen 3 FOCUS BAND answers: a move landing -- on someone local substituteBefore = target.substituteHP - local dealt = battle:applyDamage(target, hitDamage, true, info.damageAdjustment) + local dealt, endured = battle:applyDamage(target, hitDamage, true, info.damageAdjustment) if (info.gbaSetDamage or info.gbaBeatUp) and substituteBefore then dealt = math.min(dealt, substituteBefore) end totalDealt = totalDealt + dealt if gba and not hadSub and require("src.battle.Damage").isSpecial(move.type) then @@ -399,10 +419,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) require("src.battle.Gen3Critical").noteHit(battle, user, target, dealt, hadSub) end if hadSub and not target.substituteHP and not info.gbaBeatUp then - -- AttackSubstitute: breaking the substitute ends a multi-hit move + -- Older AttackSubstitute ends the move; GBA continues against real HP. brokeSub = true - break + if not gba then break end end + if gba and endured then break end end hits = landed > 0 and landed or hits if hits > 1 and not info.gbaBeatUp then diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua new file mode 100644 index 000000000..98b39879b --- /dev/null +++ b/src/battle/Gen3Builtins.lua @@ -0,0 +1,7 @@ +-- Adapt complete builtin records before mods capture them for delegation. +local M = {} +function M.record(id, base) + base = require("src.battle.Gen3Disable").builtin(id, base) + return require("src.battle.Gen3MultiHit").builtin(id, base) +end +return M diff --git a/src/battle/Gen3MultiHit.lua b/src/battle/Gen3MultiHit.lua new file mode 100644 index 000000000..0b419d2be --- /dev/null +++ b/src/battle/Gen3MultiHit.lua @@ -0,0 +1,36 @@ +-- GBA's hit-count opcode runs only after a successful accuracy check. +local M = {} +local V = require("src.core.GameVersion") +local cache = setmetatable({}, { __mode = "k" }) +function M.ordinary(move) + local effect = tonumber(move.gen3Effect) + return effect == 29 or effect == 44 or effect == 77 +end +function M.builtin(id, base) + if not V.isGen3() or not base or id ~= "TWO_TO_FIVE_ATTACKS_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.hitCount = function(ctx) + if not V.isGen3() then + return base.hitCount(ctx) + end + local first = ctx.rng(0, 3) + return first > 1 and ctx.rng(0, 3) % 4 + 2 or first + 2 + end + cache[base] = out + return out +end +function M.sleepTalk(battle) + local frame = battle._gbaMoveFrame + local caller = frame and frame.parent and frame.parent.move + return caller + and (tonumber(caller.index) == 214 or tonumber(caller.gen3Effect) == 97 or caller.id == "SLEEP_TALK") +end +return M diff --git a/src/battle/Gen3Secondary.lua b/src/battle/Gen3Secondary.lua index 31bf190a0..d0b252e64 100644 --- a/src/battle/Gen3Secondary.lua +++ b/src/battle/Gen3Secondary.lua @@ -159,6 +159,7 @@ function S.run(ctx, record) if not record or not native or record.run ~= native.run then return false end + if ctx.gbaNoEffect then return true end local _, messages = S.withChance(ctx, spec) for _, message in ipairs(messages) do ctx.battle:sayNext(message) diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 953d78735..cdad3fc71 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 5 or nil + return require("src.core.GameVersion").isGen3() and 6 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 3c30ae7b6..be698bf17 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -101,7 +101,7 @@ end function Builtins.install(content, data) for _, entry in ipairs(REGISTRANTS) do - local adapt = entry.name == "move_effects" and require("src.battle.Gen3Disable").builtin or nil + local adapt = entry.name == "move_effects" and require("src.battle.Gen3Builtins").record or nil local registry = content[entry.name] and isolate(content[entry.name], adapt) if registry then if entry.install then diff --git a/tests/enemy_actions/multi_hit.lua b/tests/enemy_actions/multi_hit.lua new file mode 100644 index 000000000..041b03692 --- /dev/null +++ b/tests/enemy_actions/multi_hit.lua @@ -0,0 +1,380 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT" }, + { 24, "DOUBLE_KICK", 44, "ATTACK_TWICE_EFFECT" }, + { 41, "TWINEEDLE", 77, "TWINEEDLE_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 15, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 15, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used = {}, {}, {}, {} + local currentKind, liveUser, liveTarget + -- Public events are detached views. Mutate the owned fixture's real battlers + -- to test live continuation; this does not assert event-view write-through. + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 and currentKind == "sleep_after_hit" then + liveUser.mon.status = "SLP" + end + if #damage == 1 and currentKind == "type_after_hit" then + if native then + liveTarget.type1, liveTarget.type2 = 7, 7 + else + liveTarget.curTypes = { "GHOST" } + end + end + if #damage == 1 and currentKind == "user_ko" then + liveUser.mon.hp = 0 + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, first, second) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 3, 24, 41, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TWINEEDLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + local count, crit, variance = 0, 0, 0 + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + count = count + 1 + value = count == 1 and first or second + end + if lo == 0 and hi == 15 then + crit = crit + 1 + value = currentKind == "crit" and crit % 2 == 1 and 0 or hi + end + if lo == 85 and hi == 100 then + variance = variance + 1 + value = variance % 2 == 1 and lo or hi + end + if lo == 0 and hi == 99 and (currentKind == "poison" or currentKind == "focus_band") then + value = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function view(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + sub = tonumber(a.substituteHP) or 0, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + attack = a.stages.attack or 0, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for slot = 1, 3 do + for first = 0, 3 do + for second = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "crit", + "focus_crit", + "scope_crit", + "focus_scope_crit", + "max_crit", + "sub_survive", + "sub_break", + "endure", + "focus_band", + "target_ko", + "protected", + "hidden", + "immune", + "accuracy_miss", + "poison", + "sleep_after_hit", + "type_after_hit", + "user_ko", + "called", + }) do + currentKind = kind + draws, damage, used = {}, {}, {} + local b = fresh(double, first, second) + local user, target = at(b, uid), at(b, tid) + liveUser, liveTarget = user, target + if kind == "focus_crit" or kind == "focus_scope_crit" or kind == "max_crit" then + user.focusEnergy = true + end + if kind == "scope_crit" or kind == "focus_scope_crit" then + if native then + user.item, user.mon.item = 198, 198 + else + user.mon.item = "SCOPE_LENS" + end + end + if kind == "max_crit" then + user.species = 113 + if native then + user.item, user.mon.item = 222, 222 + else + user.mon.item = "LUCKY_PUNCH" + end + end + if kind == "sub_survive" then + target.substituteHP = 400 + end + if kind == "sub_break" then + target.substituteHP = 1 + end + if kind == "target_ko" then + target.mon.hp = 1 + end + if kind == "endure" then + target.mon.hp = 1 + target[native and "expEnduring" or "enduring"] = true + end + if kind == "focus_band" then + target.mon.hp = 1 + if native then + target.item, target.mon.item = 196, 196 + else + target.mon.item = "FOCUS_BAND" + end + end + if kind == "protected" then + target[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + local number = ({ 3, 24, 41 })[slot] + local def = native and f.data.gen3Moves._rom[number] + or f.data.moves[({ f.ids.DOUBLE_SLAP, f.ids.DOUBLE_KICK, f.ids.TWINEEDLE })[slot]] + local old = def.accuracy + if kind == "accuracy_miss" then + def.accuracy = 90 + end + if native then + local nativeSlot = slot + if kind == "called" then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = kind == "called" } + ) + elseif kind == "called" then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + def.accuracy = old + local label = (double and "double" or "single") + .. "/" + .. uid + .. "/" + .. slot + .. "/" + .. first + .. "/" + .. second + .. "/" + .. kind + rows[#rows + 1] = { + label = label, + field = view(b), + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + } + if kind == "ordinary" then + T.eq( + #damage, + slot == 1 and (first > 1 and second + 2 or first + 2) or 2, + "native hit count follows its own four-value opcode" + ) + end + if + kind == "sleep_after_hit" + or kind == "type_after_hit" + or kind == "user_ko" + or kind == "endure" + then + T.eq(#damage, 1, "native continuation stops at its actual live condition") + end + if kind == "sleep_after_hit" then + T.eq(user.mon.status, "SLP", "callback actually changes the live user's status") + end + if kind == "user_ko" then + T.eq(user.mon.hp, 0, "callback actually faints the live user") + end + if kind == "type_after_hit" then + T.eq( + native and target.type1 or target.curTypes[1], + native and 7 or "GHOST", + "callback actually changes the live target's type" + ) + end + if kind == "endure" or kind == "focus_band" then + T.eq(target.mon.hp, 1, "native damage survival is retained") + end + if kind == "sub_break" then + T.check(target.mon.hp < 400, "later hits continue after the Substitute breaks") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + kind == "called" and 10 or 9, + "a multi-hit move spends PP once" + ) + end + end + end + end + end + end + off() + offMove() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 6b3b169b8..91f146140 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #219 with command revision 5. +-- Legacy hash audited against #214; native hash is #220 with command revision 6. local LEGACY = "e33d251961f2a41d" -local NATIVE = "da62fe7b50f40107" +local NATIVE = "a7db0874db78aec0" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 5 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 6 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_multi_hit.lua b/tests/engine/gen3_multi_hit.lua new file mode 100644 index 000000000..54a3f476e --- /dev/null +++ b/tests/engine/gen3_multi_hit.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.multi_hit").run(".", "gen2", version) +end +require("tests.modkit").finish("native ordinary GBA multi-hit execution") diff --git a/tests/engine/gen3_multi_hit_composition.lua b/tests/engine/gen3_multi_hit_composition.lua new file mode 100644 index 000000000..e8d9162a6 --- /dev/null +++ b/tests/engine/gen3_multi_hit_composition.lua @@ -0,0 +1,142 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "delegate", "override", "damage", "fallback" }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base=assert(mod.content.move_effects:get('TWO_TO_FIVE_ATTACKS_EFFECT')) + local count=base.hitCount + mod.exports.counts,mod.exports.hits=0,0 + base.hitCount=function(ctx) + mod.exports.counts=mod.exports.counts+1 + ]] .. (mode == "delegate" and "return count(ctx)" or "return 4") .. [[ + end + ]] .. (mode == "damage" and [[ + base.chooseDamage=function() return 2,{crit=false,typeMult=10} end + base.perHitDamage=function() + mod.exports.hits=mod.exports.hits+1 + return 2,{crit=false,typeMult=10} + end + ]] or "") .. [[ + assert(mod.content.move_effects:override('TWO_TO_FIVE_ATTACKS_EFFECT',base)) + mod.exports.api=mod + end]] + end + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.moves = { { id = f.ids.DOUBLE_SLAP, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local original = b.data.move_effects + if mode == "fallback" then + b.data.move_effects = nil + end + local draws, hits = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return hi + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.damage + end) + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq( + #hits, + (mode == "delegate" or mode == "fallback") and 5 or 4, + "authored count or native delegation controls the actual hits" + ) + T.eq(c.user.curMoves[1].pp, 9, "composed multi-hit spends exactly one PP") + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "the public count handler is called once") + end + if mode == "damage" then + T.eq(f.e.hits, 4, "explicit authored per-hit damage is never replaced") + T.same(hits, { 2, 2, 2, 2 }, "authored per-hit amounts reach real HP and events") + end + if mode == "delegate" or mode == "fallback" then + T.same(draws[2], { 0, 3 }, "native count keeps its raw bounded RNG opcode") + end + local before = #hits + f.data.moves[f.ids.DOUBLE_SLAP].accuracy = 90 + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(#hits, before, "a failed accuracy check emits no hit") + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "a failed accuracy check never invokes authored hit count") + end + off() + b.data.move_effects = original + f.close() + end + for _, caller in ipairs({ "sleep_talk", "other" }) do + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version) + local c = f.fresh("TACKLE") + local b = c.battle + b:syncSides() + -- Test the actual nested dispatcher after the calling move's selection. + f.data.moves.CALLER = { + id = "CALLER", + index = caller == "sleep_talk" and 214 or 118, + gen3Effect = caller == "sleep_talk" and 97 or 83, + name = "CALLER", + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + effect = "TEST_CALLER", + } + b.data.move_effects.TEST_CALLER = { + kind = "full", + callsMove = function() + return f.ids.DOUBLE_SLAP + end, + } + c.user.mon.status = "SLP" + c.user.curMoves = { { id = "CALLER", pp = 10 } } + c.user.mon.moves = c.user.curMoves + local hits = 0 + local off = f.e.api.events:on("battle.damage_dealt", function() + hits = hits + 1 + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(_, hi) + return hi + end + b:performMove(c.user, c.target, c.user.curMoves[1]) + T.eq( + hits, + caller == "sleep_talk" and 5 or 1, + "only a real Sleep Talk parent exempts sleeping multi-hit continuation" + ) + T.eq(c.user.curMoves[1].pp, 9, "the actual calling slot alone spends PP") + T.eq(b._gbaMoveFrame, nil, "nested frames restore the native dispatcher after completion") + off() + f.close() + end +end +local M = require("src.battle.Gen3MultiHit") +local raw = require("src.battle.MoveEffects").RECORDS.TWO_TO_FIVE_ATTACKS_EFFECT +V.set("firered") +local adapted = M.builtin("TWO_TO_FIVE_ATTACKS_EFFECT", raw) +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq(M.builtin("TWO_TO_FIVE_ATTACKS_EFFECT", raw), raw, "older games keep original builtin identity") + local calls = {} + local ctx = { + move = {}, + rng = function(lo, hi) + calls[#calls + 1] = { lo, hi } + return hi + end, + } + T.eq(adapted.hitCount(ctx), 5, "a reused native adapter delegates back to legacy policy") + T.same(calls, { { 0, 7 } }, "older games keep the original eight-value hit-count draw") +end +T.finish("native ordinary multi-hit builtin composition and called-move boundaries") diff --git a/tests/engine/gen3_multi_hit_link.lua b/tests/engine/gen3_multi_hit_link.lua new file mode 100644 index 000000000..eb7584128 --- /dev/null +++ b/tests/engine/gen3_multi_hit_link.lua @@ -0,0 +1,92 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 5 + T.eq(H.checkCompat(current, older), "subset", "pre-multi-hit rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = { + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TWINEEDLE, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = math.min(turn + 1, 3) + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + for slot = 1, 3 do + T.eq(guest.mon.moves[slot].pp, 9, "each actual multi-hit turn spends its own learned slot once") + end + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native ordinary multi-hit real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 15aa4a121..8e57fea00 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 7acd3cd27..f0530104e 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "disable" then +if mode == "multi_hit" then + rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version) +elseif mode == "disable" then rows = dofile(root .. "/tests/enemy_actions/disable.lua").run(root, backend, version) elseif mode == "encore" then rows = dofile(root .. "/tests/enemy_actions/encore.lua").run(root, backend, version) From 00e67c40abcb224ff693e4f543a4d018014edf73 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:55:29 +0100 Subject: [PATCH 57/90] Keep scripted Beat Up continuation separate from ordinary multi-hits --- src/battle/EffectRegistry.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index e012e4d43..ebb412635 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -112,6 +112,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss local gba = require("src.core.GameVersion").isGen3() + local ordinaryMulti = gba and require("src.battle.Gen3MultiHit").ordinary(move) if gba and type(target.substituteHP) == "number" and target.substituteHP <= 0 then target.substituteHP = nil end @@ -292,13 +293,13 @@ function EffectRegistry.runDamaging(battle, ctx, record) local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end - if gba and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" + if ordinaryMulti and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" and not require("src.battle.Gen3MultiHit").sleepTalk(battle)) then break end if record and record.perHitDamage then local nextDamage, nextInfo = record.perHitDamage(ctx, h, dmg, info) if nextDamage == nil then break end dmg, info = nextDamage, nextInfo or info - elseif gba and h > 1 and require("src.battle.Gen3MultiHit").ordinary(move) + elseif ordinaryMulti and h > 1 and not (record and record.chooseDamage) then dmg, info = ordinaryDamage(battle, ctx, record) if info.typeMult == 0 or info.missed then @@ -423,7 +424,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) brokeSub = true if not gba then break end end - if gba and endured then break end + if ordinaryMulti and endured then break end end hits = landed > 0 and landed or hits if hits > 1 and not info.gbaBeatUp then From 1d958bca394b280f91f5977785128dcc9c26fd98 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:59:42 +0100 Subject: [PATCH 58/90] Document ordinary GBA multi-hit validation and native boundaries --- docs/native-gen3-multi-hit.md | 115 ++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/native-gen3-multi-hit.md diff --git a/docs/native-gen3-multi-hit.md b/docs/native-gen3-multi-hit.md new file mode 100644 index 000000000..cf0000f33 --- /dev/null +++ b/docs/native-gen3-multi-hit.md @@ -0,0 +1,115 @@ +# Ordinary native GBA multi-hit execution + +Native multi-hit moves need independent damage commands for each reached strike. +The host previously drew an eight-value hit count before accuracy, computed the +ordinary damage only once, and stopped when a Substitute broke. This changed RNG +consumption, individual damage events and live continuation seen by mods. + +The GBA paths for raw effects **29** (two to five), **44** (two hits) and **77** +(Twineedle) now calculate ordinary damage for each reached strike. Explicit +`chooseDamage` and `perHitDamage` handlers remain authoritative. Scripted Beat Up +retains its independent party eligibility and Endure rules. + +- Accuracy precedes hit count. The native count opcode draws `0..3`; values 0/1 + give 2/3 hits, while 2/3 require a second `0..3 + 2` draw. +- Every reached ordinary strike gets its own critical/damage calculation and + event. GBA crits draw `0..denominator-1` and compare with zero. The previous + `1..denominator` draw had the same probability but a different observable RNG + ABI. The raw comparison now covers every critical stage without normalizing + away bounds or returned values. +- Later strikes continue against real HP after Substitute breaks. Actual Endure + stops an ordinary multi-hit sequence; Focus Band can save later strikes too. + PP is spent once, with called moves preserving their native PP bypass. +- Later strikes inspect live user/target state. Sleep, user/target fainting and + a new type immunity can stop continuation. A later immunity suppresses the + native Twineedle chance command, retaining the first landed damage event. +- The existing nested dispatcher carries the calling move, so a Sleep Talk + caller can exempt sleeping continuation. This is tested after the caller's + move selection; complete Sleep Talk selection and status-controller parity + are not certified here. +- Native hit-count adapters are installed per dataset before mods capture + `mod.content.move_effects:get(...).hitCount`. Overrides and delegation work + without changing shared legacy statics. A no-loader path uses the same native + adapters. Other generations retain their existing count/damage policy. + +Public events are detached views. Continuation probes mutate the owned fixture's +real battlers when a public hit event arrives; they assert the mutation and its +consequences. They do not claim that writing into a detached event view changes +engine state. + +## Compatibility and scope + +GBA command revision is **6**, native party codec remains **1**. The raw fixture +hash changes from `da62fe7b50f40107` to `a7db0874db78aec0`, independently computed +with the unchanged #220 hasher and only the revision token changed from 5 to 6. +Older-generation goldens were not reblessed. Link peers reject revision 5 before +party selection. + +The ordinary native rules are deliberately separate from scripted Beat Up. +The first integration incorrectly applied ordinary Endure/user-faint termination +to Beat Up; the original full logs and eight differing comparison runs are +retained. The accepted implementation preserves all existing Beat Up contracts. + +Triple Kick's dedicated per-hit accuracy/power script, Ruby/Sapphire move-end +scheduling, complete ability interactions, broader canceller/controller rules, +Rage production, whole-game RNG/VBlank timing, animations and full playthroughs +remain separate work. This PR is a prerequisite for testing native Rage against +multi-hit attacks; it does not claim Rage itself has been ported. + +## Accepted evidence + +Feature runtime: `00e67c40abcb224ff693e4f543a4d018014edf73`. +Combined runtime: `1a20cb1f104972c46c756ba6a55e814fc27619c8`. +Gen1 reference: clean, unchanged and read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +- **17,974 focused checks** pass: 17,856 native command/live-continuation, 68 + composition/caller/legacy and 50 actual link/compatibility checks. The native + protocol suite also passes. +- Both clean runtimes match the source in **100 workers / 74,496 paired + observations**. The new mode contributes **5,760 observations per profile**, + covering all four battler slots, all count words, all critical stages, + Substitute/Endure/Focus Band/KO, Protect/hidden/accuracy/type immunity, + Twineedle chance, called PP, and live sleep/type/faint changes between hits. + Every RNG bound/value, individual public damage/move event, all battler HP/PP, + status, substitute HP, last move and attack stage is retained. +- Feature regression: **119/128 engine suites**, SDK **3/3**, B30 passed. + The exact Counter of all **25 inherited FAIL lines in nine suites** matches + clean #220 (116/125), without additions. The full command keeps its nonzero exit. +- Combined regression: **240/240 engine suites**, SDK **3/3**, B30 passed, + including the independent baseline repairs and all earlier Beat Up contracts. +- Unchanged #220 fails **1,524 focused assertions** from the exact accepted + fixture. Of its 5,760 FireRed observations, **5,088 differ** from the unchanged + source. The fixture SHA-256 matches the source report; raw negative output and + earlier exploratory/failed logs remain retained. +- Two real link roles complete three turns using the variable-count, double-hit + and Twineedle paths. All paired completed hashes/components and learned PP agree. +- **18 CPU-only scenarios** with actual local FireRed/Emerald imports pass public + canonical enemy-action selection and execution for DoubleSlap, Double Kick and + Twineedle, each ordinary/with a breaking Substitute/with Endure. Imported + accuracy, type, power and PP are retained; a controlled valid accuracy roll is + used. No caches, ROMs or saves are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Use the maintained LuaJIT pin in the workflow +(`a4c064539ea931274d407e1249b4bb575424a0f4`): + +```sh +nice -n 19 "$LUA" tests/engine/gen3_multi_hit.lua +nice -n 19 "$LUA" tests/engine/gen3_multi_hit_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_multi_hit_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/multi-hit-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Feature evidence is in `tmp/multi-hit-qa/`: the accepted full/comparison/import +logs, `accepted-evidence.json`, exact FAIL-Counter audit and unchanged-parent +negative control. Combined evidence is in `tmp/multi-hit-full-accepted.log`, +`tmp/multi-hit-contracts-accepted.json`, `tmp/multi-hit-imported.log` and +`tmp/multi-hit-accepted-evidence.json`. Later commits only record validation and +publication. Hosted CI is tracked separately in the published PR. From 920b71baa9f6d415c2c2f449b6e5593d69cc8950 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:16:08 +0100 Subject: [PATCH 59/90] Implement native GBA Rage state and move-end lifecycle --- src/battle/BattleState.lua | 78 +-- src/battle/EffectRegistry.lua | 9 +- src/battle/Gen3AI.lua | 1 + src/battle/Gen3Builtins.lua | 3 +- src/battle/Gen3Rage.lua | 80 +++ src/link/LinkBattle.lua | 5 + src/mods/BattleIntents.lua | 1 + tests/enemy_actions/rage.lua | 508 +++++++++++++++++++ tests/engine/gen3_rage.lua | 5 + tools/enemy-action-contract-check/worker.lua | 4 +- 10 files changed, 662 insertions(+), 32 deletions(-) create mode 100644 src/battle/Gen3Rage.lua create mode 100644 tests/enemy_actions/rage.lua create mode 100644 tests/engine/gen3_rage.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 82bd99e53..0fdefd240 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5240,9 +5240,7 @@ function BattleState:pokedudeUseAntidote(step) .use(self.data, self.game.save, "ANTIDOTE", self.player.mon, self) for _, message in ipairs(messages or {}) do self:say(message) end self:syncShownStatus() - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end @@ -5535,9 +5533,27 @@ function BattleState:resolveTurn(playerAction) -- the bench to replace it; `actionFor` asks each surviving slot for its own local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) local selectedPlayerAction, selectedEnemyAction = playerAction, enemyAction + local nativeActions if GameVersion.isGen3() then local Encore = require("src.battle.Gen3Encore") playerAction, enemyAction = Encore.plan(self.player, playerAction), Encore.plan(self.enemy, enemyAction) + local Rage = require("src.battle.Gen3Rage") + if self:isDouble() then + nativeActions = {} + for pos = 0, 3 do + local b = self:battlerAt(pos) + if b and b.mon and b.mon.hp > 0 then + local action = self._nativeEnemyActions and self._nativeEnemyActions[pos] + or self:actionFor(b, playerAction, enemyAction) + action = Encore.plan(b, action) + nativeActions[pos] = action + Rage.selected(self, b, action) + end + end + else + Rage.selected(self, self.player, playerAction) + Rage.selected(self, self.enemy, enemyAction) + end end self.turnCount = (self.turnCount or 0) + 1 if GameVersion.isGen3() then require("src.battle.Gen3FollowMe").clear(self) end @@ -5609,8 +5625,10 @@ function BattleState:resolveTurn(playerAction) for pos = 0, 3 do local b = self:battlerAt(pos) if b and b.mon and (b.mon.hp or 0) > 0 then - local action = self:actionFor(b, playerAction, enemyAction) - if GameVersion.isGen3() then action = require("src.battle.Gen3Encore").plan(b, action) end + -- Native choices were resolved once, before turn-start observers. + local action + if nativeActions then action = nativeActions[pos] + else action = self:actionFor(b, playerAction, enemyAction) end entries[#entries + 1] = { battler = b, move = orderMove(action, self.data), action = action } end @@ -5754,9 +5772,7 @@ function BattleState:resolveSwitch(newMon) if self.bw and require("src.battle.Gen5MoveRules").playerSwitch(self, newMon) then return end self:act(function() self:switchPlayerInto(BattleState.POS.PLAYER_LEFT, newMon) end) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end @@ -9824,9 +9840,7 @@ function BattleState:tryRun() -- lucky roll can never break the scene. if self.noRun then self:say(self:romText("_CantEscapeText", "Can't escape!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) return end @@ -9888,9 +9902,7 @@ function BattleState:tryRun() self.afterQueue = "finish" else self:say(self:romText("_CantEscapeText", "Can't escape!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end end @@ -9915,6 +9927,23 @@ function BattleState:gen4ItemTargetScreen() return Gen4Battle.partyScreenId(self) end +-- A committed non-move action consumes the turn. Native enemy selection +-- precedes Rage cleanup; older generations retain their delayed selection. +function BattleState:nonMoveAction() + local native, action = GameVersion.isGen3() + if native then + action = self:enemyAction() + action = require("src.battle.Gen3Encore").plan(self.enemy, action) + local Rage = require("src.battle.Gen3Rage") + Rage.selected(self, self.player, nil) + Rage.selected(self, self.enemy, action) + end + return function() + if not native then action = self:enemyAction() end + self:executeAction(self.enemy, self.player, action) + end +end + -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD @@ -9922,9 +9951,7 @@ function BattleState:itemUsed(messages) self:syncShownStatus() for _, m in ipairs(messages or {}) do self:say(m) end table.insert(self.queue, { drain = true }) -- potions animate the bar - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end @@ -10431,6 +10458,7 @@ end -- called by BagMenu when a ball is thrown function BattleState:throwBall(ball) + local reply = self:nonMoveAction() -- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1 -- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 / -- call PrintText` (:146-147), so a trainer battle never shows the @@ -10463,9 +10491,7 @@ function BattleState:throwBall(ball) or self:romText("_ThrowBallAtTrainerMonText1", "The trainer\nblocked the BALL!")) self:sayNext(t._ThrowBallAtTrainerMonText2 or self:romText("_ThrowBallAtTrainerMonText2", "Don't be a thief!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) return end @@ -10481,9 +10507,7 @@ function BattleState:throwBall(ball) self:animNext(self:tossAnimFor(ball), true, nil, ball) self:sayNext(Strings("It dodged the\nthrown BALL!")) self:sayNext(Strings("This POKéMON\ncan't be caught!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) return end @@ -10507,9 +10531,7 @@ function BattleState:throwBall(ball) self:act(function() self:storeCaughtMon() end) else self:sayNext(self:ballMissMessage(shakes)) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) end end) @@ -10561,9 +10583,7 @@ function BattleState:bugContestBall() else self:sayNext(self:ballMissMessage(shakes)) -- the ball is the player's turn, so the foe still moves - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end end) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index ebb412635..10ad3bc7f 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -180,7 +180,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) if nativeAccuracy or not neverMiss and not alwaysHits then local hit, reason = battle:accuracyRoll(move, user, target, accuracyRaw) if not hit then - if reason == "absorbed" then battle:cancelMoveAnim(); return end + if reason == "absorbed" then + battle:cancelMoveAnim() + if record and record.onMiss then record.onMiss(ctx, "absorbed") end + return + end -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -192,6 +196,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end + if record and record.afterAccuracy then record.afterAccuracy(ctx) end if gba then hits = hitCount(ctx, record) end -- damage per hit @@ -462,6 +467,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) MoveEffects.warnUnknown(move.effect) end + if gba then require("src.battle.Gen3Rage").moveEnd(battle, ctx, landed) end + if gba and ctx.gbaSpecialHit and target ~= user and target.mon.hp > 0 and target.mon.status == "FRZ" and require("src.battle.Abilities").normalizeType(move.type) == "FIRE" then target.mon.status = nil diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index f4a1c1bf4..b2a7043f3 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -555,6 +555,7 @@ local function status2(b) if b.confusedTurns then v = v + 0x7 end if b.infatuated then v = v + 0xF0000 end if b.focusEnergy then v = v + 0x100000 end + if b.rage then v = v + 0x800000 end if b.substituteHP then v = v + 0x1000000 end if b.trappingTurns or b.boundTurns then v = v + 0x4000000 end if b.nightmare then v = v + 0x8000000 end diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index 98b39879b..b06957c99 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -2,6 +2,7 @@ local M = {} function M.record(id, base) base = require("src.battle.Gen3Disable").builtin(id, base) - return require("src.battle.Gen3MultiHit").builtin(id, base) + base = require("src.battle.Gen3MultiHit").builtin(id, base) + return require("src.battle.Gen3Rage").builtin(id, base) end return M diff --git a/src/battle/Gen3Rage.lua b/src/battle/Gen3Rage.lua new file mode 100644 index 000000000..780bcd01a --- /dev/null +++ b/src/battle/Gen3Rage.lua @@ -0,0 +1,80 @@ +-- Native Rage is a move-end flag, not a free-PP move lock. +local R = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +function R.activate(ctx) + if V.isGen3() then + ctx.user.rage = true + end +end +function R.builtin(id, base) + if not V.isGen3() or not base or id ~= "RAGE_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.afterAccuracy = R.activate + out.afterDamage = function(ctx, ...) + if V.isGen3() then + return R.activate(ctx) + end + if base.afterDamage then + return base.afterDamage(ctx, ...) + end + end + out.onMiss = function(ctx, reason) + if V.isGen3() and reason ~= "immune" and reason ~= "floored" then + ctx.user.rage = nil + end + if base.onMiss then + return base.onMiss(ctx, reason) + end + end + out.onProtect = function(ctx) + if V.isGen3() then + ctx.user.rage = nil + end + if base.onProtect then + return base.onProtect(ctx) + end + end + cache[base] = out + return out +end +function R.selected(battle, user, action) + if not V.isGen3() or not user or not user.rage then + return + end + local def = action and not action.special and battle:moveDef(action) + local number = def and tonumber(def.index or def.numId) + if number ~= 99 and not (not number and action and action.id == "RAGE") then + user.rage = nil + end +end +function R.moveEnd(battle, ctx, landed) + local user, target = ctx.user, ctx.target + if + target + and target ~= user + and target.rage + and target.mon.hp > 0 + and battle:sideOf(user) ~= battle:sideOf(target) + and not ctx.gbaNoEffect + and landed > 0 + and (tonumber(ctx.move.power) or 0) > 0 + and (target.stages.attack or 0) < 6 + then + target.stages.attack = (target.stages.attack or 0) + 1 + battle:sayNext(Strings("%s's\nRAGE is building!", ctx.displayName(target))) + end +end +function R.signature(user) + return ":rage=" .. tostring(not not user.rage) +end +return R diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index fce669d22..f6acbc77f 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -165,6 +165,7 @@ local function activeStr(b, role) .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3BatonPass").signature(b, role) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Encore").signature(b) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Disable").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Rage").signature(b) or "") end local function benchStr(party) @@ -437,6 +438,8 @@ function LinkBattle.new(game, net, opts) if require("src.core.GameVersion").isGen3() then local Encore=require("src.battle.Gen3Encore") myAction,theirAction=Encore.plan(s.player,myAction),Encore.plan(s.enemy,theirAction) + local Rage=require("src.battle.Gen3Rage") + Rage.selected(s,s.player,myAction);Rage.selected(s,s.enemy,theirAction) end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, @@ -843,6 +846,8 @@ function LinkBattle.newSpectator(game, net, opts) if require("src.core.GameVersion").isGen3() then local Encore=require("src.battle.Gen3Encore") hostAction,guestAction=Encore.plan(s.player,hostAction),Encore.plan(s.enemy,guestAction) + local Rage=require("src.battle.Gen3Rage") + Rage.selected(s,s.player,hostAction);Rage.selected(s,s.enemy,guestAction) end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua index 2a5473db6..1def1dcb0 100644 --- a/src/mods/BattleIntents.lua +++ b/src/mods/BattleIntents.lua @@ -96,6 +96,7 @@ local BATTLER_FLAGS = { "thrashTurns", "thrashMove", "rageMove", + "rage", "trappingTurns", "bideTurns", "encoreTurns", diff --git a/tests/enemy_actions/rage.lua b/tests/enemy_actions/rage.lua new file mode 100644 index 000000000..6a6655bf1 --- /dev/null +++ b/tests/enemy_actions/rage.lua @@ -0,0 +1,508 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT" }, + { 24, "DOUBLE_KICK", 44, "ATTACK_TWICE_EFFECT" }, + { 41, "TWINEEDLE", 77, "TWINEEDLE_EFFECT" }, + { 99, "RAGE", 81, "RAGE_EFFECT" }, + { 700, "MOD_RAGE", 81, "RAGE_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 15, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 15, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + if number == 700 then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used = {}, {}, {}, {} + local owner, phase, currentKind, incomingHits + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + attack = owner and owner.stages.attack or 0, + rage = owner and not not owner.rage or false, + } + if phase == "incoming" then + incomingHits = incomingHits + 1 + if currentKind == "later_immunity" and incomingHits == 1 then + if native then + owner.type1, owner.type2 = 7, 7 + else + owner.curTypes = { "GHOST" } + end + end + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local nums = { + [99] = f.ids.RAGE, + [3] = f.ids.DOUBLE_SLAP, + [24] = f.ids.DOUBLE_KICK, + [33] = f.ids.TACKLE, + [700] = f.ids.MOD_RAGE, + } + local function num(id) + if native then + return tonumber(id) + end + for n, name in pairs(nums) do + if name == id then + return n + end + end + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 99, 3, 24, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.RAGE, pp = 10 }, + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local v = hi + if lo == 0 and hi == 1 and currentKind == "confused" then + v = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + return b + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if native then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function record(b, label) + local field = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for slot = 1, 4 do + pp[slot] = native and a.mon.pp[slot] or a.curMoves[slot].pp + end + field[#field + 1] = { + hp = a.mon.hp, + pp = pp, + rage = not not a.rage, + legacyLock = not not a.rageMove, + attack = a.stages.attack or 0, + last = num(native and a.lastMoveId or a.lastMove), + sub = tonumber(a.substituteHP) or 0, + status = a.mon.status, + } + end + rows[#rows + 1] = + { label = label, field = field, draws = Copy(draws), damage = Copy(damage), used = Copy(used) } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for _, previous in ipairs({ false, true }) do + for _, kind in ipairs({ + "ordinary", + "repeated", + "protected", + "hidden", + "immune", + "substitute", + "accuracy_miss", + "disabled", + "sleep", + "confused", + "called", + "called_disabled", + "custom", + "absorbed", + }) do + currentKind = kind + phase = "producer" + draws, damage, used = {}, {}, {} + local b = fresh(double) + owner = at(b, uid) + local foe = at(b, tid) + owner.rage = previous or nil + local raw = native and f.data.gen3Moves._rom[99] or f.data.moves[f.ids.RAGE] + local accuracy, moveType = raw.accuracy, raw.type + if kind == "protected" then + foe[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + foe.semiInvulnerable = "ON_AIR" + else + foe.invulnerable, foe.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + foe.type1, foe.type2 = 7, 7 + else + foe.curTypes = { "GHOST" } + end + end + if kind == "substitute" then + foe.substituteHP = 100 + end + if kind == "accuracy_miss" then + raw.accuracy = 90 + end + if kind == "disabled" or kind == "called_disabled" then + owner[native and "expDisabledMove" or "disabledMove"] = native and 99 or f.ids.RAGE + end + if kind == "sleep" then + owner.mon.status = "SLP" + owner.sleepTurns = 2 + end + if kind == "confused" then + owner[native and "confusionTurns" or "confusedTurns"] = 4 + end + if kind == "custom" then + if native then + owner.mon.moves[1] = 700 + else + owner.curMoves[1].id = f.ids.MOD_RAGE + end + end + if kind == "absorbed" then + raw.type = native and 13 or "ELECTRIC" + if native then + foe.ability, foe.mon.ability = 10, 10 + else + foe.abilityOverride = "VOLT_ABSORB" + end + end + cast(b, uid, tid, 1, kind == "called" or kind == "called_disabled") + if kind == "repeated" then + cast(b, uid, tid, 1) + end + if + kind == "ordinary" + or kind == "repeated" + or kind == "immune" + or kind == "substitute" + or kind == "called" + or kind == "called_disabled" + or kind == "custom" + then + T.eq(owner.rage, true, "native Rage activates independently of a legacy move lock") + T.eq(owner.rageMove, nil, "native Rage never installs the free-PP legacy controller") + end + record( + b, + (double and "double" or "single") .. "/" .. uid .. "/" .. tostring(previous) .. "/" .. kind + ) + raw.accuracy, raw.type = accuracy, moveType + end + end + for _, kind in ipairs({ + "ordinary", + "multi", + "two", + "sub_survive", + "sub_break", + "endure", + "cap", + "last_stage", + "protected", + "hidden", + "immune", + "ally", + "self_called", + "later_immunity", + "residual", + }) do + if double or kind ~= "ally" then + currentKind = kind + phase = "producer" + draws, damage, used = {}, {}, {} + local b = fresh(double) + owner = at(b, uid) + cast(b, uid, tid, 1) + record(b, "incoming/" .. kind .. "/activated/" .. uid .. "/" .. tostring(double)) + phase = "incoming" + incomingHits = 0 + local attacker = kind == "ally" and (uid + 2) % 4 or tid + local called = kind == "self_called" + if called then + attacker = uid + end + local slot = (kind == "multi" or kind == "later_immunity") and 2 or kind == "two" and 3 or 4 + if kind == "sub_survive" then + owner.substituteHP = 400 + end + if kind == "sub_break" then + owner.substituteHP = 1 + end + if kind == "endure" then + owner.mon.hp = 1 + owner[native and "expEnduring" or "enduring"] = true + end + if kind == "cap" or kind == "last_stage" then + owner.stages.attack = kind == "cap" and 6 or 5 + end + if kind == "protected" then + owner[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + owner.semiInvulnerable = "ON_AIR" + else + owner.invulnerable, owner.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + owner.type1, owner.type2 = 7, 7 + else + owner.curTypes = { "GHOST" } + end + end + if kind == "residual" then + owner.mon.status = "PSN" + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + else + cast(b, attacker, uid, slot, called) + end + local expected = ( + kind == "ordinary" + or kind == "multi" + or kind == "two" + or kind == "sub_survive" + or kind == "sub_break" + or kind == "endure" + ) + and 1 + or (kind == "cap" or kind == "last_stage") and 6 + or 0 + T.eq( + owner.stages.attack or 0, + expected, + "native Rage boost applies once at the completed move boundary" + ) + record(b, "incoming/" .. kind .. "/finished/" .. uid .. "/" .. tostring(double)) + end + end + end + end + for _, double in ipairs({ false, true }) do + for _, choice in ipairs({ "rage", "other", "custom", "encore_rage", "encore_other" }) do + currentKind, phase = choice, "planning" + draws, damage, used = {}, {}, {} + local b = fresh(double) + -- Match the already prepared native turn input; this does not certify + -- the earlier global RNG/order orchestration (see the Encore contract). + b.randomTurnNumber, b._nativeOrderReady = 65535, true + local chosen = (choice == "rage" or choice == "encore_other") and 99 or choice == "custom" and 700 or 33 + local slot = chosen == 99 and 1 or 4 + local actions = {} + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.rage = true + if chosen == 700 then + if native then + a.mon.moves[4] = 700 + else + a.curMoves[4].id = f.ids.MOD_RAGE + end + end + if choice == "encore_rage" or choice == "encore_other" then + a[native and "expEncoreMove" or "encoreMove"] = native and (choice == "encore_rage" and 99 or 33) + or (choice == "encore_rage" and f.ids.RAGE or f.ids.TACKLE) + a[native and "expEncoreSlot" or "encoreSlot"] = choice == "encore_rage" and 1 or 4 + a[native and "expEncoreTurns" or "encoreTurns"] = 4 + end + actions[id] = + { kind = "move", move = chosen, slot = slot, battler = id, target = id % 2 == 0 and id + 1 or id - 1 } + end + if native then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + actions + ) + else + b.pendingActions = { [0] = at(b, 0).curMoves[slot], [2] = double and at(b, 2).curMoves[slot] or nil } + f.e.choose(function() + return { move = chosen == 99 and "RAGE" or chosen == 700 and "MOD_RAGE" or "TACKLE" } + end) + b:resolveTurn(b.pendingActions[0]) + end + for id = 0, double and 3 or 1 do + T.eq( + not not at(b, id).rage, + choice == "rage" or choice == "encore_rage", + "real planner clears Rage using the actual forced choice before actions" + ) + end + record(b, "planning/" .. choice .. "/" .. tostring(double)) + rows[#rows].preparedTurnWord = b.randomTurnNumber + end + end + for _, kind in ipairs({ "item", "switch", "run" }) do + currentKind, phase = kind, "nonmove" + draws, damage, used = {}, {}, {} + local b = fresh(false) + owner = at(b, 0) + at(b, 0).rage, at(b, 1).rage = true, true + b.randomTurnNumber, b._nativeOrderReady = 65535, true + if native then + require("src.core.game3.battle.engine").planTurnFromActions( + b, + require("src.core.game3.battle.adapter").new(b), + { kind = kind, user = "player" }, + { kind = "move", move = 33, slot = 4, user = "enemy" } + ) + else + f.e.choose(function() + return { move = "TACKLE" } + end) + -- The host spends non-move turns through its actual queued reply. + b:nonMoveAction() + end + T.eq(owner.rage, nil, "committed non-move action clears Rage") + T.eq(at(b, 1).rage, nil, "the independently selected enemy move clears its Rage too") + record(b, "nonmove/" .. kind) + rows[#rows].preparedTurnWord = b.randomTurnNumber + end + off() + offMove() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_rage.lua b/tests/engine/gen3_rage.lua new file mode 100644 index 000000000..94beba910 --- /dev/null +++ b/tests/engine/gen3_rage.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, v in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.rage").run(".", "gen2", v) +end +require("tests.modkit").finish("native GBA Rage activation, PP and move-end boosts") diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index f0530104e..ab27e5d38 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "multi_hit" then +if mode == "rage" then + rows = dofile(root .. "/tests/enemy_actions/rage.lua").run(root, backend, version) +elseif mode == "multi_hit" then rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version) elseif mode == "disable" then rows = dofile(root .. "/tests/enemy_actions/disable.lua").run(root, backend, version) From 381afee9cd5c76320fb7121c293d14b0edd5a3d4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:57:31 +0200 Subject: [PATCH 60/90] Align native GBA AI order prediction and turn planning --- .github/workflows/gen3-ai-order-contract.yml | 41 ++++ docs/gen3-ai-order.md | 78 ++++++++ src/battle/BattleState.lua | 73 +++++-- src/battle/Gen3AI.lua | 1 + src/battle/Gen3AIOrder.lua | 146 ++++++++++++++ src/battle/Gen3OrderCore.lua | 84 ++++++++ src/battle/HoldItems.lua | 7 +- src/battle/TurnOrder.lua | 29 +++ tests/engine/gen3_ai_order.lua | 6 + tests/gen3_ai_order/cases.lua | 191 ++++++++++++++++++ tests/gen3_ai_order/planning.lua | 196 +++++++++++++++++++ tests/gen3_ai_order/scenarios.lua | 107 ++++++++++ tests/gen3_ai_order/worker.lua | 5 + tools/gen3-ai-order-check/check.py | 97 +++++++++ tools/gen3-ai-order-check/oracle.c | 87 ++++++++ 15 files changed, 1128 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/gen3-ai-order-contract.yml create mode 100644 docs/gen3-ai-order.md create mode 100644 src/battle/Gen3AIOrder.lua create mode 100644 src/battle/Gen3OrderCore.lua create mode 100644 tests/engine/gen3_ai_order.lua create mode 100644 tests/gen3_ai_order/cases.lua create mode 100644 tests/gen3_ai_order/planning.lua create mode 100644 tests/gen3_ai_order/scenarios.lua create mode 100644 tests/gen3_ai_order/worker.lua create mode 100644 tools/gen3-ai-order-check/check.py create mode 100644 tools/gen3-ai-order-check/oracle.c diff --git a/.github/workflows/gen3-ai-order-contract.yml b/.github/workflows/gen3-ai-order-contract.yml new file mode 100644 index 000000000..42c6d3827 --- /dev/null +++ b/.github/workflows/gen3-ai-order-contract.yml @@ -0,0 +1,41 @@ +name: native GBA AI order +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen3-ai-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: pret/pokefirered + ref: ce6406d8c2eaf46d543c8a2606de0c3cb1951982 + path: .contract-reference/firered + - uses: actions/checkout@v7 + with: + repository: pret/pokeemerald + ref: 731ad5bfd6e6f265508d0efcca0ba42f9dcf5881 + path: .contract-reference/emerald + - name: Install runtime and compiler + run: sudo apt-get update && sudo apt-get install -y luajit build-essential + - name: Check real AI queries and battle planning + run: luajit tests/engine/gen3_ai_order.lua + - name: Compare actual VMs and sorting with original native C + run: | + python3 tools/gen3-ai-order-check/check.py \ + --firered-main .contract-reference/firered/src/battle_main.c \ + --emerald-main .contract-reference/emerald/src/battle_main.c \ + --report /tmp/gen3-ai-order/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gen3-ai-order + path: /tmp/gen3-ai-order + if-no-files-found: ignore diff --git a/docs/gen3-ai-order.md b/docs/gen3-ai-order.md new file mode 100644 index 000000000..69ca0594c --- /dev/null +++ b/docs/gen3-ai-order.md @@ -0,0 +1,78 @@ +# Native GBA order prediction and planning + +The native AI commands `if_user_goes` and `if_user_doesnt_go` compare the +literal result of `GetWhoStrikesFirst(user, target, TRUE)` with their operand. +They do not select a battler through that operand. Result 0 means the user +goes first, 1 means the target has greater speed, and 2 is the odd random +result of an equal-speed tie. The inverse compares for inequality. + +This adds both commands and makes actual GBA move planning share their speed +arithmetic: weather before integer stage division, native third-badge policy, +Macho Brace, paralysis, and the strict Quick Claw threshold. Speed can be zero. +Enigma Berry uses the physical holder's per-battler berry definition. Living +Cloud Nine or Air Lock holders suppress weather, including either right-hand +slot in a double battle. AI prediction ignores chosen move priority; actual +planning applies it before resolving equal-priority speed ties. + +A single unsigned 16-bit word is prepared before enemy selection, reused for +both holders and actual planning, and released after planning or end of turn. +No query samples another Quick Claw word. Both successful claws set their +holder's speed to UINT32_MAX and therefore tie rather than favoring one side. +The native strict threshold can fail even at a 100-percent parameter when the +stored word is 65535. The existing actual comparator RNG interface stays +`rng(0, 1)`; AI tie queries use `rng(0, 65535)`. This tests native decisions and +draw placement, not bit-for-bit equivalence between different RNG algorithms. + +Double battles compare each later pair in the native nested i,j order. Item +and switch choices form a stable position-ordered prefix and do not undergo +speed comparisons. Both enemy choices precede new-turn flag reset. The public +`battle.turn_order` chain remains active per real comparison and honors +arguments forwarded by a wrapping mod. Actual priority item reads keep the +held-item extension seam; AI reads use physical metadata. + +## References and validation + +The primary references are `GetWhoStrikesFirst`, +`SetActionsAndBattlersTurnOrder`, and the surrounding action-selection/turn +transitions in: + +- [FireRed at ce6406d8](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/battle_main.c) +- [Emerald at 731ad5bf](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_main.c) + +Run the ROM-free focused suite with: + +```sh +luajit tests/engine/gen3_ai_order.lua +``` + +The matrix executes real Gen2Recomped AI programs for both profiles over all +13 speed stages, rounding boundaries, weather masks, suppressors, badge modes, +status words, per-battler Enigma data, Quick Claw boundary words and literal +comparison operands. It also invokes the owned comparator and sorter. The +oracle compiles the original C function from pinned, read-only decompilation +checkouts. It validates the function's SHA-256 before adding observations of +its local speed variables; no native function or ROM content is copied into +this repository. The authored shim supplies declared native input data and +the relevant item, flag, weather-ability and RNG interfaces. + +```sh +python3 tools/gen3-ai-order-check/check.py \ + --firered-main /path/to/pokefirered/src/battle_main.c \ + --emerald-main /path/to/pokeemerald/src/battle_main.c \ + --report /tmp/gen3-ai-order/records.json +``` + +The oracle covers 262 cases per profile: 5,240 actual VM queries, 524 actual +pair comparisons and 524 sorts in total. A separate real `BattleState` fixture +constructs battles, selects via a registered enemy-action consumer, plans and +executes ordinary single/double move queues, then advances through end of turn. +It covers word reuse/renewal, both claws, priority, right-slot weather abilities, +real save badge flags and forwarded hook arguments. Metadata cases inspect the +actual queued entries; complete trainer item/switch controllers are outside +that fixture's scope. Validation is CPU-only and does not claim a graphical +playthrough or complete stock AI semantic coverage. + +This PR is for Gen2Recomped only. It requires no change, PR, or patched checkout +of Gen1Recomp. Gen1Recomp remains a read-only portability reference; its existing +speed-only predictor is not treated as a native correctness oracle. Existing +non-GBA ordering policies stay on their original path. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0fdefd240..586e4e3f1 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5416,6 +5416,12 @@ end -- killing their first Pokemon. A slot with nobody in it has no action. function BattleState:enemyAction(battler) local requestedBattler = battler + if GameVersion.isGen3() then + local who = battler or self.enemy + if who and who.mon and (who.mon.hp or 0) > 0 then + require("src.battle.Gen3AIOrder").prepare(self, self._nativeOrderSelectionTurn or self.turnCount or 0) + end + end battler = battler or self.enemy if not battler then return nil end -- The POKé DUDE opponent does not run the AI at all. Its controller feeds @@ -5529,9 +5535,22 @@ function BattleState:choosingBattler() end function BattleState:resolveTurn(playerAction) + if GameVersion.isGen3() then + self._nativeOrderSelectionTurn = (self.turnCount or 0) + 1 + require("src.battle.Gen3AIOrder").prepare(self, self._nativeOrderSelectionTurn) + end -- nil in a double battle whose left-hand foe has fainted with nothing on -- the bench to replace it; `actionFor` asks each surviving slot for its own local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) + local enemyActions + if GameVersion.isGen3() and self:isDouble() then + enemyActions = { [1] = enemyAction } + local right = self:battlerAt(3) + if right and right.mon and (right.mon.hp or 0) > 0 then + enemyActions[3] = self:enemyAction(right) + end + end + self._nativeOrderSelectionTurn = nil local selectedPlayerAction, selectedEnemyAction = playerAction, enemyAction local nativeActions if GameVersion.isGen3() then @@ -5543,8 +5562,9 @@ function BattleState:resolveTurn(playerAction) for pos = 0, 3 do local b = self:battlerAt(pos) if b and b.mon and b.mon.hp > 0 then - local action = self._nativeEnemyActions and self._nativeEnemyActions[pos] - or self:actionFor(b, playerAction, enemyAction) + local action + if enemyActions and not b.isPlayer and not b.ally then action = enemyActions[pos] + else action = self:actionFor(b, playerAction, enemyAction) end action = Encore.plan(b, action) nativeActions[pos] = action Rage.selected(self, b, action) @@ -5577,13 +5597,15 @@ function BattleState:resolveTurn(playerAction) }) local pMove = orderMove(playerAction, self.data) local eMove = orderMove(enemyAction, self.data) - -- QUICK CLAW, before the speed comparison and before priority is even - -- read: AI_TryToFaint's caller rolls Random() % 100 against the item's own - -- parameter for each side and a winner simply goes first. Both sides - -- rolling it is not a draw -- the cartridge checks the player's first, so - -- a double claw is the player's turn. - local pClaw = self:quickClawWins(self.player) - local eClaw = not pClaw and self:quickClawWins(self.enemy) + -- GBA Quick Claw uses the shared turn word through the native comparator; + -- the earlier-generation path retains its separate activation rolls. + local function gbaMeta(action) + local special = action and action.special + return GameVersion.isGen3() and (special == "aiItem" or special == "aiSwitch" or special == "playerSwitch") + end + local singleMeta = not self:isDouble() and (gbaMeta(playerAction) or gbaMeta(enemyAction)) + local pClaw = not GameVersion.isGen3() and self:quickClawWins(self.player) + local eClaw = not GameVersion.isGen3() and not pClaw and self:quickClawWins(self.enemy) local pFirst -- EITHER LEFT SLOT CAN BE EMPTY IN A DOUBLE. A fainted battler is lifted -- off the field at once and only replaced when the turn ends, so between @@ -5591,7 +5613,11 @@ function BattleState:resolveTurn(playerAction) -- flank fights on. The pairwise comparison below is the SINGLE battle's -- ordering and has nothing to compare; the double branch sorts all four -- entries with the same comparator a few lines down and does not read it. - if not (self.player and self.enemy) then + if GameVersion.isGen3() and self:isDouble() then + pFirst = true -- native double planning compares only the real pairs below + elseif singleMeta then + pFirst = gbaMeta(playerAction) + elseif not (self.player and self.enemy) then pFirst = true elseif pClaw or eClaw then pFirst = pClaw == true @@ -5615,12 +5641,9 @@ function BattleState:resolveTurn(playerAction) if self:isDouble() then -- FOUR ENTRIES, SORTED WITH THE SAME COMPARATOR. -- - -- SetActionsAndBattlersTurnOrder builds the list in position order and - -- bubbles it with GetWhoStrikesFirst, which is firstMover -- so this is - -- the same rule applied to four rather than a second rule for doubles. - -- Built in position order 0,1,2,3 because that is the order the - -- cartridge fills its own array in, and a bubble is stable, so - -- equal-speed entries fall out the same way it does. + -- GBA SetActionsAndBattlersTurnOrder starts in position order, keeps + -- item/switch choices in that order, and compares every remaining i,j + -- pair. Equal-speed comparisons consume one tie draw each. local entries = {} for pos = 0, 3 do local b = self:battlerAt(pos) @@ -5633,6 +5656,14 @@ function BattleState:resolveTurn(playerAction) action = action } end end + local metadata = {} + if GameVersion.isGen3() then + local moves = {} + for _, e in ipairs(entries) do + if gbaMeta(e.action) then metadata[#metadata + 1] = e else moves[#moves + 1] = e end + end + entries = moves + end TurnOrder.order(entries, self.rng, self) -- and a QUICK CLAW winner is simply hoisted to the front if pClaw or eClaw then @@ -5645,6 +5676,12 @@ function BattleState:resolveTurn(playerAction) end end end + if #metadata > 0 then + local merged = {} + for _, e in ipairs(metadata) do merged[#merged + 1] = e end + for _, e in ipairs(entries) do merged[#merged + 1] = e end + entries = merged + end -- ...AND EVERY SWITCH HAPPENS BEFORE EVERY MOVE. -- -- SetActionsAndBattlersTurnOrder runs the switch actions out of the array @@ -5660,7 +5697,7 @@ function BattleState:resolveTurn(playerAction) moves[#moves + 1] = e end end - if #switches > 0 then + if not GameVersion.isGen3() and #switches > 0 then entries = switches for _, e in ipairs(moves) do entries[#entries + 1] = e end end @@ -5680,6 +5717,7 @@ function BattleState:resolveTurn(playerAction) -- BLACK AND WHITE: who chose what, Quick Claw / Custap, Pursuit before a -- switch (src/battle/Gen5MoveRules.lua) + if GameVersion.isGen3() then require("src.battle.Gen3AIOrder").release(self) end if self.bw then order = require("src.battle.Gen5MoveRules").arrange(self, order) end if GameVersion.isGen3() then @@ -6374,6 +6412,7 @@ function BattleState:holdItemTrigger(battler, when) end function BattleState:endOfTurn() + if GameVersion.isGen3() then require("src.battle.Gen3AIOrder").release(self) end -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual -- sweep and the trapping-counter release are skipped on the turn a diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index b2a7043f3..475b862bd 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -681,6 +681,7 @@ end -- --------------------------------------------------------------------------- require("src.battle.Gen3AIItemKnowledge").install(Gen3AI) +require("src.battle.Gen3AIOrder").install(Gen3AI) -- Every opcode a script can reach, followed through its branches. Memoised -- on the program table: the answer depends only on the cartridge. diff --git a/src/battle/Gen3AIOrder.lua b/src/battle/Gen3AIOrder.lua new file mode 100644 index 000000000..ecc0e369d --- /dev/null +++ b/src/battle/Gen3AIOrder.lua @@ -0,0 +1,146 @@ +-- Native order snapshots use physical item metadata; actual sorting retains hooks. +local O = {} +local C = require("src.battle.Gen3OrderCore") +local bit = require("bit") +local ABILITIES = { CLOUD_NINE = 13, AIR_LOCK = 76, SWIFT_SWIM = 33, CHLOROPHYLL = 34 } +local function abilityId(value) + return tonumber(value) or ABILITIES[tostring(value or ""):upper():gsub("%s+", "_")] or 0 +end +local function berryOf(b, battle, id) + return b.enigmaBerry or battle.enigmaBerries and battle.enigmaBerries[id] or {} +end +function O.prepare(battle, turn, rng) + turn = turn or battle.turn or battle.turnCount or 0 + if not battle._nativeOrderReady or battle.randomTurnNumber == nil then + battle.randomTurnNumber = math.floor((rng or battle.rng or math.random)(0, 65535)) % 65536 + battle._nativeOrderTurn = turn + battle._nativeOrderReady = true + end + return battle.randomTurnNumber +end +function O.release(battle) + battle._nativeOrderReady = false +end +local function definition(b, battle, value) + local items = b.items or battle.data and battle.data.items or {} + if items[value] then + return items[value] + end + local number = tonumber(value) + if number then + for _, row in pairs(items) do + if type(row) == "table" and tonumber(row.index) == number then + return row + end + end + end +end +function O.hold(b, battle) + local mon = b.mon or {} + local value = mon.item or mon.heldItem or 0 + local def = definition(b, battle, value) + local item = tonumber(def and def.index) or tonumber(value) or 0 + if item == 175 then + local berry = berryOf(b, battle, b.position) + return tonumber(berry.holdEffect) or 0, tonumber(berry.holdEffectParam) or 0, item + end + def = def or {} + return tonumber(def.gen3HoldEffect) or tonumber(def.holdEffect) or C.effectIds[def.holdEffect] or 0, + tonumber(def.holdEffectParam) or 0, + item +end +local function ability(b) + return abilityId(require("src.battle.Abilities").of(b)) +end +local function badge(battle) + if type(battle.badges) == "table" then + return battle.badges[3] == true + end + if type(battle.badges) == "number" then + return bit.band(battle.badges, 4) ~= 0 + end + local constants = battle.data and battle.data.constants or {} + local flag = constants.gen3BadgeFlags and constants.gen3BadgeFlags[3] + local flags = battle.game and battle.game.save and battle.game.save.flags or {} + return flag ~= nil and flags[("FLAG_G3_%04X"):format(flag)] == true +end +function O.field(battle) + local kinds = battle.kinds or {} + local field = { + weather = C.weather(battle.weather or battle.field and battle.field.weather), + weatherActive = true, + turnWord = (tonumber(battle.randomTurnNumber) or 0) % 65536, + badge = badge(battle), + emerald = require("src.core.GameVersion").get() == "emerald", + link = battle.link == true, + frontier = battle.gen3Frontier == true or kinds.frontier == true, + recordedLink = kinds.recordedLink == true, + } + for id = 0, battle.double and 3 or 1 do + local b = battle.battlerAt and battle:battlerAt(id) + or battle.battlers and battle.battlers[id] + or (id == 0 and battle.player or id == 1 and battle.enemy) + if b and b.mon and (tonumber(b.mon.hp) or 0) > 0 then + local ab = ability(b) + if ab == 13 or ab == 76 then + field.weatherActive = false + end + end + end + return field +end +function O.snapshot(b, battle, actual) + local mon = b.mon or {} + local effect, parameter = O.hold(b, battle) + if actual then + local name + name, parameter = require("src.battle.HoldItems").effectOf( + b, + "priority", + { effect = C.effectNames[effect], parameter = parameter } + ) + effect = C.effectIds[name] or 0 + end + return { + speed = tonumber(b.curStats and b.curStats.speed) or tonumber(mon.stats and mon.stats.speed) or tonumber( + mon.speed or mon.spe + ) or 0, + stage = b.stages and b.stages.speed or 0, + ability = ability(b), + status = mon.status, + effect = effect, + parameter = parameter, + player = b.position ~= nil and b.position % 2 == 0 or b.isPlayer == true, + } +end +function O.speed(b, battle, actual) + local adjust + if actual then + local record = require("src.battle.Status").recordFor(b.statuses, b.mon and b.mon.status) + local penalty = record and record.statPenalty + if penalty and penalty.stat == "speed" and not C.paralysed(b.mon.status) then + adjust = function(speed) + return math.max(1, math.floor(speed / penalty.div)) + end + end + end + return C.speed(O.snapshot(b, battle, actual), O.field(battle), adjust) +end +function O.result(vm) + return C.result(O.speed(vm.user, vm.battle), O.speed(vm.target, vm.battle), function() + return vm.rng(0, 65535) + end) +end +function O.install(ai) + ai.OPS.if_user_goes = function(vm, args) + if O.result(vm) == (args[1] or 0) then + return args[#args] + end + end + ai.OPS.if_user_doesnt_go = function(vm, args) + if O.result(vm) ~= (args[1] or 0) then + return args[#args] + end + end +end +return O diff --git a/src/battle/Gen3OrderCore.lua b/src/battle/Gen3OrderCore.lua new file mode 100644 index 000000000..4fddce2b1 --- /dev/null +++ b/src/battle/Gen3OrderCore.lua @@ -0,0 +1,84 @@ +-- Native GBA speed arithmetic and GetWhoStrikesFirst result codes. +local C = {} +local bit = require("bit") +local ratios = { + { 10, 40 }, + { 10, 35 }, + { 10, 30 }, + { 10, 25 }, + { 10, 20 }, + { 10, 15 }, + { 10, 10 }, + { 15, 10 }, + { 20, 10 }, + { 25, 10 }, + { 30, 10 }, + { 35, 10 }, + { 40, 10 }, +} +C.effectNames, C.effectIds = {}, {} +for name in + ([[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]]):gmatch( + "%S+" + ) +do + C.effectNames[#C.effectNames + 1] = name + C.effectIds[name] = #C.effectNames +end +function C.weather(value) + if type(value) == "number" then + return value + end + value = tostring(value or ""):upper() + if value == "RAIN" or value == "RAINY" or value == "DOWNPOUR" then + return 7 + end + if value == "SUN" or value == "SUNNY" or value == "HARSH_SUN" then + return 0x60 + end + return 0 +end +function C.paralysed(value) + if type(value) == "number" then + return bit.band(value, 64) ~= 0 + end + return value == "PAR" or value == "PARALYSIS" +end +function C.speed(v, field, adjust) + local mul = field.weatherActive + and ((v.ability == 33 and bit.band(field.weather, 7) ~= 0) or (v.ability == 34 and bit.band( + field.weather, + 0x60 + ) ~= 0)) + and 2 + or 1 + local ratio = ratios[math.max(1, math.min(13, math.floor(v.stage or 0) + 7))] + local value = math.floor((math.floor(v.speed or 0) % 65536) * mul * ratio[1] / ratio[2]) + local noBadge = field.link or field.emerald and (field.frontier or field.recordedLink) + if v.player and field.badge and not noBadge then + value = math.floor(value * 110 / 100) + end + if v.effect == 24 then + value = math.floor(value / 2) + end + if C.paralysed(v.status) then + value = math.floor(value / 4) + end + if adjust then + value = adjust(value) + end + if v.effect == 26 and field.turnWord < math.floor(65535 * (v.parameter or 0) / 100) then + value = 4294967295 + end + return value +end +function C.result(a, b, draw) + if a < b then + return 1 + end + if a > b then + return 0 + end + return math.floor(draw()) % 2 == 1 and 2 or 0 +end +return C diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index 8ee2f2fb7..ee0f9b3f8 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -48,7 +48,8 @@ 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. -local function nativeEffect(battler) +local function nativeEffect(battler, native) + if type(native) == "table" then return native.effect, native.parameter, HoldItems.defOf(battler) end local def = HoldItems.defOf(battler) if not def then return nil end local effect,param=require('src.inventory.PolishedBerries').held(def,battler.mon) @@ -58,8 +59,8 @@ local function nativeEffect(battler) return effect, tonumber(def.holdEffectParam) or 0, def end -function HoldItems.effectOf(battler, trigger) - local effect, param, def = nativeEffect(battler) +function HoldItems.effectOf(battler, trigger, native) + local effect, param, def = nativeEffect(battler, native) 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) diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 05fdce1c0..e07f326e8 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -17,6 +17,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) + if require("src.core.GameVersion").isGen3() and battle then return require("src.battle.Gen3AIOrder").speed(battler, battle, true) end local spd = Stats.applyStage(battler.curStats.speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from @@ -80,6 +81,14 @@ end -- who moves first. function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie, battle, data) rng = rng or love.math.random + if require("src.core.GameVersion").isGen3() and battle then + local pa, pb = priority(aMove), priority(bMove) + local O, C = require("src.battle.Gen3AIOrder"), require("src.battle.Gen3OrderCore") + local sa, sb = O.speed(a, battle, true), O.speed(b, battle, true) + if pa ~= pb then return pa > pb end + if sa ~= sb then return sa > sb end + return (C.result(sa, sb, function() return rng(0, 1) end) == 0) ~= (invertTie == true) + end local pa, pb = priority(aMove), priority(bMove) if pa ~= pb then return pa > pb end @@ -138,6 +147,26 @@ TurnOrder.effectiveSpeed = effectiveSpeed -- which is the order the cartridge fills its own array in, so equal-speed -- entries fall out the same way. function TurnOrder.order(entries, rng, battle) + if require("src.core.GameVersion").isGen3() and battle then + for i = 1, #entries - 1 do + for j = i + 1, #entries do + local a, b = entries[i], entries[j] + local function vanilla(first, firstMove, second, secondMove, ctx) + return TurnOrder.firstMover(first, firstMove, second, secondMove, + ctx.rng, ctx.invertTie, ctx.battle, ctx.data) + end + local Runtime = require("src.mods.Runtime") + local first + if Runtime.wantsHook("battle.turn_order") then + first = Runtime.call("battle.turn_order", vanilla, a.battler, a.move, b.battler, b.move, + { battle = battle, data = battle.data, rng = rng, firstId = a.battler.position, + secondId = b.battler.position, playerMove = a.move and a.move.id, enemyMove = b.move and b.move.id }) + else first = TurnOrder.firstMover(a.battler, a.move, b.battler, b.move, rng, false, battle) end + if not first then entries[i], entries[j] = b, a end + end + end + return entries + end local n = #entries for pass = 1, n - 1 do local swapped = false diff --git a/tests/engine/gen3_ai_order.lua b/tests/engine/gen3_ai_order.lua new file mode 100644 index 000000000..cacada82f --- /dev/null +++ b/tests/engine/gen3_ai_order.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gen3_ai_order/cases.lua").run(".", version) + dofile("tests/gen3_ai_order/planning.lua").run(version) +end +require("tests.modkit").finish("native GBA AI order") diff --git a/tests/gen3_ai_order/cases.lua b/tests/gen3_ai_order/cases.lua new file mode 100644 index 000000000..31d7c212c --- /dev/null +++ b/tests/gen3_ai_order/cases.lua @@ -0,0 +1,191 @@ +-- Actual VM programs; the checker additionally evaluates the original C function. +local C = {} +function C.run(root, version) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local AI = require("src.battle.Gen3AI") + local O = require("src.battle.Gen3AIOrder") + local Core = require("src.battle.Gen3OrderCore") + local copy = require("src.mods.Merge").deepCopy + local raw, defs = {}, {} + local rows = {} + local function at(values, id, default) + return values and values[id + 1] ~= nil and values[id + 1] or default + end + for _, spec in ipairs(dofile(root .. "/tests/gen3_ai_order/scenarios.lua")) do + for k in pairs(raw) do + raw[k] = nil + end + for k in pairs(defs) do + defs[k] = nil + end + local flags = spec.mode == "link" and 1 + or spec.mode == "recorded" and 2 + or spec.mode == "frontier" and 4 + or 0 + local b = { + battlers = {}, + double = spec.double == true, + badges = { [3] = spec.badge == true }, + kinds = { frontier = flags == 4, recordedLink = flags == 2 }, + link = flags == 1, + weather = spec.weather or 0, + randomTurnNumber = spec.word or 0, + absent = {}, + data = { items = defs, constants = {}, moves = {} }, + trainer = { aiFlags = 1 }, + } + local input = { + spec.user or 1, + spec.target or 0, + b.double and 4 or 2, + b.weather, + flags, + spec.badge and 1 or 0, + b.randomTurnNumber, + spec.tie or 0, + } + for id = 0, 3 do + local speed, stage, ability = + at(spec.speeds, id, 100), at(spec.stages, id, 0), at(spec.abilities, id, 0) + local effect, parameter = at(spec.effects, id, 0), at(spec.parameters, id, 0) + local item = spec.enigma and 175 or effect ~= 0 and (180 + id) or 0 + raw[item] = { + name = "ORDER ITEM", + pocket = "ITEMS", + holdEffect = spec.enigma and 0 or effect, + holdEffectParam = spec.enigma and 0 or parameter, + } + defs["ITEM_" .. item] = { + index = item, + gen3HoldEffect = raw[item].holdEffect, + holdEffect = Core.effectNames[raw[item].holdEffect], + holdEffectParam = raw[item].holdEffectParam, + } + local status = at(spec.statuses, id, 0) + local statusWord = type(status) == "number" and status + or (status == "PAR" or status == "PARALYSIS") and 64 + or 0 + local hp, priority = at(spec.hps, id, 100), at(spec.priorities, id, 0) + local berry = { holdEffect = effect, holdEffectParam = parameter } + local actor = { + id = id, + position = id, + isPlayer = id % 2 == 0, + side = id % 2 == 0 and "player" or "enemy", + ability = ability, + abilityOverride = ability, + status = status, + item = item, + items = defs, + stages = { speed = stage }, + curStats = { speed = speed }, + enigmaBerry = spec.enigma and berry or nil, + mon = { + speed = speed, + stats = { speed = speed }, + hp = hp, + moves = { 33 + id }, + pp = { 10 }, + status = status, + item = "ITEM_" .. item, + }, + } + b.battlers[id] = actor + b.data.moves["TEST_" .. id] = { index = 33 + id, power = 35, priority = priority } + for _, value in ipairs({ + speed, + stage + 6, + ability, + statusWord, + item, + raw[item].holdEffect, + raw[item].holdEffectParam, + spec.enigma and effect or 0, + spec.enigma and parameter or 0, + hp, + priority, + }) do + input[#input + 1] = value + end + end + b.player, b.enemy = b.battlers[0], b.battlers[1] + local user, target = b.battlers[spec.user or 1], b.battlers[spec.target or 0] + local observations = {} + for _, wanted in ipairs({ 0, 1, 2, 3, 255 }) do + for _, inverse in ipairs({ false, true }) do + local draws = {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return spec.tie or 0 + end + local before = copy(b.battlers) + local score + local p = { + entries = { [0] = 1 }, + code = { + { name = inverse and "if_user_doesnt_go" or "if_user_goes", args = { wanted, 3 }, next = 2 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 4 }, + { name = "end", args = {} }, + }, + } + b.data.constants.gen3BattleAI = p + T.eq(AI.runs(p, 1), true, "native order predicate is reachable") + local scores = AI.scoreMoves(b, user, target, { { id = "TEST_" .. user.id, pp = 10 } }) + score = scores and scores[1] or false + if spec.expected ~= nil then + T.eq( + score, + ((spec.expected == wanted) ~= inverse) and 105 or 100, + spec.name .. " literal native result" + ) + end + T.same(b.battlers, before, spec.name .. " preserves native actor state") + T.check(#draws <= 1, "query never draws a new Quick Claw word") + for _, draw in ipairs(draws) do + T.same(draw, { 0, 65535 }, "native tie RNG ABI") + end + observations[#observations + 1] = { wanted = wanted, inverse = inverse, score = score, draws = draws } + end + end + local a, z = O.speed(user, b), O.speed(target, b) + local TurnOrder = require("src.battle.TurnOrder") + local actualDraws = {} + local function actualRng(lo, hi) + actualDraws[#actualDraws + 1] = { lo, hi } + return (spec.tie or 0) % 2 + end + local first = TurnOrder.firstMover( + user, + b.data.moves["TEST_" .. user.id], + target, + b.data.moves["TEST_" .. target.id], + actualRng, + false, + b + ) + local entries = {} + for id = 0, b.double and 3 or 1 do + entries[#entries + 1] = { battler = b.battlers[id], move = b.data.moves["TEST_" .. id] } + end + local sortDraws = {} + TurnOrder.order(entries, function(lo, hi) + sortDraws[#sortDraws + 1] = { lo, hi } + return (spec.tie or 0) % 2 + end, b) + local order = {} + for _, entry in ipairs(entries) do + order[#order + 1] = entry.battler.id + end + rows[#rows + 1] = { + name = spec.name, + native = input, + speeds = { a, z }, + observations = observations, + actual = { first = first, draws = actualDraws, order = order, sortDraws = sortDraws }, + } + end + return rows +end +return C diff --git a/tests/gen3_ai_order/planning.lua b/tests/gen3_ai_order/planning.lua new file mode 100644 index 000000000..dcfea73c4 --- /dev/null +++ b/tests/gen3_ai_order/planning.lua @@ -0,0 +1,196 @@ +-- Real BattleState constructors, selection, planning, action queue and EOT. +-- A registered enemy-action consumer supplies legal fixture choices; no owned +-- engine functions are replaced. Metadata cases inspect the planner's actual +-- queued entries (not full trainer item/switch controller behavior). +local M = {} +function M.run(version) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + local Battle = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + local Save = require("src.core.SaveData") + local Runtime = require("src.mods.Runtime") + local Order = require("src.battle.Gen3AIOrder") + local Events, Hooks = require("src.mods.Events"), require("src.mods.Hooks") + local events, hooks = Events.new(), Hooks.new() + local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks, Runtime.errors + Runtime.install(events, hooks) + Version.set(version) + local function setup(double, words, tie) + local data = T.fixtures.fresh() + data.constants.gen = 3 + data.constants.gen3BadgeFlags = { 0x820, 0x821, 0x822 } + data.moves.ORDER_HIGH = require("src.mods.Merge").deepCopy(data.moves.FIX_TACKLE) + data.moves.ORDER_HIGH.id, data.moves.ORDER_HIGH.priority = "ORDER_HIGH", 1 + data.items.ORDER_CLAW = + { index = 217, holdEffect = "QUICK_CLAW", gen3HoldEffect = 26, holdEffectParam = 20 } + local save = Save.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { data = data, save = save, stack = { top = function() end, push = function() end } } + local b = Battle.newWild(game, "FIXMON_C", 30) + b.double = double + if double then + b:placeBattler(2, Battle.makeBattler(data, Pokemon.new(data, "FIXMON_A", 30), true, save)) + b:placeBattler(3, Battle.makeBattler(data, Pokemon.new(data, "FIXMON_C", 30), false)) + end + b:syncSides() + b.queue, b.testChoices, b.testDraws, b.testWords, b.testComparisons, b.testMoves = {}, {}, {}, {}, {}, {} + for _, actor in b:activeBattlers() do + actor.mon.hp, actor.mon.stats.hp, actor.curStats.hp = 1000, 1000, 1000 + actor.mon.status, actor.mon.item, actor.abilityOverride = nil, nil, false + actor.curStats.speed, actor.mon.stats.speed = 100, 100 + actor.curMoves = { { id = "FIX_TACKLE", pp = 100 } } + actor.mon.moves = actor.curMoves + end + local wordIndex = 0 + b.rng = function(lo, hi) + b.testDraws[#b.testDraws + 1] = { lo, hi } + if lo == 0 and hi == 65535 then + wordIndex = wordIndex + 1 + return words[wordIndex] or words[#words] + end + if lo == 0 and hi == 1 then + return tie or 0 + end + return lo + end + return b + end + hooks:wrap("battle.enemy_action", function(_, b, actor) + T.eq(b._nativeOrderReady, true, "turn word prepared before public enemy-action consumer") + b.testWords[#b.testWords + 1] = { actor.position, b.randomTurnNumber, b.turnCount or 0 } + return b.testChoices[actor.position] or actor.curMoves[1] + end, 0, "order-fixture") + hooks:wrap("battle.turn_order", function(next, a, am, b, bm, ctx) + local battle = ctx.battle + T.eq(a, battle:battlerAt(a.position), "turn-order hook keeps native first actor") + T.eq(b, battle:battlerAt(b.position), "turn-order hook keeps native second actor") + battle.testComparisons[#battle.testComparisons + 1] = { a.position, b.position } + return next(a, am, b, bm, ctx) + end, 0, "order-fixture") + events:on("battle.move_used", function(e) + e.battle.testMoves[#e.battle.testMoves + 1] = e.user.position + end, 0, "order-fixture") + local function pump(b) + local guard = 0 + while b.queue[1] and guard < 1000 do + guard = guard + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(guard < 1000, "real action queue drains") + end + local function scheduled(b) + local ids = {} + for _, row in ipairs(b.queue) do + if row.fn then + local n = 1 + while true do + local name, entry = debug.getupvalue(row.fn, n) + if not name then + break + end + if name == "entry" then + ids[#ids + 1] = entry[1].position + end + n = n + 1 + end + end + end + return ids + end + local b = setup(false, { 0, 65535 }) + b.player.curStats.speed, b.enemy.curStats.speed = 10, 200 + b.player.mon.item = "ORDER_CLAW" + T.eq(b:enemyAction(b.enemy), b.enemy.curMoves[1], "choice keeps native move handle") + b:enemyAction(b.enemy) + T.same(b.testDraws, { { 0, 65535 } }, "repeated selection shares one turn word") + b:resolveTurn(b.player.curMoves[1]) + T.same(b.testDraws, { { 0, 65535 } }, "planner reuses selection word without separate claw roll") + T.same(scheduled(b), { 0, 1 }, "successful claw is used by actual planner") + pump(b) + T.same(b.testMoves, { 0, 1 }, "successful claw reaches actual move execution") + T.eq(b._nativeOrderReady, false, "EOT releases selection word") + b.testMoves = {} + b:resolveTurn(b.player.curMoves[1]) + T.eq(b.randomTurnNumber, 65535, "next actual turn samples fresh word") + T.same(scheduled(b), { 1, 0 }, "failed claw follows physical speed") + pump(b) + T.same(b.testMoves, { 1, 0 }, "failed claw reaches actual move execution") + + local priority = setup(false, { 0 }) + priority.player.mon.item = "ORDER_CLAW" + priority.testChoices[1] = { id = "ORDER_HIGH", pp = 10 } + priority:resolveTurn(priority.player.curMoves[1]) + T.same(scheduled(priority), { 1, 0 }, "Quick Claw cannot override greater move priority") + T.same(priority.testDraws, { { 0, 65535 } }, "unequal priorities do not draw a tie") + + local both = setup(false, { 0 }, 1) + both.player.mon.item, both.enemy.mon.item = "ORDER_CLAW", "ORDER_CLAW" + both:resolveTurn(both.player.curMoves[1]) + T.same(scheduled(both), { 1, 0 }, "two successful claws use native tie result") + T.same(both.testDraws, { { 0, 65535 }, { 0, 1 } }, "shared claw word followed by one actual tie draw") + + local doubles = setup(true, { 0 }, 1) + doubles.pendingActions = { [2] = doubles:battlerAt(2).curMoves[1] } + doubles:resolveTurn(doubles.player.curMoves[1]) + T.same(scheduled(doubles), { 3, 2, 1, 0 }, "real double planner follows nested native comparisons") + T.eq(#doubles.testComparisons, 6, "double planner invokes six actual comparison hooks") + T.eq(#doubles.testDraws, 7, "double planner uses one word plus six tie draws") + T.same(doubles.testWords, { { 1, 0, 0 }, { 3, 0, 0 } }, "both enemy choices precede new-turn flag reset") + pump(doubles) + T.same(doubles.testMoves, { 3, 2, 1, 0 }, "double order reaches actual action execution") + + local forwarded = setup(true, { 0 }, 1) + forwarded.pendingActions = { [2] = forwarded:battlerAt(2).curMoves[1] } + local undo = hooks:wrap("battle.turn_order", function(next, a, am, b, bm, ctx) + if a == forwarded.player then + local changed = require("src.mods.Merge").deepCopy(am) + changed.priority = 7 + return next(a, changed, b, bm, ctx) + end + return next(a, am, b, bm, ctx) + end, 10, "forwarding-fixture") + forwarded:resolveTurn(forwarded.player.curMoves[1]) + T.same(scheduled(forwarded), { 0, 3, 2, 1 }, "double comparator honors forwarded hook arguments") + T.eq(#forwarded.testDraws, 4, "forwarded priority suppresses only its own tie draws") + undo() + + local meta = setup(true, { 0 }, 1) + meta.testChoices[1] = { special = "aiItem", item = "POTION" } + meta.pendingActions = { [2] = { special = "playerSwitch", mon = meta.player.mon } } + meta:resolveTurn(meta.player.curMoves[1]) + T.same(scheduled(meta), { 1, 2, 3, 0 }, "item/switch stable prefix is preserved before move sorting") + T.eq(#meta.testComparisons, 1, "metadata actions are never speed-compared") + T.eq(#meta.testDraws, 2, "metadata actions consume no extra tie draws") + + local weather = setup(true, { 0 }) + weather.weather = "RAIN" + weather.enemy.abilityOverride, weather.enemy.curStats.speed = "SWIFT_SWIM", 75 + weather:battlerAt(2).abilityOverride = "CLOUD_NINE" + T.eq(Order.speed(weather.enemy, weather), 75, "real right-flank living ability suppresses weather") + weather:battlerAt(2).mon.hp = 0 + T.eq(Order.speed(weather.enemy, weather), 150, "fainted right-flank ability does not suppress weather") + weather.double = false + weather:battlerAt(2).mon.hp = 1000 + T.eq(Order.speed(weather.enemy, weather), 150, "unused right-flank storage is ignored in singles") + weather.game.save.flags = { FLAG_G3_0822 = true } + T.eq(Order.speed(weather.player, weather), 110, "real save flag grants native third-badge boost") + weather.gen3Frontier = true + T.eq( + Order.speed(weather.player, weather), + version == "emerald" and 100 or 110, + "actual frontier flag has profile-specific badge policy" + ) + + local planless = setup(false, { 10, 20 }) + planless:enemyAction(planless.enemy) + planless:endOfTurn() + planless:enemyAction(planless.enemy) + T.eq(planless.randomTurnNumber, 20, "planless EOT releases word without relying on turn counters") + Runtime.install(oldEvents, oldHooks, oldErrors) +end +return M diff --git a/tests/gen3_ai_order/scenarios.lua b/tests/gen3_ai_order/scenarios.lua new file mode 100644 index 000000000..c3d8dfa6a --- /dev/null +++ b/tests/gen3_ai_order/scenarios.lua @@ -0,0 +1,107 @@ +-- Declared native inputs, independent of either engine's order calculation. +local cases = {} +local function add(name, values) + values = values or {} + values.name = name + cases[#cases + 1] = values +end +add("user-faster", { speeds = { 100, 101 }, expected = 0 }) +add("target-faster", { speeds = { 101, 100 }, expected = 1 }) +add("tie-even", { expected = 0, tie = 0 }) +add("tie-odd", { expected = 2, tie = 1 }) +add("tie-high-even", { expected = 0, tie = 65534 }) +add("tie-high-odd", { expected = 2, tie = 65535 }) +for stage = -6, 6 do + for _, speed in ipairs({ 0, 1, 3, 7, 51, 101, 255, 65535 }) do + add( + "stage-" .. stage .. "-speed-" .. speed, + { speeds = { speed, 101 }, stages = { stage, -stage }, tie = 1 } + ) + end +end +for _, weather in ipairs({ 0, 1, 2, 4, 7, 32, 64, 96, 97 }) do + for _, ability in ipairs({ 0, 33, 34 }) do + add( + "weather-" .. weather .. "-ability-" .. ability, + { speeds = { 7, 5 }, weather = weather, abilities = { ability, ability }, stages = { 1, -1 } } + ) + end +end +for _, ability in ipairs({ 13, 76 }) do + for _, hp in ipairs({ 0, 100 }) do + add("right-weather-suppressor-" .. ability .. "-hp-" .. hp, { + double = true, + weather = 7, + speeds = { 101, 100 }, + abilities = { 0, 33, ability, 0 }, + hps = { 100, 100, hp, 100 }, + }) + add("unused-slot-not-suppressor-" .. ability .. "-hp-" .. hp, { + double = false, + weather = 7, + speeds = { 101, 100 }, + abilities = { 0, 33, ability, 0 }, + hps = { 100, 100, hp, 100 }, + }) + end +end +for _, mode in ipairs({ "ordinary", "link", "frontier", "recorded" }) do + for _, player in ipairs({ 0, 2 }) do + add( + "badge-" .. mode .. "-player-" .. player, + { double = true, user = 1, target = player, speeds = { 100, 109, 100, 109 }, badge = true, mode = mode } + ) + end +end +for _, status in ipairs({ 0, 64, 72, 0x80000040, "PAR", "PARALYSIS", "PSN", "SLP" }) do + add("paralysis-" .. tostring(status), { speeds = { 10, 39 }, statuses = { 0, status } }) +end +for _, word in ipairs({ 0, 130, 131, 13106, 13107, 13108, 65534, 65535 }) do + for _, chance in ipairs({ 0, 1, 20, 100, 255 }) do + add("claw-word-" .. word .. "-chance-" .. chance, { + speeds = { 100, 10 }, + effects = { 0, 26 }, + parameters = { 0, chance }, + word = word, + }) + add("double-claw-word-" .. word .. "-chance-" .. chance, { + speeds = { 100, 10 }, + effects = { 26, 26 }, + parameters = { chance, chance }, + word = word, + tie = 1, + }) + end +end +add("brace-weather-paralysis-flooring", { + speeds = { 3, 7 }, + effects = { 24, 24 }, + abilities = { 0, 33 }, + weather = 7, + statuses = { 0, "PAR" }, + stages = { 1, -1 }, +}) +add( + "enigma-per-battler", + { speeds = { 100, 10 }, enigma = true, effects = { 24, 26 }, parameters = { 0, 20 }, word = 0 } +) +add( + "enigma-threshold", + { speeds = { 100, 10 }, enigma = true, effects = { 24, 26 }, parameters = { 0, 20 }, word = 13107 } +) +add("priority-is-ignored", { speeds = { 100, 10 }, priorities = { -7, 7 }, expected = 1 }) +add("priority-does-not-break-speed-tie", { priorities = { -7, 7 }, expected = 2, tie = 1 }) +for user = 0, 3 do + for target = 0, 3 do + add("roles-" .. user .. "-" .. target, { + double = true, + user = user, + target = target, + speeds = { 97, 99, 101, 103 }, + effects = { 24, 0, 26, 0 }, + parameters = { 0, 0, 20, 0 }, + word = 13107, + }) + end +end +return cases diff --git a/tests/gen3_ai_order/worker.lua b/tests/gen3_ai_order/worker.lua new file mode 100644 index 000000000..d103ecc6f --- /dev/null +++ b/tests/gen3_ai_order/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, version = unpack(arg) +local rows = dofile(root .. "/tests/gen3_ai_order/cases.lua").run(root, version) +print("GEN3_AI_ORDER " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA AI order") diff --git a/tools/gen3-ai-order-check/check.py b/tools/gen3-ai-order-check/check.py new file mode 100644 index 000000000..9888f9e8d --- /dev/null +++ b/tools/gen3-ai-order-check/check.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Compare real Gen2 VMs/comparators with pinned native C, without ROM assets. + +Read-only source inputs are pret/pokefirered@ce6406d8 and +pret/pokeemerald@731ad5bf src/battle_main.c. No Gen1 patches are required. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +PINS = { + "firered": "af41c49b333346e1f06f0f028a425aaf84d2920c499a75d4e366ebef8991155f", + "emerald": "38813182841cab356c5d6400e160333996195c362960237ae3b4573980642a47", +} + + +def native_function(path, profile): + text = Path(path).read_text() + start = text.index("u8 GetWhoStrikesFirst(u8 battler1, u8 battler2, bool8 ignoreChosenMoves)\n{") + end = text.index("\n}", start) + 2 + function = text[start:end] + digest = hashlib.sha256(function.encode()).hexdigest() + if digest != PINS[profile]: + raise SystemExit(f"Unverified {profile} function: {digest}") + assert function.count("return strikesFirst;") == 1 + # Observe local arithmetic immediately before the original return. + return function.replace("return strikesFirst;", "oracleSpeed1 = speedBattler1; oracleSpeed2 = speedBattler2; return strikesFirst;") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--firered-main", required=True) + parser.add_argument("--emerald-main", required=True) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + cc = shutil.which(os.environ.get("CC", "cc")) + if not cc: + raise SystemExit("A C99 compiler is required") + report = {"profiles": {}, "errors": []} + for profile in PINS: + result = subprocess.run([args.lua, "tests/gen3_ai_order/worker.lua", ".", profile], + cwd=root, text=True, capture_output=True) + if result.returncode: + raise SystemExit(result.stdout + result.stderr) + match = re.search(r"^GEN3_AI_ORDER (.+)$", result.stdout, re.M) + if not match: + raise SystemExit("Missing worker records") + rows = json.loads(match[1]) + if len(rows) < 250: + raise SystemExit("Incomplete scenario matrix") + with tempfile.TemporaryDirectory(prefix="native-gba-order-") as directory: + tmp = Path(directory) + (tmp / "native-function.c").write_text(native_function(getattr(args, profile + "_main"), profile)) + executable = tmp / "oracle" + subprocess.run([cc, "-std=c99", "-Wall", "-Wextra", "-Werror", "-I", str(tmp), + str(root / "tools/gen3-ai-order-check/oracle.c"), "-o", str(executable)], check=True) + inputs = "".join(" ".join(map(str, row["native"])) + "\n" for row in rows) + output = subprocess.run([str(executable)], input=inputs, text=True, capture_output=True, check=True) + native_rows = output.stdout.splitlines() + if len(native_rows) != len(rows): + raise SystemExit("Incomplete native records") + for row, line in zip(rows, native_rows): + native = list(map(int, line.split())) + result, sa, sb, query_draws, actual_result, actual_draws, sort_draws = native[:7] + errors = [] + if row["speeds"] != [sa, sb]: + errors.append(f"speeds {row['speeds']} != {[sa, sb]}") + for observation in row["observations"]: + expected = 105 if ((result == observation["wanted"]) != observation["inverse"]) else 100 + if observation["score"] != expected or observation["draws"] != [[0, 65535]] * query_draws: + errors.append(f"native VM result {result}: {observation}") + actual = row["actual"] + expected_actual = {"first": actual_result == 0, "draws": [[0, 1]] * actual_draws, + "order": native[7:], "sortDraws": [[0, 1]] * sort_draws} + if actual != expected_actual: + errors.append(f"actual comparator/sort {actual} != {expected_actual}") + if errors: + report["errors"].append({"profile": profile, "name": row["name"], "errors": errors}) + report["profiles"][profile] = {"cases": len(rows), "vmQueries": len(rows) * 10, + "comparators": len(rows), "sorts": len(rows), "functionSha256": PINS[profile]} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + return int(bool(report["errors"])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gen3-ai-order-check/oracle.c b/tools/gen3-ai-order-check/oracle.c new file mode 100644 index 000000000..d6f3c2f14 --- /dev/null +++ b/tools/gen3-ai-order-check/oracle.c @@ -0,0 +1,87 @@ +/* Authored input shim. The checked, original GetWhoStrikesFirst is included + * from the pinned decompilation checkout by check.py, never copied here. */ +#include +#include +#include +#include +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef u8 bool8; +#define STAT_SPEED 0 +#define ABILITY_SWIFT_SWIM 33 +#define ABILITY_CHLOROPHYLL 34 +#define B_WEATHER_RAIN 7 +#define B_WEATHER_SUN 0x60 +#define ITEM_ENIGMA_BERRY 175 +#define HOLD_EFFECT_MACHO_BRACE 24 +#define HOLD_EFFECT_QUICK_CLAW 26 +#define STATUS1_PARALYSIS 64 +#define BATTLE_TYPE_LINK 1 +#define BATTLE_TYPE_RECORDED_LINK 2 +#define BATTLE_TYPE_FRONTIER 4 +#define B_SIDE_PLAYER 0 +#define FLAG_BADGE03_GET 1 +#define B_ACTION_USE_MOVE 0 +#define MOVE_NONE 0 +#define MOVE_STRUGGLE 5 +static struct { u16 speed, item, hp, moves[4]; u8 ability, statStages[8]; u32 status1; } gBattleMons[4]; +static struct { u8 holdEffect, holdEffectParam; } gEnigmaBerries[4], items[256]; +static struct { int8_t priority; } gBattleMoves[6]; +static struct { u8 noValidMoves; } gProtectStructs[4]; +static struct { u8 chosenMovePositions[4]; } battleStruct, *gBattleStruct = &battleStruct; +static u8 gChosenActionByBattler[4], gBattlersCount, badge; +static u16 gBattleWeather, gRandomTurnNumber, tie; +static u32 gBattleTypeFlags, oracleSpeed1, oracleSpeed2; +static unsigned draws; +static const u8 gStatStageRatios[13][2] = { + {10,40},{10,35},{10,30},{10,25},{10,20},{10,15},{10,10}, + {15,10},{20,10},{25,10},{30,10},{35,10},{40,10} +}; +static u8 weatherActive(void) { + for (u8 i=0; i Date: Sun, 25 Oct 2026 07:26:20 +0100 Subject: [PATCH 61/90] Test native Rage planning, public commands and linked turns --- .github/workflows/enemy-action-contract.yml | 1 + src/battle/EffectRegistry.lua | 2 +- src/battle/Gen3Rage.lua | 2 +- src/link/Fingerprint.lua | 2 +- tests/enemy_actions/rage.lua | 43 +++-- tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_rage_continuation.lua | 190 ++++++++++++++++++++ tests/engine/gen3_rage_link.lua | 104 +++++++++++ tools/enemy-action-contract-check/check.py | 2 +- 9 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/engine/gen3_rage_continuation.lua create mode 100644 tests/engine/gen3_rage_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index ede247bd8..b3e9661bd 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -28,6 +28,7 @@ on: - 'tests/engine/gen3_encore*.lua' - 'tests/engine/gen3_disable*.lua' - 'tests/engine/gen3_multi_hit*.lua' + - 'tests/engine/gen3_rage*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 10ad3bc7f..9292137a8 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -196,7 +196,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end - if record and record.afterAccuracy then record.afterAccuracy(ctx) end + if gba and record and record.gbaAfterAccuracy then record.gbaAfterAccuracy(ctx) end if gba then hits = hitCount(ctx, record) end -- damage per hit diff --git a/src/battle/Gen3Rage.lua b/src/battle/Gen3Rage.lua index 780bcd01a..94b84f2a5 100644 --- a/src/battle/Gen3Rage.lua +++ b/src/battle/Gen3Rage.lua @@ -19,7 +19,7 @@ function R.builtin(id, base) for key, value in pairs(base) do out[key] = value end - out.afterAccuracy = R.activate + out.gbaAfterAccuracy = R.activate out.afterDamage = function(ctx, ...) if V.isGen3() then return R.activate(ctx) diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index cdad3fc71..128c4ab3e 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 6 or nil + return require("src.core.GameVersion").isGen3() and 7 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/tests/enemy_actions/rage.lua b/tests/enemy_actions/rage.lua index 6a6655bf1..b7f692e90 100644 --- a/tests/enemy_actions/rage.lua +++ b/tests/enemy_actions/rage.lua @@ -75,7 +75,7 @@ function M.run(root, backend, version) local native = backend == "gen1" local f = M.fixture(root, backend, version) local T, Copy = f.T, require("src.mods.Merge").deepCopy - local rows, draws, damage, used = {}, {}, {}, {} + local rows, draws, damage, used, accuracy = {}, {}, {}, {}, {} local owner, phase, currentKind, incomingHits local off = f.e.api.events:on("battle.damage_dealt", function(e) damage[#damage + 1] = { @@ -102,6 +102,11 @@ function M.run(root, backend, version) local offMove = f.e.api.events:on("battle.move_used", function(e) used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } end) + local offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) + accuracy[#accuracy + 1] = + { rage = owner and not not owner.rage or false, attack = owner and owner.stages.attack or 0 } + return nextAccuracy(...) + end) local function at(b, id) return native and b.battlers[id] or b:battlerAt(id) end @@ -220,8 +225,14 @@ function M.run(root, backend, version) status = a.mon.status, } end - rows[#rows + 1] = - { label = label, field = field, draws = Copy(draws), damage = Copy(damage), used = Copy(used) } + rows[#rows + 1] = { + label = label, + field = field, + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + accuracy = Copy(accuracy), + } end for _, double in ipairs({ false, true }) do for uid = 0, double and 3 or 1 do @@ -245,7 +256,7 @@ function M.run(root, backend, version) }) do currentKind = kind phase = "producer" - draws, damage, used = {}, {}, {} + draws, damage, used, accuracy = {}, {}, {}, {} local b = fresh(double) owner = at(b, uid) local foe = at(b, tid) @@ -343,7 +354,7 @@ function M.run(root, backend, version) if double or kind ~= "ally" then currentKind = kind phase = "producer" - draws, damage, used = {}, {}, {} + draws, damage, used, accuracy = {}, {}, {}, {} local b = fresh(double) owner = at(b, uid) cast(b, uid, tid, 1) @@ -423,11 +434,11 @@ function M.run(root, backend, version) for _, double in ipairs({ false, true }) do for _, choice in ipairs({ "rage", "other", "custom", "encore_rage", "encore_other" }) do currentKind, phase = choice, "planning" - draws, damage, used = {}, {}, {} + draws, damage, used, accuracy = {}, {}, {}, {} local b = fresh(double) - -- Match the already prepared native turn input; this does not certify - -- the earlier global RNG/order orchestration (see the Encore contract). - b.randomTurnNumber, b._nativeOrderReady = 65535, true + -- Compare actual turn-word preparation with fixed choices. Earlier + -- selection/RNG orchestration remains a separate controller boundary. + b.randomTurnNumber, b._nativeOrderReady = nil, false local chosen = (choice == "rage" or choice == "encore_other") and 99 or choice == "custom" and 700 or 33 local slot = chosen == 99 and 1 or 4 local actions = {} @@ -447,8 +458,13 @@ function M.run(root, backend, version) a[native and "expEncoreSlot" or "encoreSlot"] = choice == "encore_rage" and 1 or 4 a[native and "expEncoreTurns" or "encoreTurns"] = 4 end - actions[id] = - { kind = "move", move = chosen, slot = slot, battler = id, target = id % 2 == 0 and id + 1 or id - 1 } + actions[id] = { + kind = "move", + move = chosen, + slot = slot, + battler = id, + target = id % 2 == 0 and id + 1 or id - 1, + } end if native then require("src.core.game3.battle.engine").planTurnActions( @@ -476,7 +492,7 @@ function M.run(root, backend, version) end for _, kind in ipairs({ "item", "switch", "run" }) do currentKind, phase = kind, "nonmove" - draws, damage, used = {}, {}, {} + draws, damage, used, accuracy = {}, {}, {}, {} local b = fresh(false) owner = at(b, 0) at(b, 0).rage, at(b, 1).rage = true, true @@ -485,7 +501,7 @@ function M.run(root, backend, version) require("src.core.game3.battle.engine").planTurnFromActions( b, require("src.core.game3.battle.adapter").new(b), - { kind = kind, user = "player" }, + { kind = kind == "item" and "bag" or kind, user = "player" }, { kind = "move", move = 33, slot = 4, user = "enemy" } ) else @@ -502,6 +518,7 @@ function M.run(root, backend, version) end off() offMove() + offAccuracy() f.close() return rows end diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 91f146140..506154f8f 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #220 with command revision 6. +-- Legacy hash audited against #214; native hash is #222 with command revision 7. local LEGACY = "e33d251961f2a41d" -local NATIVE = "a7db0874db78aec0" +local NATIVE = "dcc938191b0bb075" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 6 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 7 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_rage_continuation.lua b/tests/engine/gen3_rage_continuation.lua new file mode 100644 index 000000000..b0b5eec0f --- /dev/null +++ b/tests/engine/gen3_rage_continuation.lua @@ -0,0 +1,190 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local R, V = require("src.battle.Gen3Rage"), require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.save.options.ruleset = "gen3_emerald" + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "PLAYER" + mon.hp, mon.stats.hp = 400, 400 + mon.moves = { { id = f.ids.RAGE, pp = 10 }, { id = f.ids.TACKLE, pp = 10 } } + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.nickname, b.enemy.mon.hp, b.enemy.mon.stats.hp = "ENEMY", 400, 400 + b.enemy.curMoves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + b.phase, b.queue, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, 0, 1, 1 + game.stack.list[1] = b + b.rng = function(_, hi) + return hi + end + f.e.choose(function() + return { move = "TACKLE" } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local started, used, stages = {}, {}, {} + local off = f.e.api.events:on("battle.turn_started", function(e) + started[#started + 1] = { id = e.playerAction.id, rage = not not b.player.rage } + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, move = e.move.id } + end) + local nativeHeld = b.holdItemTrigger + b.holdItemTrigger = function(self, who, when) + if when == "moveEnd" and who == self.player then + stages[#stages + 1] = who.stages.attack or 0 + end + return nativeHeld(self, who, when) + end + local api, command = f.e.api.battle, 0 + local function submit(raw) + local snapshot = assert(api:snapshot()) + command = command + 1 + raw.id, raw.revision = command, snapshot.revision + local ok, why = api:submit(raw) + T.eq(ok, true, "public Rage command is accepted: " .. tostring(why)) + end + local function complete(turn) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "native Rage turn returns to the actual menu") + T.eq(b.turnCount, turn, "exactly one native turn completes") + end + submit({ kind = "move", slot = 1 }) + complete(1) + T.eq(b.player.rage, true, "real Rage producer activates its native flag") + T.eq(b.player.rageMove, nil, "real Rage does not install a legacy lock") + T.eq(mon.moves[1].pp, 9, "first selected Rage spends PP") + T.eq(b.player.stages.attack or 0, 0, "earlier attack before first Rage does not build rage") + T.eq(assert(api:snapshot()).prompt, "menu", "native Rage leaves public menu actionable") + submit({ kind = "menu", choice = "fight" }) + T.eq(b.phase, "moveSelect", "FIGHT opens the real move list after Rage") + submit({ kind = "move", slot = 1 }) + complete(2) + T.eq(mon.moves[1].pp, 8, "a separately selected repeat spends another PP") + T.eq(b.player.stages.attack, 1, "faster foe builds Rage once before the second attack") + T.eq(stages[3], 1, "Rage boost is present before the real move-end item read") + submit({ kind = "menu", choice = "fight" }) + submit({ kind = "move", slot = 2 }) + complete(3) + T.eq(mon.moves[1].pp, 8, "another selected move does not consume Rage PP") + T.eq(mon.moves[2].pp, 9, "the independently selected alternative actually executes") + T.eq(b.player.rage, nil, "alternative choice clears Rage before the faster opponent") + T.eq(b.player.stages.attack, 1, "no stale Rage boost occurs on the alternative turn") + T.same( + started, + { { id = "RAGE", rage = false }, { id = "RAGE", rage = true }, { id = "TACKLE", rage = false } }, + "turn-start observes Rage cleanup and original selected moves" + ) + T.eq(#used, 6, "both real actions execute on all three turns") + b.phase = "moveSelect" + local offered = assert(api:snapshot()) + b.player.rage = true + local stale = api:submit({ id = command + 1, revision = offered.revision, kind = "move", slot = 2 }) + T.eq(stale, nil, "changed Rage flag invalidates an old offered command") + T.eq( + require("src.battle.Gen3AI").OPS.if_status2({ user = b.player }, { 1, 0x800000, 71 }), + 71, + "native AI sees produced Rage status bit" + ) + off() + offMove() + f.close() + for _, mode in ipairs({ "override", "delegate" }) do + local entry = [[return function(mod) + local base=assert(mod.content.move_effects:get('RAGE_EFFECT'));local run=base.afterDamage + mod.exports.calls=0 + assert(mod.content.move_effects:override('RAGE_EFFECT',{kind='full',afterDamage=function(ctx) + mod.exports.calls=mod.exports.calls+1 + ]] .. (mode == "delegate" and "return run(ctx)" or "return") .. [[ + end}));mod.exports.api=mod + end]] + local g = require("tests.enemy_actions.rage").fixture(".", "gen2", version, entry) + local c = g.fresh("TACKLE", { enemy = true }) + c.battle:syncSides() + c.user.curMoves = { { id = g.ids.RAGE, pp = 10 } } + c.user.mon.moves = c.user.curMoves + c.battle.rng = function(_, hi) + return hi + end + c.battle:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(g.e.calls, 1, "authored Rage handler runs once") + T.eq( + not not c.user.rage, + mode == "delegate", + "complete override retains its own policy or native delegate" + ) + T.eq(c.user.rageMove, nil, "native delegate never installs the legacy lock") + T.eq(c.user.curMoves[1].pp, 9, "authored Rage retains native PP ownership") + g.close() + end + for _, mode in ipairs({ "item", "ball", "reply", "switch", "baton" }) do + local g = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + local c = g.fresh("TACKLE") + local battle, donor = c.battle, c.user + battle:syncSides() + donor.rage, battle.enemy.rage = true, true + local calls = 0 + g.e.choose(function() + calls = calls + 1 + T.eq(donor.rage, true, "enemy selection sees the original pre-cleanup Rage state") + return { move = "TACKLE" } + end) + battle.rng = function(_, hi) + return hi + end + if mode == "item" then + battle:itemUsed({}) + elseif mode == "ball" then + battle:throwBall("POKE_BALL") + elseif mode == "reply" then + local run = battle:nonMoveAction() + g.e.choose(function() + error("native reply selected twice") + end) + run() + T.eq(donor.stages.attack or 0, 0, "non-move reply cannot build stale Rage") + else + local bench = Copy(donor.mon) + bench.nickname = "BENCH" + battle.game.save.party[2] = bench + if mode == "switch" then + battle:resolveSwitch(bench) + else + require("src.battle.Gen3BatonPass").switch(battle, donor, 2, battle.game.save.party) + end + end + if mode ~= "baton" then + T.eq(donor.rage, nil, "committed non-move action clears native Rage") + T.eq(calls, 1, "native non-move action prepares exactly one enemy reply") + else + T.eq(battle.player.rage, nil, "Baton Pass never transfers Rage") + end + g.close() + end +end +T.neq(R.signature({ rage = true }), R.signature({}), "native active hash includes Rage") +T.eq(R.signature({ rage = false }), R.signature({}), "false and absent flags share the same semantic hash") +local raw = require("src.battle.MoveEffects").RECORDS.RAGE_EFFECT +for _, v in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(v) + T.eq(R.builtin("RAGE_EFFECT", raw), raw, "older generation retains original builtin identity") + local user = {} + local move = { id = "LEGACY" } + raw.afterDamage({ user = user, moveInst = move }) + T.eq(user.rageMove, move, "older generation keeps the existing move lock") + T.eq(user.rage, nil, "native marker does not leak to other generations") +end +T.finish("native Rage public commands, replies, composition and legacy behavior") diff --git a/tests/engine/gen3_rage_link.lua b/tests/engine/gen3_rage_link.lua new file mode 100644 index 000000000..d47a977a0 --- /dev/null +++ b/tests/engine/gen3_rage_link.lua @@ -0,0 +1,104 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 6 + T.eq(H.checkCompat(current, older), "subset", "pre-Rage rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.options.ruleset = "gen3_emerald" + game.save.party[1].moves = { + { id = f.ids.RAGE, pp = 10 }, + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn < 2 and 1 or turn == 2 and (i == 1 and 2 or 1) or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[3] and b.localHashes[3] and not seen[3] then + seen[3] = true + T.eq( + a.player.stages.attack, + 2, + "choosing an alternative clears the faster owner's Rage before the reply" + ) + T.eq(a.enemy.stages.attack, 2, "the whole incoming multi-hit builds Rage only once") + T.eq(b.enemy.stages.attack, 2, "remote owner keeps the same completed stage") + T.eq(b.player.stages.attack, 2, "guest observes one completed multi-hit boost") + end + if a.localHashes[4] and b.localHashes[4] then + break + end + end + T.check(seen[3], "the mixed Rage and multi-hit turn completes on both roles") + for turn = 1, 4 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local host = battle == a and battle.player or battle.enemy + local guest = battle == a and battle.enemy or battle.player + T.eq(host.mon.moves[1].pp, 8, "host's two separately selected Rage actions both spend PP") + T.eq(guest.mon.moves[1].pp, 7, "guest's three Rage actions all spend their learned PP") + T.eq(host.mon.moves[2].pp, 9, "host actually executes the independently selected multi-hit") + T.eq(guest.mon.moves[2].pp, 10, "guest never spends the unselected multi-hit slot") + for _, battler in ipairs({ host, guest }) do + T.eq(battler.mon.moves[3].pp, 9, "last independently selected Tackle spends its own PP") + T.eq(battler.rage, nil, "both native Rage flags clear before the last turn") + T.eq(battler.stages.attack, 2, "no stale Rage boost occurs on the last turn") + end + T.eq(battle.result, nil, "no hidden input or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Rage real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 8e57fea00..bb1e6dca3 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), From 03c4a841ee327d232ca504a1ae5df1c5159064b6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:30:27 +0100 Subject: [PATCH 62/90] Clear native Rage on completed successful escape --- src/battle/BattleState.lua | 1 + tests/engine/gen3_rage_continuation.lua | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 586e4e3f1..59f0610d3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9933,6 +9933,7 @@ function BattleState:tryRun() TurnOrder.effectiveSpeed(self.enemy)) end if escaped then + if GameVersion.isGen3() then self:nonMoveAction() end require("src.core.Sound").play(self.data, "Run") -- BLACK AND WHITE: "X fled using its Smoke Ball!" (bank 14 #1004) local smoke = self.bw and require("src.battle.Gen5HoldItems").smokeBallLine(self, self.player) diff --git a/tests/engine/gen3_rage_continuation.lua b/tests/engine/gen3_rage_continuation.lua index b0b5eec0f..653c8313e 100644 --- a/tests/engine/gen3_rage_continuation.lua +++ b/tests/engine/gen3_rage_continuation.lua @@ -130,7 +130,7 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(c.user.curMoves[1].pp, 9, "authored Rage retains native PP ownership") g.close() end - for _, mode in ipairs({ "item", "ball", "reply", "switch", "baton" }) do + for _, mode in ipairs({ "item", "ball", "reply", "switch", "baton", "run" }) do local g = require("tests.enemy_actions.rage").fixture(".", "gen2", version) local c = g.fresh("TACKLE") local battle, donor = c.battle, c.user @@ -147,6 +147,10 @@ for _, version in ipairs({ "firered", "emerald" }) do end if mode == "item" then battle:itemUsed({}) + elseif mode == "run" then + battle.player.curStats.speed, battle.enemy.curStats.speed = 200, 100 + battle:tryRun() + T.eq(battle.result, "run", "actual successful escape finishes with the cleaned native state") elseif mode == "ball" then battle:throwBall("POKE_BALL") elseif mode == "reply" then From 8e543618bc3a6ec9e0c6d8f1f31f9e12b5259f2e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:36:32 +0100 Subject: [PATCH 63/90] Document native Rage contracts, acceptance and remaining boundaries --- docs/native-gen3-rage.md | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/native-gen3-rage.md diff --git a/docs/native-gen3-rage.md b/docs/native-gen3-rage.md new file mode 100644 index 000000000..e0df6ebbf --- /dev/null +++ b/docs/native-gen3-rage.md @@ -0,0 +1,124 @@ +# Native GBA Rage state and move-end boosts + +The shared legacy Rage handler installs `rageMove`: it locks later choices and +can exempt a continued move from PP. GBA Rage instead owns a boolean `rage` +state. A separately selected use spends its learned PP, and the player can +choose another move, item, switch or escape. + +The native builtin adapter activates Rage after successful accuracy and before +damage/type handling. Accuracy misses, Protect and absorption clear it; type +immunity after accuracy retains the native flag. A cancelled same-Rage action +preserves the previous flag, and called moves retain their native PP bypass. +Known authored Rage effects do not require cartridge indices to activate. + +Rage raises the defender's Attack once at the completed incoming move boundary, +after public hit/secondary events and before move-end item reads. The native +guards require a living opposing defender, a landed positive-power attack and +Attack below +6. Substitute and zero HP loss under Endure still count. Ally, +self, missed/ineffective and residual damage do not build Rage. An ordinary +multi-hit attack builds it once, including after a Substitute breaks; a later +immunity prevents the boost. + +The actual planners clear Rage for a different effective choice before actions +and turn-start observers, including Encore-forced choices. Native double choices +are resolved once and reused. Spent non-move actions prepare one enemy reply +before Rage cleanup, including successful escape cleanup and capture replies. +Switches and Baton Pass do not transfer the flag. Older generations and explicit +legacy `rageMove` controllers retain their existing behavior. + +Native AI STATUS2 queries read bit `0x800000`. Changed Rage state invalidates an +old offered mod command. Host builtin adaptation occurs per dataset before mods +capture/delegate `afterDamage`; complete authored overrides keep their own policy. +The native accuracy-stage callback is private (`gbaAfterAccuracy`), not a new +portable public effect stage. + +## Dependencies and compatibility + +This feature stacks on #222 and includes the already published #188 native +AI/order prerequisite. The separate clean prerequisite checkout is +`fa5cb30ec4050980dc940e2181d6943a9de669b4`: #222 plus #188, preserving held-item +reads, original declared actions, Encore and cached native enemy selections. +The combined fork already contained #188; it is not applied twice there. + +GBA command revision is **7**, native party codec remains **1**. Active hashes +include Rage and treat false/absent as the same inactive state. Revision 6 is +refused before party exchange. The raw fixture digest changes from +`a7db0874db78aec0` to `dcc938191b0bb075`, independently calculated with the +unchanged #222 hasher and only the command token changed from 6 to 7. +Older-generation goldens were not reblessed. + +## Accepted validation + +Feature runtime: `03c4a841ee327d232ca504a1ae5df1c5159064b6`. +Combined runtime: `e1a9295e57c1545ec69deec2c4541a4da193b7b6`. +Gen1 remains clean, unchanged and read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. No Gen1 development or publication +is performed. + +- **796 focused checks** pass: 584 native command/planning/lifecycle, 122 host + public-command/composition/reply/legacy and 90 actual link/compatibility checks. + Native protocol and the reused native AI/order regression suite also pass. +- Both clean runtimes match Gen1 in **104 workers / 75,210 paired observations**. + The new Rage mode adds **357 complete rows per FireRed/Emerald profile**, + retaining every bounded RNG call/value, accuracy-hook state, move/damage event, + all battler HP/PP/status/substitute HP/Attack/last move and native/legacy flags. + Cases cover all four actor slots, activation/failure/cancellation/called/custom + moves, repeated PP, opponent/ally/self attacks, ordinary multi-hits, stage caps, + Substitute/Endure, later immunity, residuals, actual planners and Encore choices. +- Move planning compares actual turn-word preparation with fixed choices. The + non-move planning observations begin with the same explicitly prepared word; + they compare the actual committed reply/cleanup boundary. Original unmatched + planner reports remain retained. This does not certify earlier/global enemy + selection, capture/escape RNG orchestration or all controller timing. +- Full feature: **123/132 engine suites**, SDK **3/3**, B30 passed. The exact + Counter of all **25 inherited FAIL lines across nine suites** equals the clean + #222+#188 prerequisite (120/129). The full command retains its nonzero exit. +- Combined: **243/243 engine suites**, SDK **3/3**, B30, the same complete + comparison and all four imported smoke scenarios pass. Changes after the + runtime snapshots only record validation/publication. +- The unchanged prerequisite fails all **four** assertions of the same public + selector/delegated-native continuation: no native flag, a legacy lock, only one + PP spent across two uses, and a locked menu. Raw negative output is retained. +- Two real link roles complete four turns: Rage twice, one side choosing a + multi-hit while the other retains Rage, then both selecting Tackle. All paired + completed hashes/components, PP and Attack stages agree. The whole incoming + multi-hit increases Attack once; the cleared owner gets no stale boost. +- Four CPU-only scenarios using actual local FireRed/Emerald imports pass public + FIGHT/move submissions, repeated Rage PP, alternative Tackle and the actual + committed item-reply pipeline. Imported Rage is move 99 / effect 81; PP goes + 20 to 18. Accuracy/power/type/PP are retained with controlled valid hit rolls. + No ROM/cache/save data is distributed and no normal SAVE is issued. The item + test starts at the spent-item callback, not a full bag/healing playthrough. +- Scoped StyLua, actionlint and whitespace checks pass. + +Host content overrides/delegation are tested separately from native default +behavior in the shared checker. An additional synthetic Gen1 GBA content probe +could not retrieve `RAGE_EFFECT`; its raw failure is retained. This is not proof +of effect-catalog ID/delegation equivalence across engines and needs a separate +catalog/fixture audit. The public event views used by contract tests are detached; +controlled live fixture mutations do not assert event-view write-through. + +```sh +nice -n 19 "$LUA" tests/engine/gen3_rage.lua +nice -n 19 "$LUA" tests/engine/gen3_rage_continuation.lua +nice -n 19 "$LUA" tests/engine/gen3_rage_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/rage-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Use the workflow's maintained LuaJIT pin +`a4c064539ea931274d407e1249b4bb575424a0f4`. Private evidence is retained in +`tmp/rage-qa/`, including accepted full/comparison/import logs, exact baseline +FAIL-Counter evidence and negative/exploratory reports. Combined evidence is in +`tmp/rage-full-accepted.log`, `tmp/rage-contracts-accepted.json`, +`tmp/rage-imported-accepted.log` and `tmp/rage-accepted-evidence.json`. + +Broader canceller ordering, all full/custom/delayed scripts, Ruby/Sapphire +per-hit scheduling, complete content-catalog parity, all AI/item/capture/escape +controllers, whole-game RNG/VBlank, rendering and full playthroughs remain work. +Hosted CI is reported separately in the published PR. From 6ccc478115973e6c934753778acf6660486230f0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:55:00 +0100 Subject: [PATCH 64/90] Implement native GBA Triple Kick accuracy and power per hit --- .github/workflows/enemy-action-contract.yml | 1 + src/battle/Damage.lua | 2 +- src/battle/EffectRegistry.lua | 19 +- src/battle/Gen3Builtins.lua | 3 +- src/battle/Gen3MoveEffects.lua | 12 +- src/battle/Gen3TripleKick.lua | 23 + src/link/Fingerprint.lua | 2 +- tests/enemy_actions/triple_kick.lua | 430 ++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_triple_kick.lua | 5 + tests/engine/gen3_triple_kick_composition.lua | 79 ++++ tests/engine/gen3_triple_kick_link.lua | 96 ++++ tools/enemy-action-contract-check/check.py | 2 +- .../imported-triple-kick-smoke.lua | 111 +++++ tools/enemy-action-contract-check/worker.lua | 4 +- 15 files changed, 774 insertions(+), 21 deletions(-) create mode 100644 src/battle/Gen3TripleKick.lua create mode 100644 tests/enemy_actions/triple_kick.lua create mode 100644 tests/engine/gen3_triple_kick.lua create mode 100644 tests/engine/gen3_triple_kick_composition.lua create mode 100644 tests/engine/gen3_triple_kick_link.lua create mode 100644 tools/enemy-action-contract-check/imported-triple-kick-smoke.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index b3e9661bd..8c3cb8da2 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -29,6 +29,7 @@ on: - 'tests/engine/gen3_disable*.lua' - 'tests/engine/gen3_multi_hit*.lua' - 'tests/engine/gen3_rage*.lua' + - 'tests/engine/gen3_triple_kick*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index d52a19490..e0bebdc16 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -459,7 +459,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- FURY CUTTER and ROLLOUT doubling each turn they land -- hand the -- pipeline a multiplier and it arrives here. Floored at 1 so a scaled -- move never becomes a status move by arithmetic. - local power = move.power + local power = gbaBase and opts.power or move.power if opts.powerMultiplier and opts.powerMultiplier ~= 1 then power = math.max(1, math.floor(power * opts.powerMultiplier)) end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 9292137a8..ee0e6cde8 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -100,7 +100,8 @@ local function ordinaryDamage(battle, ctx, record) return battle:computeDamage(ctx.user, ctx.target, ctx.move, { rng = battle.rng, explode = (record and record.explode) or nil, powerMultiplier = record and record.powerMultiplier and record.powerMultiplier(ctx) or nil, - highCrit = record and record.highCrit or nil }) + highCrit = record and record.highCrit or nil, + power = record and record.gbaTripleKick and 10 * (ctx.gbaHit or 1) or nil }) end -- The damaging pipeline, extracted from the performMove monolith: every @@ -113,6 +114,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) local neverMiss = record and record.neverMiss local gba = require("src.core.GameVersion").isGen3() local ordinaryMulti = gba and require("src.battle.Gen3MultiHit").ordinary(move) + local tripleKick = gba and record and record.gbaTripleKick + local nativeMulti = ordinaryMulti or tripleKick if gba and type(target.substituteHP) == "number" and target.substituteHP <= 0 then target.substituteHP = nil end @@ -298,13 +301,21 @@ function EffectRegistry.runDamaging(battle, ctx, record) local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end - if ordinaryMulti and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" + if nativeMulti and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" and not require("src.battle.Gen3MultiHit").sleepTalk(battle)) then break end + -- Unlike ordinary multi-hit moves, each reached native kick checks + -- accuracy again before its critical/damage opcodes. A later miss ends + -- the script quietly; already landed kicks still reach move-end (Rage). + if tripleKick and h > 1 then + local hit = battle:accuracyRoll(move, user, target, accuracyRaw) + if not hit then break end + end + ctx.gbaHit = h if record and record.perHitDamage then local nextDamage, nextInfo = record.perHitDamage(ctx, h, dmg, info) if nextDamage == nil then break end dmg, info = nextDamage, nextInfo or info - elseif ordinaryMulti and h > 1 + elseif nativeMulti and h > 1 and not (record and record.chooseDamage) then dmg, info = ordinaryDamage(battle, ctx, record) if info.typeMult == 0 or info.missed then @@ -429,7 +440,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) brokeSub = true if not gba then break end end - if ordinaryMulti and endured then break end + if nativeMulti and endured then break end end hits = landed > 0 and landed or hits if hits > 1 and not info.gbaBeatUp then diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index b06957c99..55717685e 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -3,6 +3,7 @@ local M = {} function M.record(id, base) base = require("src.battle.Gen3Disable").builtin(id, base) base = require("src.battle.Gen3MultiHit").builtin(id, base) - return require("src.battle.Gen3Rage").builtin(id, base) + base = require("src.battle.Gen3Rage").builtin(id, base) + return require("src.battle.Gen3TripleKick").builtin(id, base) end return M diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index f00d0b46b..2e6c0103f 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -1140,15 +1140,9 @@ F.LOW_KICK_EFFECT = { end, } --- TRIPLE KICK hits three times and its power escalates 10, 20, 30. --- --- This pipeline rolls the damage ONCE and applies it per hit, so a per-hit --- ramp is not something it can express. Three hits at twice the base power --- is the same TOTAL -- 3 x 2 x 10 is 60, and 10 + 20 + 30 is 60 -- so the --- damage a Hitmontop does over the move is right even though it is spread --- evenly rather than escalating. Said plainly here rather than left to be --- discovered: what is not modelled is the ramp, and the cartridge's rule --- that the move stops early if a kick misses. +-- Legacy fallback: three equal-power hits with one accuracy check. +-- Native GBA loads adapt this record per dataset in Gen3TripleKick before +-- mods capture it: its script checks every kick and assigns 10/20/30 power. F.TRIPLE_KICK_EFFECT = { hitCount = function() return 3 end, powerMultiplier = function() return 2 end, diff --git a/src/battle/Gen3TripleKick.lua b/src/battle/Gen3TripleKick.lua new file mode 100644 index 000000000..3c952791e --- /dev/null +++ b/src/battle/Gen3TripleKick.lua @@ -0,0 +1,23 @@ +-- Native GBA Triple Kick owns a distinct per-hit accuracy/power script. +local M = {} +local V = require("src.core.GameVersion") +local cache = setmetatable({}, { __mode = "k" }) +function M.builtin(id, base) + if not V.isGen3() or not base or id ~= "TRIPLE_KICK_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.gbaTripleKick = true + -- The native opcode assigns 10/20/30, regardless of the extracted power. + -- The earlier-generation approximation remains in the shared static record. + out.powerMultiplier = nil + cache[base] = out + return out +end +return M diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 128c4ab3e..7cff40321 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 7 or nil + return require("src.core.GameVersion").isGen3() and 8 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/tests/enemy_actions/triple_kick.lua b/tests/enemy_actions/triple_kick.lua new file mode 100644 index 000000000..ffa97f484 --- /dev/null +++ b/tests/enemy_actions/triple_kick.lua @@ -0,0 +1,430 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 10, + type = 0, + accuracy = 90, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 10, + type = "NORMAL", + accuracy = 90, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used = {}, {}, {}, {} + local currentKind, liveUser, liveTarget, missAt + local accuracy, calculations = {}, {} + local offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + accuracy[#accuracy + 1] = { + accuracy = ctx.accuracy, + hp = ctx.target.mon.hp, + rage = liveTarget and not not liveTarget.rage or false, + } + return nextAccuracy(ctx) + end) + -- Public events are detached views. Mutate the owned fixture's real battlers + -- to test live continuation; this does not assert event-view write-through. + local offCalculation = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ctx) + calculations[#calculations + 1] = { power = ctx.opts.power, basePower = ctx.move.power } + if currentKind == "power_hook" and #calculations == 2 then + ctx.opts.power = 5 + end + return nextDamage(ctx) + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 and currentKind == "sleep_after_hit" then + liveUser.mon.status = "SLP" + end + if #damage == 1 and (currentKind == "type_after_hit" or currentKind == "rage_type_after_hit") then + if native then + liveTarget.type1, liveTarget.type2 = 7, 7 + else + liveTarget.curTypes = { "GHOST" } + end + end + if #damage == 1 and currentKind == "protect_after_hit" then + liveTarget[native and "expProtected" or "protecting"] = true + end + if #damage == 1 and currentKind == "hidden_after_hit" then + if native then + liveTarget.semiInvulnerable = "ON_AIR" + else + liveTarget.invulnerable, liveTarget.hiddenAs = true, "air" + end + end + if #damage == 1 and currentKind == "user_ko" then + liveUser.mon.hp = 0 + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 167, 33, 33, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TRIPLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + local crit, variance, checks = 0, 0, 0 + b.rng = function(lo, hi) + local value = hi + if lo == 1 and hi == 100 then + checks = checks + 1 + value = checks == missAt and 100 or 1 + end + if lo == 0 and hi == 15 then + crit = crit + 1 + value = currentKind == "crit" and crit % 2 == 1 and 0 or hi + end + if lo == 85 and hi == 100 then + variance = variance + 1 + value = variance % 2 == 1 and lo or hi + end + if lo == 0 and hi == 99 and currentKind == "focus_band" then + value = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function view(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + sub = tonumber(a.substituteHP) or 0, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + attack = a.stages.attack or 0, + rage = not not a.rage, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + local slot = 1 + for first = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "crit", + "focus_crit", + "scope_crit", + "focus_scope_crit", + "max_crit", + "sub_survive", + "sub_break", + "endure", + "focus_band", + "target_ko", + "protected", + "hidden", + "immune", + "changed_power", + "rage", + "rage_type_after_hit", + "power_hook", + "sleep_after_hit", + "protect_after_hit", + "hidden_after_hit", + "type_after_hit", + "user_ko", + "called", + }) do + currentKind, missAt = kind, first + draws, damage, used, accuracy, calculations = {}, {}, {}, {}, {} + local b = fresh(double) + local user, target = at(b, uid), at(b, tid) + liveUser, liveTarget = user, target + if kind == "focus_crit" or kind == "focus_scope_crit" or kind == "max_crit" then + user.focusEnergy = true + end + if kind == "scope_crit" or kind == "focus_scope_crit" then + if native then + user.item, user.mon.item = 198, 198 + else + user.mon.item = "SCOPE_LENS" + end + end + if kind == "max_crit" then + user.species = 113 + if native then + user.item, user.mon.item = 222, 222 + else + user.mon.item = "LUCKY_PUNCH" + end + end + if kind == "sub_survive" then + target.substituteHP = 400 + end + if kind == "sub_break" then + target.substituteHP = 1 + end + if kind == "target_ko" then + target.mon.hp = 1 + end + if kind == "endure" then + target.mon.hp = 1 + target[native and "expEnduring" or "enduring"] = true + end + if kind == "focus_band" then + target.mon.hp = 1 + if native then + target.item, target.mon.item = 196, 196 + else + target.mon.item = "FOCUS_BAND" + end + end + if kind == "protected" then + target[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + local number = 167 + local def = native and f.data.gen3Moves._rom[number] or f.data.moves[f.ids.TRIPLE_KICK] + local old = def.power + if kind == "changed_power" then + def.power = 35 + end + if kind == "rage" or kind == "rage_type_after_hit" then + target.rage = true + end + if native then + local nativeSlot = slot + if kind == "called" then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = kind == "called" } + ) + elseif kind == "called" then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + def.power = old + local label = (double and "double" or "single") .. "/" .. uid .. "/" .. first .. "/" .. kind + rows[#rows + 1] = { + label = label, + field = view(b), + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + accuracy = Copy(accuracy), + calculations = Copy(calculations), + } + if kind == "ordinary" or kind == "changed_power" or kind == "crit" then + T.eq( + #damage, + first == 0 and 3 or first - 1, + "Triple Kick stops at the first missed per-hit accuracy check" + ) + T.eq(#accuracy, first == 0 and 3 or first, "only reached kicks dispatch the public accuracy hook") + if first == 0 and kind ~= "crit" then + T.check( + damage[1].damage < damage[2].damage and damage[2].damage < damage[3].damage, + "each reached kick uses increasing native power" + ) + end + end + if + first ~= 1 + and ( + kind == "sleep_after_hit" + or kind == "type_after_hit" + or kind == "rage_type_after_hit" + or kind == "protect_after_hit" + or kind == "hidden_after_hit" + or kind == "user_ko" + or kind == "endure" + or kind == "target_ko" + ) + then + T.eq(#damage, 1, "native continuation stops at its actual live condition") + end + if first ~= 1 and kind == "sleep_after_hit" then + T.eq(user.mon.status, "SLP", "callback changes the owned live user") + end + if first ~= 1 and kind == "user_ko" then + T.eq(user.mon.hp, 0, "callback faints the owned live user") + end + if first ~= 1 and (kind == "type_after_hit" or kind == "rage_type_after_hit") then + T.eq( + native and target.type1 or target.curTypes[1], + native and 7 or "GHOST", + "callback changes the owned live target" + ) + end + if kind == "rage_type_after_hit" then + T.eq( + target.stages.attack or 0, + first == 2 and 1 or 0, + "only a reached immunity check suppresses the completed Rage boost" + ) + end + if kind == "rage" then + T.eq( + target.stages.attack or 0, + first == 1 and 0 or 1, + "landed kicks build Rage once even when a later kick misses" + ) + end + if kind == "endure" or kind == "focus_band" then + T.eq(target.mon.hp, 1, "native survival is retained") + end + if first == 0 and kind == "sub_break" then + T.check(target.mon.hp < 400, "later kicks continue after Substitute breaks") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + kind == "called" and 10 or 9, + "a multi-hit move spends PP once" + ) + end + end + end + end + off() + offMove() + offAccuracy() + offCalculation() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 506154f8f..9761e80e3 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #222 with command revision 7. +-- Legacy hash audited against #214; native hash is #225 with command revision 8. local LEGACY = "e33d251961f2a41d" -local NATIVE = "dcc938191b0bb075" +local NATIVE = "b9ec4e8a94f4ba6e" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 7 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 8 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_triple_kick.lua b/tests/engine/gen3_triple_kick.lua new file mode 100644 index 000000000..d9e4f2222 --- /dev/null +++ b/tests/engine/gen3_triple_kick.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.triple_kick").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Triple Kick execution") diff --git a/tests/engine/gen3_triple_kick_composition.lua b/tests/engine/gen3_triple_kick_composition.lua new file mode 100644 index 000000000..a351fdb14 --- /dev/null +++ b/tests/engine/gen3_triple_kick_composition.lua @@ -0,0 +1,79 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "delegate", "count", "full", "damage", "fallback" }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base = assert(mod.content.move_effects:get("TRIPLE_KICK_EFFECT")) + local count = base.hitCount + mod.exports.counts, mod.exports.hits = 0, 0 + ]] .. (mode == "full" and 'base = {kind="full", hitCount=count}' or "") .. [[ + base.hitCount = function(ctx) + mod.exports.counts = mod.exports.counts + 1 + ]] .. (mode == "count" and "return 2" or "return count(ctx)") .. [[ + end + ]] .. (mode == "damage" and [[ + base.chooseDamage = function() return 2, {crit=false,typeMult=10} end + base.perHitDamage = function() + mod.exports.hits = mod.exports.hits + 1 + return 2, {crit=false,typeMult=10} + end + ]] or "") .. [[ + assert(mod.content.move_effects:override("TRIPLE_KICK_EFFECT", base)) + mod.exports.api = mod + end]] + end + local f = require("tests.enemy_actions.triple_kick").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.moves = { { id = f.ids.TRIPLE_KICK, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local original = b.data.move_effects + if mode == "fallback" then + b.data.move_effects = nil + end + local draws, hits = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return lo == 1 and hi == 100 and 1 or hi + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.damage + end) + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(#hits, mode == "count" and 2 or 3, "authored count remains authoritative") + T.eq(c.user.curMoves[1].pp, 9, "composed Triple Kick spends one PP") + local accuracy = 0 + for _, d in ipairs(draws) do + if d[1] == 1 and d[2] == 100 then + accuracy = accuracy + 1 + end + end + T.eq(accuracy, mode == "full" and 1 or #hits, "complete authored scripts own their accuracy policy") + if mode == "delegate" or mode == "count" or mode == "fallback" then + T.check(hits[1] < hits[2], "native delegation retains increasing kick power") + elseif mode == "full" then + T.eq(hits[1], hits[2], "complete authored override has no private native ramp") + elseif mode == "damage" then + T.same(hits, { 2, 2, 2 }, "explicit authored damage is not replaced") + T.eq(f.e.hits, 3, "every authored per-hit handler runs") + end + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "count handler runs once per attack") + end + T.eq( + require("src.battle.Gen3MoveEffects").full.TRIPLE_KICK_EFFECT.gbaTripleKick, + nil, + "per-load adapter leaves shared builtin statics untouched" + ) + off() + b.data.move_effects = original + f.close() + end +end +T.finish("native Triple Kick content composition") diff --git a/tests/engine/gen3_triple_kick_link.lua b/tests/engine/gen3_triple_kick_link.lua new file mode 100644 index 000000000..b5d4b8b0e --- /dev/null +++ b/tests/engine/gen3_triple_kick_link.lua @@ -0,0 +1,96 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.triple_kick").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 7 + T.eq(H.checkCompat(current, older), "subset", "pre-Triple-Kick rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = { + { id = f.ids.TRIPLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + ctx.accuracy = 100 + return nextAccuracy(ctx) + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 1 and 2 or 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 8, "two separately selected Triple Kicks spend one PP each") + T.eq(guest.mon.moves[2].pp, 9, "independently selected Tackle spends its own PP") + T.eq(guest.mon.moves[3].pp, 10, "unused move retains its PP") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Triple Kick real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index bb1e6dca3..4a1224cf7 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua b/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua new file mode 100644 index 000000000..583468708 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua @@ -0,0 +1,111 @@ +-- Optional CPU-only check against locally imported cartridges; distributes no assets. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cacheRoot = assert(arg[1], "pass the Gen2Recomp import-cache root") +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "ordinary", "miss1", "miss2", "miss3", "sub_break", "endure" }) do + local function configure(data, _, ids) + local root = cacheRoot .. "/" .. version .. "/data/generated/" + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. name .. ".lua")) + end + for name, number in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[number]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure) + T.link.prepare(f.data) + local b = f.fresh("TACKLE", { enemy = true }).battle + b:syncSides() + local id = assert(f.data.constants.moveOrder[167]) + local def = f.data.moves[id] + assert(def.index == 167 and def.gen3Effect == 104 and def.power == 10 and def.accuracy == 90) + b.player.mon.moves = { { id = id, pp = def.pp } } + b.player.curMoves = b.player.mon.moves + local reply = mode == "endure" and "ENDURE" or "TACKLE" + local replyId = mode == "endure" and assert(f.data.constants.moveOrder[203]) or f.ids.TACKLE + b.enemy.mon.moves = { { id = replyId, pp = f.data.moves[replyId].pp } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + if mode == "sub_break" then + b.enemy.substituteHP = 1 + end + if mode == "endure" then + b.enemy.mon.hp = 1 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + end + local checks, hits, draws = 0, {}, {} + local missAt = tonumber(mode:match("miss(%d)")) + local tripleAccuracy = false + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + tripleAccuracy = ctx.move.id == "TRIPLE_KICK" + if tripleAccuracy then + checks = checks + 1 + end + local out = nextAccuracy(ctx) + tripleAccuracy = false + return out + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + if e.move.id == "TRIPLE_KICK" then + hits[#hits + 1] = { damage = e.damage, substitute = e.substitute or false } + end + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if tripleAccuracy and checks == missAt then + value = 100 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + f.e.choose(function() + return { move = reply } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local snapshot = assert(f.e.api.battle:snapshot()) + assert(f.e.api.battle:submit({ id = 1, revision = snapshot.revision, kind = "move", slot = 1 })) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == 1, "public imported turn must finish") + local expected = missAt and missAt - 1 or mode == "endure" and 1 or 3 + assert(#hits == expected, mode .. ": wrong reached kick count") + assert(checks == (missAt or expected), mode .. ": wrong per-hit accuracy count") + assert(b.player.curMoves[1].pp == def.pp - 1, "one learned PP per submitted attack") + if mode == "ordinary" then + assert(hits[1].damage < hits[2].damage and hits[2].damage < hits[3].damage) + end + if mode == "sub_break" then + assert(hits[1].substitute and not hits[2].substitute and b.enemy.mon.hp < 400) + end + if mode == "endure" then + assert(b.enemy.mon.hp == 1) + end + rows[#rows + 1] = { + version = version, + mode = mode, + passed = true, + checks = checks, + hits = hits, + pp = b.player.curMoves[1].pp, + draws = draws, + } + off() + f.close() + end +end +print("IMPORTED_TRIPLE_KICK_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index ab27e5d38..ee79fc09d 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "rage" then +if mode == "triple_kick" then + rows = dofile(root .. "/tests/enemy_actions/triple_kick.lua").run(root, backend, version) +elseif mode == "rage" then rows = dofile(root .. "/tests/enemy_actions/rage.lua").run(root, backend, version) elseif mode == "multi_hit" then rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version) From 10a5c325a7852c6e8f7fea1502fafd9cfdc4a393 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:57:29 +0100 Subject: [PATCH 65/90] Honor authored never-miss policy on every Triple Kick strike --- src/battle/EffectRegistry.lua | 2 +- tests/engine/gen3_triple_kick_composition.lua | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index ee0e6cde8..593fb3eb9 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -306,7 +306,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Unlike ordinary multi-hit moves, each reached native kick checks -- accuracy again before its critical/damage opcodes. A later miss ends -- the script quietly; already landed kicks still reach move-end (Rage). - if tripleKick and h > 1 then + if tripleKick and h > 1 and (nativeAccuracy or not neverMiss and not alwaysHits) then local hit = battle:accuracyRoll(move, user, target, accuracyRaw) if not hit then break end end diff --git a/tests/engine/gen3_triple_kick_composition.lua b/tests/engine/gen3_triple_kick_composition.lua index a351fdb14..eb7d0b62b 100644 --- a/tests/engine/gen3_triple_kick_composition.lua +++ b/tests/engine/gen3_triple_kick_composition.lua @@ -1,7 +1,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") for _, version in ipairs({ "firered", "emerald" }) do - for _, mode in ipairs({ "delegate", "count", "full", "damage", "fallback" }) do + for _, mode in ipairs({ "delegate", "count", "full", "damage", "neverMiss", "fallback" }) do local entry if mode ~= "fallback" then entry = [[return function(mod) @@ -9,6 +9,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local count = base.hitCount mod.exports.counts, mod.exports.hits = 0, 0 ]] .. (mode == "full" and 'base = {kind="full", hitCount=count}' or "") .. [[ + ]] .. (mode == "neverMiss" and "base.neverMiss = true" or "") .. [[ base.hitCount = function(ctx) mod.exports.counts = mod.exports.counts + 1 ]] .. (mode == "count" and "return 2" or "return count(ctx)") .. [[ @@ -54,7 +55,11 @@ for _, version in ipairs({ "firered", "emerald" }) do accuracy = accuracy + 1 end end - T.eq(accuracy, mode == "full" and 1 or #hits, "complete authored scripts own their accuracy policy") + T.eq( + accuracy, + mode == "neverMiss" and 0 or mode == "full" and 1 or #hits, + "complete authored scripts own their accuracy policy" + ) if mode == "delegate" or mode == "count" or mode == "fallback" then T.check(hits[1] < hits[2], "native delegation retains increasing kick power") elseif mode == "full" then From ecd2c0cc8efbe1301b19bc289858e952dfbc2f95 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:02:05 +0100 Subject: [PATCH 66/90] Document native Triple Kick acceptance and effect catalog boundary --- docs/native-gen3-triple-kick.md | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/native-gen3-triple-kick.md diff --git a/docs/native-gen3-triple-kick.md b/docs/native-gen3-triple-kick.md new file mode 100644 index 000000000..f29b771b6 --- /dev/null +++ b/docs/native-gen3-triple-kick.md @@ -0,0 +1,116 @@ +# Native GBA Triple Kick execution + +Triple Kick previously used the legacy approximation: one accuracy/critical/ +damage calculation, repeated three times at twice the extracted power. Native +GBA effect 104 instead checks every reached kick, assigns power 10/20/30 and +calculates each critical and damage roll independently. Rounding, misses and +move-end state make the approximation observably different. + +This change stacks on #225. It adapts the builtin per dataset before mods +capture it and preserves the shared static fallback for other generations. +Complete authored records own their script; captured records retain native +behavior, authored counts, explicit damage handlers and `neverMiss` policy. +The native damage hook receives the assigned power in `ctx.opts.power`; a +public wrapper can delegate or change that value independently per kick. + +Stop before unreachable kicks when either battler faints, the user sleeps +outside the existing Sleep Talk continuation, a new accuracy/protection/hidden +check fails, a reached damage calculation is immune, or actual Endure ends the +sequence. Continue through a broken Substitute; Focus Band can save subsequent +strikes. A separately selected attack costs one learned PP regardless of how +many kicks land. Called execution keeps its own PP semantics. + +FireRed/Emerald move-end Rage runs once for a completed qualifying attack. +A later missed kick retains success from earlier kicks; a reached immunity +suppresses it. Accuracy failure precedes the next damage/type calculation, so +an immunity introduced after hit one does not suppress Rage when kick two +misses before reaching that calculation. + +## Verification + +At clean feature runtime `10a5c325a7852c6e8f7fea1502fafd9cfdc4a393`: + +- **2,220 focused checks** pass: 2,100 command/hook/lifecycle, 70 content + composition and 50 real link/compatibility checks. The 154-check native + protocol suite also passes. +- **108 workers / 76,362 paired observations** agree with unchanged, clean, + read-only Gen1 `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + The new mode contributes **576 complete rows per FireRed/Emerald profile**: + singles/doubles and all four actor slots; each possible missed kick; + critical stages/items; unchanged and edited extracted power; public damage + power overrides; Substitute, Endure, Focus Band, faint/sleep, called moves, + immunity/protection/hidden state before and after hit one, and Rage. +- Every bounded RNG call/value, public accuracy/damage calculation, hit/move + event and all battler HP/PP/status/Substitute/last-move/Attack/Rage observations + stay in the comparison. Public events are detached views; lifecycle probes + mutate and assert the fixture's owned live battlers. No draws or mismatches + are filtered out. +- Full feature: **126/135 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines across nine suites** equals clean #225 + `8e543618bc3a6ec9e0c6d8f1f31f9e12b5259f2e` (**123/132**). The full command's + nonzero exit remains retained. Their independent repairs are in the combined + branch, whose runtime `c1dac0994aa9fe0d1659e510874065697dca2534` passes + **246/246 engine suites**, SDK **3/3**, B30 and the same full comparison. +- Unchanged #225 fails **240 assertions** with the exact final fixture; + **396/576** complete FireRed rows differ from the unchanged reference. + Exploratory failures, the initial hook-signature error and all raw negatives + remain retained separately from accepted evidence. +- Two actual link roles complete three turns (Triple Kick, Tackle, Triple Kick) + with every completed paired hash/component equal and correct learned PP. + Native command revision **8** refuses revision 7 before party selection; + party codec remains **1**. Raw fixture `dcc938191b0bb075` becomes + `b9ec4e8a94f4ba6e`, audited using the unchanged #225 hasher with only its + explicit revision token changed. Other-generation goldens are unchanged. +- **Twelve CPU-only scenarios with actual local FireRed/Emerald imports** pass + public move submission and the completed turn/menu: three landed kicks, + misses on kick 1/2/3, Substitute breaking, and a faster enemy actually using + Endure. Extracted effect/power/accuracy and type metadata remain intact; + controlled valid RNG exercises the branches. Public events use canonical + `TRIPLE_KICK`, without relying on private move-index fields. No ROM/cache/ + save files are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Reproduce the ROM-free tests with the maintained pinned LuaJIT +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +nice -n 19 "$LUA" tests/engine/gen3_triple_kick.lua +nice -n 19 "$LUA" tests/engine/gen3_triple_kick_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_triple_kick_link.lua +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/triple-kick-contracts.json +``` + +Optional imported-data test (the root contains `firered/data/generated` and +`emerald/data/generated`): + +```sh +nice -n 19 "$LUA" tools/enemy-action-contract-check/imported-triple-kick-smoke.lua \ + /path/to/local/Gen2Recomp +``` + +Feature acceptance is retained in `tmp/triple-kick-qa/accepted-evidence.json`, +`full-feature-accepted.log`, `contracts-feature-accepted.json` and +`imported-final.log`; combined evidence is in `tmp/triple-kick-accepted-evidence.json`, +`tmp/triple-kick-full-accepted.log`, `tmp/triple-kick-contracts-accepted.json` +and `tmp/triple-kick-imported-accepted.log`. Later commits only document results. +Hosted workflow execution is recorded separately on the PR. + +## Catalog boundary and remaining work + +The earlier `RAGE_EFFECT` retrieval failure is a confirmed catalog difference, +not a missing fixture import. At the pinned Gen1 reference, generation-3 +`Builtins.registries(3)` seeds only types, growth and evolution. Both +`RAGE_EFFECT` and `TRIPLE_KICK_EFFECT` are absent from its public effect catalog; +Gen2 exposes both. A minimal authored primary effect registers successfully in +both engines, tested through the same public loader for FireRed and Emerald. +Raw audits are `tmp/triple-kick-qa/catalog-gen1.log` and `catalog-gen2.log`. + +Portable consumers must not assume native builtin capture/delegation is a +shared GBA contract. Host composition checks here are separate from the +cross-engine native execution comparison. This change neither publishes nor +requires any Gen1 modification. Complete builtin catalogs, full Sleep Talk/ +other controllers, Ruby/Sapphire per-hit scheduling, global RNG/selection +orchestration, rendering and full playthrough parity remain separate work. From 997ff3c978b14fb6e7203dee9c5a757ce58d7fb9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:26:22 +0100 Subject: [PATCH 67/90] Implement native GBA Sleep Talk selection and nested move execution --- .github/workflows/enemy-action-contract.yml | 1 + src/battle/BattleState.lua | 10 +- src/battle/Gen3Builtins.lua | 3 +- src/battle/Gen3SleepTalk.lua | 149 +++++++ src/battle/Status.lua | 24 +- src/link/Fingerprint.lua | 2 +- src/mods/Builtins.lua | 1 + tests/enemy_actions/sleep_talk.lua | 389 ++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_sleep_talk.lua | 5 + tests/engine/gen3_sleep_talk_composition.lua | 120 ++++++ tests/engine/gen3_sleep_talk_link.lua | 99 +++++ tools/enemy-action-contract-check/check.py | 2 +- .../imported-sleep-talk-smoke.lua | 144 +++++++ tools/enemy-action-contract-check/worker.lua | 4 +- 15 files changed, 941 insertions(+), 18 deletions(-) create mode 100644 src/battle/Gen3SleepTalk.lua create mode 100644 tests/enemy_actions/sleep_talk.lua create mode 100644 tests/engine/gen3_sleep_talk.lua create mode 100644 tests/engine/gen3_sleep_talk_composition.lua create mode 100644 tests/engine/gen3_sleep_talk_link.lua create mode 100644 tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 8c3cb8da2..4e733ed30 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -30,6 +30,7 @@ on: - 'tests/engine/gen3_multi_hit*.lua' - 'tests/engine/gen3_rage*.lua' - 'tests/engine/gen3_triple_kick*.lua' + - 'tests/engine/gen3_sleep_talk*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 59f0610d3..69e8cf116 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7966,6 +7966,7 @@ function BattleState:statusInterrupt(user, target, move) end local canMove, msgs, selfHit, reason = Status.beforeMove(user, self.rng, self, move) for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end + if reason == "gen3_sleep" or reason == "gen3_flinch" then user.lastMove = nil end if reason == "gen3_disabled" then require("src.battle.Gen3Disable").cancel(user) return true @@ -8003,7 +8004,7 @@ function BattleState:statusInterrupt(user, target, move) -- while one is going on, on either side. if (self.player and self.player.uproarTurns) or (self.enemy and self.enemy.uproarTurns) then - if user.mon.status == "SLP" then + if user.mon.status == "SLP" and (not GameVersion.isGen3() or Abilities.of(user) ~= "SOUNDPROOF") then user.mon.status = nil self:sayNext(Strings("%s woke up in\nthe UPROAR!", displayName(user))) end @@ -8217,6 +8218,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni moveInst.pp = math.max(0, moveInst.pp - cost) end + if record and record.gbaSleepTalk and user.mon.status == "SLP" then self:statusOnomatopoeia(user, "sleep") end self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) @@ -8262,7 +8264,8 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni self:cancelMoveAnim() end if pick then - self:performMove(user, target, { id = pick, pp = 1 }, true) + if record.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, pick) + else self:performMove(user, target, { id = pick, pp = 1 }, true) end end return end @@ -8357,7 +8360,8 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni local picked = user.callsMoveId user.callsMoveId = nil for _, m in ipairs(msgs) do self:sayNext(m) end - self:performMove(user, target, { id = picked, pp = 1 }, true) + if ctx.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, picked) + else self:performMove(user, target, { id = picked, pp = 1 }, true) end return end -- Gen 1 status/stat effects animate only when they take effect diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index 55717685e..6383d051a 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -4,6 +4,7 @@ function M.record(id, base) base = require("src.battle.Gen3Disable").builtin(id, base) base = require("src.battle.Gen3MultiHit").builtin(id, base) base = require("src.battle.Gen3Rage").builtin(id, base) - return require("src.battle.Gen3TripleKick").builtin(id, base) + base = require("src.battle.Gen3TripleKick").builtin(id, base) + return require("src.battle.Gen3SleepTalk").builtin(id, base) end return M diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua new file mode 100644 index 000000000..49cf5b483 --- /dev/null +++ b/src/battle/Gen3SleepTalk.lua @@ -0,0 +1,149 @@ +local S = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache, statusCache = setmetatable({}, { __mode = "k" }), setmetatable({}, { __mode = "k" }) +local EXCLUDED = { [214] = true, [274] = true, [119] = true, [118] = true, [264] = true, [253] = true } +local TWO_TURN = { [26] = true, [39] = true, [75] = true, [145] = true, [151] = true, [155] = true } +local function number(move) + return move and tonumber(move.index or move.numId) or move and ({ SLEEP_TALK = 214, SNORE = 173 })[move.id] +end +local function clear(user) + user.mon.status, user.sleepTurns, user.nightmare, user.expNightmare = nil, nil, nil, nil +end +function S.beforeMove(user, rng, battle, move) + local ability = require("src.battle.Abilities").of(user) + if ability ~= "SOUNDPROOF" then + for id = 0, 3 do + local b = battle + and ( + battle.battlerAt and battle:battlerAt(id) + or (id == 0 and battle.player or id == 1 and battle.enemy) + ) + if b and b.mon.hp > 0 and (tonumber(b.uproarTurns) or 0) > 0 then + clear(user) + return true, { Strings("%s woke up in\nthe UPROAR!", user.name) } + end + end + end + local turns = tonumber(user.sleepTurns) or tonumber(user.mon.sleepTurns or user.mon.sleep) + if not turns or turns <= 0 then + turns = rng(0, 3) % 4 + 2 + end + turns = math.max(0, turns - (ability == "EARLY_BIRD" and 2 or 1)) + user.sleepTurns = turns + if turns == 0 then + clear(user) + return true, { Strings("%s\nwoke up!", user.name) } + end + local n = number(move and battle and battle.data and battle.data.moves[move.id] or move) + if n == 214 then + return true, {} + end + if n == 173 then + return true, { Strings("%s\nis fast asleep!", user.name) } + end + return false, { Strings("%s\nis fast asleep!", user.name) }, false, "gen3_sleep" +end +function S.status(id, base) + if not V.isGen3() or id ~= "SLP" or not base then + return base + end + if statusCache[base] then + return statusCache[base] + end + local out = {} + for k, v in pairs(base) do + out[k] = v + end + out.beforeMove = function(user, rng, battle, move) + if not V.isGen3() then + return base.beforeMove(user, rng, battle, move) + end + return S.beforeMove(user, rng, battle, move) + end + statusCache[base] = out + return out +end +function S.pick(ctx) + local user = ctx.user + if user.mon.status ~= "SLP" then + ctx.say(Strings("But, it failed!")) + return + end + local pool = {} + for slot, inst in ipairs(user.curMoves or user.mon.moves or {}) do + if slot > 4 then + break + end + local move = ctx.data.moves[inst.id] + local n = number(move) + if + move + and n + and n ~= 0 + and not EXCLUDED[n] + and not TWO_TURN[tonumber(move.gen3Effect)] + and user.disabledMove ~= inst.id + and not (user.encoreMove and (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove ~= inst.id) + then + pool[#pool + 1] = inst.id + end + end + if #pool == 0 then + ctx.say(Strings("But, it failed!")) + return + end + return pool[ctx.rng(1, #pool)] +end +function S.call(ctx, pick) + local b, user = ctx.battle, ctx.user + local move = ctx.data.moves[pick] + if not move then + return + end + local targets + if b:isDouble() and tonumber(move.target) == 32 then + -- The native all-other-battlers script uses controller slot order, + -- independent of speed and the generic foes-then-ally convenience list. + targets = {} + local order = b.linkRole == "guest" and { 1, 0, 3, 2 } or { 0, 1, 2, 3 } + for _, id in ipairs(order) do + local target = b:battlerAt(id) + if target and target ~= user and target.mon.hp > 0 then + targets[#targets + 1] = target + end + end + elseif b:isDouble() then + targets = require("src.battle.Gen3Encore").targets(b, user, move) + else + targets = { tonumber(move.target) == 16 and user or b:defaultTargetFor(user) } + end + for i, target in ipairs(targets) do + b:performMove(user, target, { id = pick, pp = 1 }, true, i > 1, i > 1) + end + user.lastMove = ctx.move.id +end +function S.run(ctx) + if ctx.user.mon.status == "SLP" then + ctx.battle:statusOnomatopoeia(ctx.user, "sleep") + end + ctx.gbaSleepTalk = true + ctx.user.callsMoveId = S.pick(ctx) + return {} +end +function S.builtin(id, base) + if not V.isGen3() or id ~= "SLEEP_TALK_EFFECT" or not base then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for k, v in pairs(base) do + out[k] = v + end + out.kind, out.gbaSleepTalk, out.callsMove, out.run = "full", true, S.pick, S.run + cache[base] = out + return out +end +return S diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 8fd084699..1f8b91c0b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -232,7 +232,9 @@ end -- the merged view when a battle is on hand, the vanilla records otherwise function Status.recordFor(statuses, id) if id == nil then return nil end - return (statuses or Status.RECORDS)[id] + local record = (statuses or Status.RECORDS)[id] + if not statuses then record = require("src.battle.Gen3SleepTalk").status(id, record) end + return record end local function battleStatuses(battle) @@ -252,7 +254,8 @@ function Status.beforeMove(battler, rng, battle, move) battler.skipMove = nil return false, {} end - if battler.flinched then + local gba = require("src.core.GameVersion").isGen3() + if battler.flinched and not gba then battler.flinched = false return false, { Strings("%s\nflinched!", name(battler)) } end @@ -261,15 +264,20 @@ function Status.beforeMove(battler, rng, battle, move) local priority = handler and (record.beforeMovePriority or 0) local msgs = {} local function runStatus() - local canMove, statusMsgs, selfHit = handler(battler, rng, battle) + local canMove, statusMsgs, selfHit, reason = handler(battler, rng, battle, move) for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end - return canMove, selfHit + return canMove, selfHit, reason end if handler and priority > VOLATILE_PRIORITY then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove, selfHit, reason = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit, reason end handler = nil end + if gba and battler.flinched then + battler.flinched = false + msgs[#msgs + 1] = Strings("%s\nflinched!", name(battler)) + return false, msgs, false, "gen3_flinch" + end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) @@ -307,8 +315,8 @@ function Status.beforeMove(battler, rng, battle, move) end end if handler then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove, selfHit, reason = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit, reason end end return true, msgs end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 7cff40321..13dae168c 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 8 or nil + return require("src.core.GameVersion").isGen3() and 9 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index be698bf17..85b3ccb05 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -102,6 +102,7 @@ end function Builtins.install(content, data) for _, entry in ipairs(REGISTRANTS) do local adapt = entry.name == "move_effects" and require("src.battle.Gen3Builtins").record or nil + if entry.name == "statuses" then adapt = require("src.battle.Gen3SleepTalk").status end local registry = content[entry.name] and isolate(content[entry.name], adapt) if registry then if entry.install then diff --git a/tests/enemy_actions/sleep_talk.lua b/tests/enemy_actions/sleep_talk.lua new file mode 100644 index 000000000..17bef1430 --- /dev/null +++ b/tests/enemy_actions/sleep_talk.lua @@ -0,0 +1,389 @@ +local M = {} +local DEFS = { + { 700, "MOD_SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 99, "RAGE", 81, "RAGE_EFFECT", 15, 0 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 173, "SNORE", 92, "SNORE_EFFECT", 40, 0 }, + { 33, "TACKLE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 10, "SCRATCH", 0, "NO_ADDITIONAL_EFFECT", 40, 0 }, + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT", 10, 0 }, + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT", 15, 0 }, + { 105, "RECOVER", 32, "HEAL_EFFECT", 0, 16 }, + { 118, "METRONOME", 83, "METRONOME_EFFECT", 0, 0 }, + { 119, "MIRROR_MOVE", 9, "MIRROR_MOVE_EFFECT", 0, 0 }, + { 274, "ASSIST", 181, "ASSIST_EFFECT", 0, 0 }, + { 264, "FOCUS_PUNCH", 170, "FOCUS_PUNCH_EFFECT", 150, 0 }, + { 253, "UPROAR", 159, "UPROAR_EFFECT", 50, 0 }, + { 117, "BIDE", 26, "BIDE_EFFECT", 0, 0 }, + { 13, "RAZOR_WIND", 39, "CHARGE_EFFECT", 80, 0 }, + { 143, "SKY_ATTACK", 75, "SKY_ATTACK_EFFECT", 140, 0 }, + { 130, "SKULL_BASH", 145, "CHARGE_EFFECT", 100, 0 }, + { 76, "SOLARBEAM", 151, "SOLARBEAM_EFFECT", 120, 0 }, + { 19, "FLY", 155, "FLY_EFFECT", 70, 0 }, +} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, r in ipairs(DEFS) do + local number, name, effect, binding, power, target = unpack(r) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + protectAffected = target ~= 16, + secondaryChance = 0, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, used, damage = {}, {}, {}, {} + local offUsed = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function def(number) + if native then + return f.data.gen3Moves._rom[number] + end + return f.data.moves[("MOVE_%03d"):format(number)] + end + local function field(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + status = a.mon.status, + sleep = a.sleepTurns, + pp = pp, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + sub = tonumber(a.substituteHP) or 0, + attack = a.stages.attack or 0, + rage = not not a.rage, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for pick = 1, 3 do + for _, kind in ipairs({ + "ordinary", + "awake", + "wake", + "early_bird", + "early_wake", + "missing_counter", + "zero_pp", + "disabled_choice", + "encore_self", + "empty", + "multi", + "triple", + "snore", + "recover", + "flinch", + "sleep_block", + "wake_ordinary", + "uproar", + "soundproof", + "forbidden", + "bide", + "razor_wind", + "sky_attack", + "skull_bash", + "solar_beam", + "disabled_caller", + "called", + "called_awake", + "custom_caller", + "zero_id", + "fifth_slot", + "spread_both", + "spread_all", + "rage", + "wake_flinch", + }) do + draws, used, damage = {}, {}, {} + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + local slots = { 214, 33, 10, 33 } + if kind == "multi" then + slots = { 214, 3, 3, 3 } + end + if kind == "triple" then + slots = { 214, 167, 167, 167 } + end + if kind == "snore" then + slots = { 214, 173, 173, 173 } + end + if kind == "recover" then + slots = { 214, 105, 105, 105 } + end + if kind == "empty" then + slots = { 214, 118, 119, 274 } + end + if kind == "forbidden" then + slots = { 214, 264, 253, 19 } + end + local nested = ({ spread_both = 57, spread_all = 89, rage = 99 })[kind] + if nested then + slots = { 214, nested, nested, nested } + end + local banned = ( + { bide = 117, razor_wind = 13, sky_attack = 143, skull_bash = 130, solar_beam = 76 } + )[kind] + if banned then + slots = { 214, banned, banned, banned } + end + if kind == "custom_caller" then + slots[1] = 700 + end + if kind == "zero_id" then + slots = { 214, 0, 10, 0 } + end + if kind == "fifth_slot" then + slots = { 214, 118, 119, 274, 33 } + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = Copy(slots), { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + else + a.mon.moves = {} + for _, num in ipairs(slots) do + a.mon.moves[#a.mon.moves + 1] = { id = ("MOVE_%03d"):format(num), pp = 10 } + end + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + end + a.mon.hp = 400 + end + local user, target = at(b, uid), at(b, tid) + user.mon.status, user.sleepTurns = "SLP", 4 + if kind == "awake" or kind == "called_awake" then + user.mon.status, user.sleepTurns = nil, nil + end + if kind == "wake" or kind == "wake_ordinary" or kind == "wake_flinch" then + user.sleepTurns = 1 + end + if kind == "missing_counter" then + user.sleepTurns = nil + end + if kind == "early_bird" or kind == "early_wake" then + user[native and "ability" or "abilityOverride"] = native and 48 or "EARLY_BIRD" + user.sleepTurns = kind == "early_wake" and 2 or 4 + end + if kind == "zero_pp" then + for i = 2, 4 do + if native then + user.mon.pp[i] = 0 + else + user.curMoves[i].pp = 0 + end + end + end + if kind == "disabled_choice" then + user[native and "expDisabledMove" or "disabledMove"] = native and 33 or f.ids.TACKLE + user[native and "expDisableTurns" or "disabledTurns"] = 3 + end + if kind == "disabled_caller" then + user[native and "expDisabledMove" or "disabledMove"] = native and 214 or f.ids.SLEEP_TALK + user[native and "expDisableTurns" or "disabledTurns"] = 3 + end + if kind == "encore_self" then + user[native and "expEncoreMove" or "encoreMove"] = native and 214 or f.ids.SLEEP_TALK + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + end + if kind == "recover" then + user.mon.hp = 100 + end + if kind == "flinch" or kind == "wake_flinch" then + user.flinched = true + end + if kind == "uproar" or kind == "soundproof" then + target[native and "expUproarTurns" or "uproarTurns"] = 3 + if kind == "soundproof" then + user[native and "ability" or "abilityOverride"] = native and 43 or "SOUNDPROOF" + end + end + user.lastMoveId, user.lastMove = native and 10 or nil, native and 10 or f.ids.SCRATCH + local sleepPending = kind == "missing_counter" + b.rng = function(lo, hi) + local value = hi + if lo == 1 and hi <= 3 then + value = math.min(pick, hi) + end + if lo == 1 and hi == 100 then + value = 1 + end + if lo == 0 and hi == 3 then + if sleepPending then + sleepPending = false + value = 0 + else + value = tid + end + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + local ordinary = kind == "sleep_block" or kind == "wake_ordinary" + local slot = ordinary and 2 or 1 + local called = kind == "called" or kind == "called_awake" + if native then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + rows[#rows + 1] = { + label = (double and "double" or "single") .. "/" .. uid .. "/" .. pick .. "/" .. kind, + field = field(b), + draws = Copy(draws), + used = Copy(used), + damage = Copy(damage), + } + if kind == "ordinary" or kind == "zero_pp" then + T.eq(#used, 2, "Sleep Talk and the chosen move each emit one public event") + T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "only the selected caller spends PP") + T.eq(user.sleepTurns, 3, "nested called execution does not decrement sleep again") + end + if + banned + or kind == "empty" + or kind == "forbidden" + or kind == "encore_self" + or kind == "fifth_slot" + then + T.eq(#damage, 0, "an empty native eligible pool cannot execute a forbidden move") + end + if kind == "called" then + T.eq(#used, 2, "called Sleep Talk dispatches its nested move") + T.eq(user.sleepTurns, 4, "called Sleep Talk bypasses the outer sleep canceller") + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 10, + "called Sleep Talk spends neither caller nor child PP" + ) + end + if kind == "sleep_block" then + T.eq(#used, 0, "ordinary moves remain blocked while asleep") + end + if kind == "wake_ordinary" then + T.eq(user.mon.status, nil, "native wake clears persistent sleep") + end + end + end + end + end + T.same(f.result.loader.errors, {}, "public callbacks complete without swallowed runtime errors") + offUsed() + offDamage() + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index 9761e80e3..ee0583886 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #225 with command revision 8. +-- Legacy hash audited against #214; native hash is #226 with command revision 9. local LEGACY = "e33d251961f2a41d" -local NATIVE = "b9ec4e8a94f4ba6e" +local NATIVE = "f1bec52ffc687ea3" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 8 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 9 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_sleep_talk.lua b/tests/engine/gen3_sleep_talk.lua new file mode 100644 index 000000000..ba568adb3 --- /dev/null +++ b/tests/engine/gen3_sleep_talk.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.sleep_talk").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Sleep Talk selection and execution") diff --git a/tests/engine/gen3_sleep_talk_composition.lua b/tests/engine/gen3_sleep_talk_composition.lua new file mode 100644 index 000000000..76cea7053 --- /dev/null +++ b/tests/engine/gen3_sleep_talk_composition.lua @@ -0,0 +1,120 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "delegate", "primary_delegate", "override", "status_override", "fallback" }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base=assert(mod.content.move_effects:get("SLEEP_TALK_EFFECT")) + local pick,run=base.callsMove,base.run + mod.exports.calls=0 + ]] .. (mode == "primary_delegate" and [[ + base={kind="primary",run=function(ctx)mod.exports.calls=mod.exports.calls+1;return run(ctx)end} + ]] or [[ + base.callsMove=function(ctx) + mod.exports.calls=mod.exports.calls+1 + ]] .. (mode == "override" and "return ctx.user.curMoves[3].id" or "return pick(ctx)") .. [[ + end + ]]) .. [[ + assert(mod.content.move_effects:override("SLEEP_TALK_EFFECT",base)) + ]] .. (mode == "status_override" and [[ + local status=mod.content.statuses:get("SLP") + status.beforeMove=function()return false,{}end + assert(mod.content.statuses:override("SLP",status)) + ]] or "") .. [[ + mod.exports.api=mod + end]] + end + local f = require("tests.enemy_actions.sleep_talk").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.status, c.user.sleepTurns = "SLP", 4 + c.user.mon.moves = + { { id = f.ids.SLEEP_TALK, pp = 10 }, { id = f.ids.TACKLE, pp = 0 }, { id = f.ids.SCRATCH, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local originalEffects, originalStatuses = b.data.move_effects, b.data.statuses + if mode == "fallback" then + b.data.move_effects, b.data.statuses = nil, nil + end + local used = {} + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { move = e.move.id, target = e.target.mon.nickname, called = e.isCalled or false } + end) + b.rng = function(lo, hi) + if lo == 1 then + return 1 + end + return hi + end + b:executeAction(c.user, c.user, c.user.curMoves[1]) + if mode == "status_override" then + T.eq(#used, 0, "authored sleep interruption owns cancellation") + T.eq(f.e.calls, 0, "cancelled caller never selects a nested move") + T.eq(c.user.curMoves[1].pp, 10, "authored cancellation spends no PP") + else + T.eq(#used, 2, "default or delegated caller reaches one nested move") + T.eq( + used[2].move, + mode == "override" and "SCRATCH" or "TACKLE", + "authored choice or native zero-PP eligibility is respected" + ) + T.eq(used[2].called, true, "nested public event retains called provenance") + T.eq(c.user.curMoves[1].pp, 9, "caller alone spends PP") + T.eq(c.user.curMoves[2].pp, 0, "called learned slot retains zero PP") + T.eq(c.user.sleepTurns, 3, "native sleep ticks once") + T.check( + c.target.mon.hp < 400 and c.user.mon.hp == 400, + "nested attack targets the foe rather than the caller" + ) + if mode ~= "primary_delegate" then + local asleep, announced + for i, row in ipairs(b.queue) do + if row.text and row.text:find("is fast asleep", 1, true) then + asleep = asleep or i + end + if row.text and row.text:find("used", 1, true) then + announced = announced or i + end + end + T.check( + asleep and announced and asleep < announced, + "native sleep text precedes the move announcement" + ) + end + T.eq(c.user.lastMove, f.ids.SLEEP_TALK, "native bookkeeping retains selected caller identity") + if mode ~= "fallback" then + T.eq(f.e.calls, 1, "captured builtin delegation is invoked once") + end + end + T.eq(b._gbaMoveFrame, nil, "nested dispatcher restores its frame") + T.eq( + require("src.battle.MoveEffects").RECORDS.SLEEP_TALK_EFFECT.callsMove, + nil, + "shared legacy effect remains unchanged" + ) + T.same(f.result.loader.errors, {}, "record callbacks complete without swallowed runtime errors") + off() + b.data.move_effects, b.data.statuses = originalEffects, originalStatuses + f.close() + end +end +local V = require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "red", "gold" }) do + V.set(version) + local base = require("src.battle.Status").RECORDS.SLP + T.eq( + require("src.battle.Gen3SleepTalk").status("SLP", base), + base, + "older-generation status records are not adapted" + ) + local user = { name = "OLD", mon = { status = "SLP" }, sleepTurns = 1 } + local canMove = require("src.battle.Status").beforeMove(user, function(lo) + return lo + end, nil, { id = "SLEEP_TALK" }) + T.eq(canMove, false, "older-generation waking still forfeits the turn") + T.eq(user.mon.status, nil, "older-generation waking clears sleep") +end +V.set(before) +T.finish("native Sleep Talk effect and sleep status composition") diff --git a/tests/engine/gen3_sleep_talk_link.lua b/tests/engine/gen3_sleep_talk_link.lua new file mode 100644 index 000000000..c86932ebc --- /dev/null +++ b/tests/engine/gen3_sleep_talk_link.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.sleep_talk").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 8 + T.eq(H.checkCompat(current, older), "subset", "pre-Sleep-Talk rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].status, game.save.party[1].sleepTurns = "SLP", 3 + game.save.party[1].moves = { + { id = f.ids.SLEEP_TALK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + ctx.accuracy = 100 + return nextAccuracy(ctx) + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = (turn == 1 or turn >= 3) and 2 or 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[4] and b.localHashes[4] then + break + end + end + T.eq(a.player.mon.status, nil, "real linked turns wake the host") + T.eq(a.enemy.mon.status, nil, "real linked turns wake the guest") + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 4 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 8, "sleeping and awake Sleep Talk each spend caller PP") + T.eq(guest.mon.moves[2].pp, 9, "independently selected Tackle spends its own PP") + T.eq(guest.mon.moves[3].pp, 10, "unused move retains its PP") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Sleep Talk real link menus and completed turn hashes") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 4a1224cf7..f0d343ec6 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick"), (3, "sleep_talk")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua new file mode 100644 index 000000000..794e03222 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua @@ -0,0 +1,144 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cacheRoot = assert(arg[1], "pass the local Gen2Recomp cache root") +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "repeat", "zero_pp", "empty", "forbidden", "early_wake", "awake" }) do + local function configure(data, _, ids) + local root = cacheRoot .. "/" .. version .. "/data/generated/" + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure) + T.link.prepare(f.data) + local b = f.fresh("TACKLE", { enemy = true }).battle + b:syncSides() + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local caller = id(214) + assert(f.data.moves[caller].gen3Effect == 97 and f.data.moves[caller].power == 0) + local nums = { 214, 33, 10 } + if mode == "empty" then + nums = { 214, 118, 119, 274 } + end + if mode == "forbidden" then + nums = { 214, 264, 253, 19 } + end + local moves = {} + for _, n in ipairs(nums) do + moves[#moves + 1] = { id = id(n), pp = f.data.moves[id(n)].pp } + end + b.player.mon.moves, b.player.curMoves = moves, moves + b.enemy.mon.moves = { { id = id(33), pp = f.data.moves[id(33)].pp } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + b.player.mon.status, b.player.sleepTurns = "SLP", 4 + if mode == "awake" then + b.player.mon.status, b.player.sleepTurns = nil, nil + end + if mode == "early_wake" then + b.player.abilityOverride, b.player.sleepTurns = "EARLY_BIRD", 2 + end + if mode == "zero_pp" then + for i = 2, #moves do + moves[i].pp = 0 + end + end + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, draws = {}, {} + local off = f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { move = e.move.id, target = e.target.mon.nickname, called = e.isCalled or false } + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local v = lo == 1 and 1 or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + f.e.choose(function() + return { move = "TACKLE" } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local serial = 0 + local function submit(action) + serial = serial + 1 + action.id, action.revision = serial, assert(f.e.api.battle:snapshot()).revision + local ok, why = f.e.api.battle:submit(action) + assert(ok, why) + end + local turns = mode == "repeat" and 4 or 1 + for turn = 1, turns do + if turn > 1 then + submit({ kind = "menu", choice = "fight" }) + end + submit({ kind = "move", slot = turn == 4 and 2 or 1 }) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == turn, "public imported turn must finish") + end + local children = 0 + assert(#f.result.loader.errors == 0, table.concat(f.result.loader.errors, "\n")) + for _, e in ipairs(used) do + if e.move == "SLEEP_TALK" then + assert(e.target == "FOE", "native DEPENDS caller uses its resolved foe target") + end + if e.called then + children = children + 1 + assert(e.target == "FOE" and e.move == "TACKLE") + end + end + assert( + children == (mode == "repeat" and 3 or mode == "zero_pp" and 1 or 0), + "wrong native called execution" + ) + assert(moves[1].pp == f.data.moves[caller].pp - (mode == "repeat" and 3 or 1), "only caller PP is spent") + if mode == "repeat" then + assert(b.player.mon.status == nil, "wake allows the independently selected ordinary move") + assert( + moves[2].pp == f.data.moves[id(33)].pp - 1, + "called Tackle PP stays intact until independently selected" + ) + end + if mode == "zero_pp" then + assert(moves[2].pp == 0 and moves[3].pp == 0) + end + if mode == "early_wake" then + assert(b.player.mon.status == nil and b.player.sleepTurns == nil) + end + assert(b._gbaMoveFrame == nil, "nested dispatcher restores its frame") + rows[#rows + 1] = { + version = version, + mode = mode, + passed = true, + turns = turns, + used = used, + draws = draws, + callerPP = moves[1].pp, + childPP = moves[2].pp, + status = b.player.mon.status, + sleep = b.player.sleepTurns, + } + off() + f.close() + end +end +print("IMPORTED_SLEEP_TALK_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index ee79fc09d..2a311de4a 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "triple_kick" then +if mode == "sleep_talk" then + rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version) +elseif mode == "triple_kick" then rows = dofile(root .. "/tests/enemy_actions/triple_kick.lua").run(root, backend, version) elseif mode == "rage" then rows = dofile(root .. "/tests/enemy_actions/rage.lua").run(root, backend, version) From 9b44f93b7a27ddfb368f490b69b3f6fa17004e0f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:28:53 +0100 Subject: [PATCH 68/90] Preserve persistent sleep counters alongside native active state --- src/battle/Gen3SleepTalk.lua | 5 ++++- tests/engine/gen3_sleep_talk_composition.lua | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index 49cf5b483..93ae2e765 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -1,3 +1,5 @@ +-- Native Sleep Talk selection/dispatch and the sleep precheck. Other attack +-- canceller phases retain their existing controller and are audited separately. local S = {} local V = require("src.core.GameVersion") local Strings = require("src.core.Strings") @@ -9,6 +11,7 @@ local function number(move) end local function clear(user) user.mon.status, user.sleepTurns, user.nightmare, user.expNightmare = nil, nil, nil, nil + user.mon.sleepTurns = 0 end function S.beforeMove(user, rng, battle, move) local ability = require("src.battle.Abilities").of(user) @@ -30,7 +33,7 @@ function S.beforeMove(user, rng, battle, move) turns = rng(0, 3) % 4 + 2 end turns = math.max(0, turns - (ability == "EARLY_BIRD" and 2 or 1)) - user.sleepTurns = turns + user.sleepTurns, user.mon.sleepTurns = turns, turns if turns == 0 then clear(user) return true, { Strings("%s\nwoke up!", user.name) } diff --git a/tests/engine/gen3_sleep_talk_composition.lua b/tests/engine/gen3_sleep_talk_composition.lua index 76cea7053..005d6d15d 100644 --- a/tests/engine/gen3_sleep_talk_composition.lua +++ b/tests/engine/gen3_sleep_talk_composition.lua @@ -63,6 +63,7 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(c.user.curMoves[1].pp, 9, "caller alone spends PP") T.eq(c.user.curMoves[2].pp, 0, "called learned slot retains zero PP") T.eq(c.user.sleepTurns, 3, "native sleep ticks once") + T.eq(c.user.mon.sleepTurns, 3, "the party record retains the actual remaining sleep count") T.check( c.target.mon.hp < 400 and c.user.mon.hp == 400, "nested attack targets the foe rather than the caller" From 7d2763e4a53c3286ba191afe9cec833e9f3137e9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:44:39 +0100 Subject: [PATCH 69/90] Allow portable authored GBA move effects and indexless Sleep Talk choices --- src/battle/Gen3SleepTalk.lua | 1 - src/mods/UnifiedContent.lua | 10 +- tests/enemy_actions/sleep_talk.lua | 154 ++++++++++++++++++- tests/engine/gen3_sleep_talk.lua | 1 + tools/enemy-action-contract-check/check.py | 2 +- tools/enemy-action-contract-check/worker.lua | 4 +- 6 files changed, 166 insertions(+), 6 deletions(-) diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index 93ae2e765..49960b103 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -82,7 +82,6 @@ function S.pick(ctx) local n = number(move) if move - and n and n ~= 0 and not EXCLUDED[n] and not TWO_TURN[tonumber(move.gen3Effect)] diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index dde1870a8..f71329141 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -274,7 +274,15 @@ local function writeRecord(ctx, kind, id, value, old, mode) 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 + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then + -- GBA's common battle core can execute authored records directly. + -- Native builtin IDs still need their own numeric-script adapters; + -- Gen1's GBA catalog does not expose those builtins as shared content. + local authored = ctx.gen == 3 and not require("src.battle.MoveEffects").RECORDS[value.effect] + if not authored then unsupported("changing move effects requires a native effect adapter") end + if old and old.gen3Effect ~= nil then out.gen3Effect = DELETE end + out.highCrit = false + end end if value.category ~= nil and old and old.class ~= nil then check(value.category ~= DELETE, "native move class cannot be deleted") diff --git a/tests/enemy_actions/sleep_talk.lua b/tests/enemy_actions/sleep_talk.lua index 17bef1430..cd0a970c6 100644 --- a/tests/enemy_actions/sleep_talk.lua +++ b/tests/enemy_actions/sleep_talk.lua @@ -1,5 +1,6 @@ local M = {} local DEFS = { + { 701, "MOD_STRIKE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, { 700, "MOD_SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, @@ -25,13 +26,15 @@ local DEFS = { } function M.fixture(root, backend, version, entry) local native = backend == "gen1" - return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + local nativeRows, nativeNames + local fixture = dofile(root .. "/tests/enemy_actions/fixture.lua").new( root, backend, version, 3, "unified", function(data, _, ids, rom, names) + nativeRows, nativeNames = rom, names for _, r in ipairs(DEFS) do local number, name, effect, binding, power, target = unpack(r) local id = native and name or ("MOVE_%03d"):format(number) @@ -72,6 +75,8 @@ function M.fixture(root, backend, version, entry) end, entry ) + fixture.nativeRows, fixture.nativeNames = nativeRows, nativeNames + return fixture end function M.run(root, backend, version) local native = backend == "gen1" @@ -167,6 +172,7 @@ function M.run(root, backend, version) "spread_all", "rage", "wake_flinch", + "indexless", }) do draws, used, damage = {}, {}, {} local b = f.fresh("TACKLE", { enemy = true }).battle @@ -210,7 +216,7 @@ function M.run(root, backend, version) if kind == "forbidden" then slots = { 214, 264, 253, 19 } end - local nested = ({ spread_both = 57, spread_all = 89, rage = 99 })[kind] + local nested = ({ spread_both = 57, spread_all = 89, rage = 99, indexless = 701 })[kind] if nested then slots = { 214, nested, nested, nested } end @@ -248,6 +254,9 @@ function M.run(root, backend, version) end a.mon.hp = 400 end + if kind == "indexless" and not native then + def(701).index = nil + end local user, target = at(b, uid), at(b, tid) user.mon.status, user.sleepTurns = "SLP", 4 if kind == "awake" or kind == "called_awake" then @@ -347,6 +356,13 @@ function M.run(root, backend, version) used = Copy(used), damage = Copy(damage), } + if kind == "indexless" and not native then + def(701).index = 701 + end + if kind == "indexless" then + T.eq(#used, 2, "a learned custom move needs no imported numeric index") + T.eq(#damage, 1, "the indexless custom move executes through the native caller") + end if kind == "ordinary" or kind == "zero_pp" then T.eq(#used, 2, "Sleep Talk and the chosen move each emit one public event") T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "only the selected caller spends PP") @@ -386,4 +402,138 @@ function M.run(root, backend, version) f.close() return rows end +function M.content(root, backend, version) + local native = backend == "gen1" + local rows = {} + for _, kind in ipairs({ "ordinary", "zero_pp", "awake", "primary", "patched" }) do + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PORTABLE_SLEEP_CHOICE",{kind="secondary",run=function(ctx) + mod.exports.runs=mod.exports.runs+1 + return {} + end})) + assert(mod.content.moves:register("PORTABLE_SLEEP_MOVE",{id="PORTABLE_SLEEP_MOVE",name="PORTABLE_SLEEP_MOVE",type="NORMAL",power=35,accuracy=100,pp=10,effect="PORTABLE_SLEEP_CHOICE"})) + mod.exports.runs=0 + mod.exports.api=mod + end]=] + if kind == "primary" then + entry = entry:gsub('kind="secondary"', 'kind="primary"'):gsub("power=35", "power=0") + entry = entry:gsub( + "mod.exports.runs=mod.exports.runs%+1", + 'mod.exports.runs=mod.exports.runs+1;ctx.changeStage(ctx.user,"attack",1)' + ) + elseif kind == "patched" then + entry = entry:gsub( + "mod.exports.runs=0", + 'assert(mod.content.moves:patch("FLY",{effect="PORTABLE_SLEEP_CHOICE"}));mod.exports.runs=0' + ) + end + local f = M.fixture(root, backend, version, entry) + local T = f.T + local c = f.fresh("TACKLE", { enemy = true }) + local b, user, target = c.battle, c.user, c.target + if not native then + for _, actor in ipairs({ user, target }) do + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + actor.curStats[stat] = 50 + end + end + end + local number, previousRegistryData + if native then + number = kind == "patched" and 19 + or assert(require("src.mods.Gen3Compat").moveId("PORTABLE_SLEEP_MOVE")) + -- The base fixture mounts its explicit native rows after public load. + -- Mount the actually merged authored row/name too; do not reconstruct + -- or modify its effect/power/type/flags. All mounted tables are owned + -- by this fixture and restored by close(). The source checkout stays clean. + local moves = require("src.core.game3.battle.moves") + assert(moves._rom == f.nativeRows and moves.BY_NUM == f.nativeNames) + f.nativeRows[number] = assert(f.data.gen3Moves._rom[number]) + local name = kind == "patched" and "FLY" or "PORTABLE_SLEEP_MOVE" + f.nativeNames[number] = name + moves._numByName[name] = number + local registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + assert(registries.hasEffects()) + user.mon.moves, user.mon.pp, user.mon.maxPp = + { 214, number }, { 10, kind == "zero_pp" and 0 or 10 }, { 10, 10 } + else + b:syncSides() + T.eq( + f.data.moves.PORTABLE_SLEEP_MOVE.index, + nil, + "portable authored move has no required numeric index" + ) + user.mon.moves = { + { id = f.ids.SLEEP_TALK, pp = 10 }, + { id = kind == "patched" and f.ids.FLY or "PORTABLE_SLEEP_MOVE", pp = kind == "zero_pp" and 0 or 10 }, + } + user.curMoves = user.mon.moves + if kind == "patched" then + T.eq(f.data.moves[f.ids.FLY].gen3Effect, nil, "authored effect clears the old native two-turn script") + end + end + user.mon.status, user.sleepTurns = "SLP", 4 + if kind == "awake" then + user.mon.status, user.sleepTurns = nil, nil + end + local draws, used, damage = {}, {}, {} + b.rng = function(lo, hi) + local v = lo == 1 and 1 or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { move = e.move.id, called = e.isCalled or false } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = + { move = e.move.id, damage = e.damage, crit = e.crit or false, typeMult = e.typeMult } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + 1, + 0, + 214, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + T.eq(f.e.runs, kind == "awake" and 0 or 1, "same API-3 authored effect executes through Sleep Talk") + T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "portable custom call spends caller PP only") + T.eq( + native and user.mon.pp[2] or user.curMoves[2].pp, + kind == "zero_pp" and 0 or 10, + "portable learned custom move keeps its PP" + ) + T.same(f.result.loader.errors, {}, "custom content and public callbacks execute without runtime errors") + rows[#rows + 1] = { + label = kind, + runs = f.e.runs, + attack = user.stages.attack or 0, + pp = { + native and user.mon.pp[1] or user.curMoves[1].pp, + native and user.mon.pp[2] or user.curMoves[2].pp, + }, + hp = { user.mon.hp, target.mon.hp }, + status = user.mon.status, + sleep = user.sleepTurns, + draws = draws, + used = used, + damage = damage, + } + off() + offDamage() + if native then + require("src.core.game3.battle.mod_registries").bind(previousRegistryData) + end + f.close() + end + return rows +end return M diff --git a/tests/engine/gen3_sleep_talk.lua b/tests/engine/gen3_sleep_talk.lua index ba568adb3..42ffbf90f 100644 --- a/tests/engine/gen3_sleep_talk.lua +++ b/tests/engine/gen3_sleep_talk.lua @@ -1,5 +1,6 @@ package.path = "./?.lua;./?/init.lua;" .. package.path for _, version in ipairs({ "firered", "emerald" }) do require("tests.enemy_actions.sleep_talk").run(".", "gen2", version) + require("tests.enemy_actions.sleep_talk").content(".", "gen2", version) end require("tests.modkit").finish("native GBA Sleep Talk selection and execution") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index f0d343ec6..532e9dee6 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -33,7 +33,7 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick"), (3, "sleep_talk")): + for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick"), (3, "sleep_talk"), (3, "sleep_talk_content")): values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 2a311de4a..85915e96d 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "sleep_talk" then +if mode == "sleep_talk_content" then + rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").content(root, backend, version) +elseif mode == "sleep_talk" then rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version) elseif mode == "triple_kick" then rows = dofile(root .. "/tests/enemy_actions/triple_kick.lua").run(root, backend, version) From 1f33eb866c90c375a2aa3b26a6b4f173dfc7ecdf Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:19:29 +0200 Subject: [PATCH 70/90] Map portable move effects to native Gen1-3 handlers --- docs/unified-mod-content.md | 35 +- src/battle/MoveEffects.lua | 2 +- src/mods/MoveEffectContract.lua | 1213 +++++++++++++++++ src/mods/UnifiedContent.lua | 35 +- src/mods/UnifiedEffects.lua | 135 ++ src/mods/UnifiedSchemas.lua | 2 +- tests/api3_contract/effects.lua | 13 + tests/api3_contract/mods/effects/main.lua | 33 + .../api3_contract/mods/effects/manifest.json | 1 + tests/api3_contract/warp.lua | 388 ++++++ tests/api3_contract/worker.lua | 8 +- tests/engine/unified_move_effects.lua | 208 +++ tools/api3-contract-check/check.py | 2 +- .../move-effect-contract/export_reference.lua | 44 + tools/move-effect-contract/generate.py | 75 + 15 files changed, 2173 insertions(+), 21 deletions(-) create mode 100644 src/mods/MoveEffectContract.lua create mode 100644 src/mods/UnifiedEffects.lua create mode 100644 tests/api3_contract/effects.lua create mode 100644 tests/api3_contract/mods/effects/main.lua create mode 100644 tests/api3_contract/mods/effects/manifest.json create mode 100644 tests/api3_contract/warp.lua create mode 100644 tests/engine/unified_move_effects.lua create mode 100644 tools/move-effect-contract/export_reference.lua create mode 100644 tools/move-effect-contract/generate.py diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md index 64d83da74..6108608a9 100644 --- a/docs/unified-mod-content.md +++ b/docs/unified-mod-content.md @@ -44,7 +44,7 @@ format's availability; other capability surfaces retain their existing answers. | Handle | Unified fields | Supported versions | | --- | --- | --- | | `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | -| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | | `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | | `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | | `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | @@ -114,11 +114,34 @@ per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` and `parties[1]` aliases stay synchronized. Move accuracy is percent, as in the engine's existing normalized definitions. -Native effect selectors and item behavior remain opaque: changing a move's -effect on Gen 2–5, changing later item behavior, or changing a DS move type -without a native type adapter returns `nil, reason`. This avoids changing a -symbolic field while leaving an incompatible native selector behind. Power, -accuracy, PP, category, price and other represented fields can be edited. +Gen1–3 move effects use canonical IDs, resolved to the host's actual native +battle handlers before an operation is journaled. For example +`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special +stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are +accepted aliases. Registered custom native effects retain their own IDs. + +`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit +odds convert to the nearest native fraction out of 256; detached unmodified +records retain their exact original byte. Explicit zero is distinguished from +a legacy native zero byte whose controller uses a fallback. Changing a +canonical effect applies its reference default chance unless the caller +supplies odds. Authored aliases survive later odds edits. + +Unknown native selectors are opaque `native:generation:selector` tokens on +Gen2/3. They roundtrip only with their original record; they cannot assign an +unknown handler to another move. Gen4/5 retain the previous diagnostic-only +numeric selector behavior and refuse effect/chance changes. Item behavior +and changing a DS move type remain unavailable without their native adapters. + +The data-only binding catalog is reproduced from pinned Gen1Recomp contract +maps plus the host importer's native names by +`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT +--check`. It does not execute imported content. A binding still requires a +real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH, +IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current +host. Identity-dependent native Rest/SonicBoom variants are not assigned to +an incompatible move. This adapter selects native behavior; it does not add +missing battle mechanics or promise cross-engine RNG/pixel equivalence. ### Encounters diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 77f64664f..9ae155305 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -144,7 +144,7 @@ end -- SHIELD DUST still refuses a doubled roll. local function sideChance(move, fallback, user, target) local own = move and tonumber(move.effectChance) - local base = (own and own > 0) and own or fallback + local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback if user or target then base = require("src.battle.Abilities").secondaryOdds(user, target, base) end diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua new file mode 100644 index 000000000..1d1ec1c27 --- /dev/null +++ b/src/mods/MoveEffectContract.lua @@ -0,0 +1,1213 @@ +-- Generated by tools/move-effect-contract/generate.py; do not hand-edit. +return { + ["bindings"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}}, + ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}}, + ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}}, + ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}}, + ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}}, + ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}}, + ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}}, + ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}}, + ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}}, + ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}}, + ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}}, + ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}}, + ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}}, + ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}}, + ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}}, + ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}}, + ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}}, + ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}}, + ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}}, + ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}}, + ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}}, + ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}}, + ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}}, + ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}}, + ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}}, + ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}}, + ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}}, + ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}}, + ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}}, + ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}}, + ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}}, + ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}}, + ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}}, + ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}}, + ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}}, + ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}}, + ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}}, + ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}}, + ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}}, + ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}}, + ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}}, + ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}}, + ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}}, + ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}}, + ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}}, + ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}}, + ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}}, + ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}}, + ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}}, + ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}}, + ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}}, + ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}}, + ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}}, + ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}}, + ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}}, + ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}}, + ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}}, + ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}}, + ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}}, + ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}}, + ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}}, + ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}}, + ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}}, + ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}}, + ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}}, + ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}}, + ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}}, + ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}}, + ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}}, + ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}}, + ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}}, + ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}}, + ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}}, + ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}}, + ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}}, + ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}}, + ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}}, + ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}}, + ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}}, + ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}}, + ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}}, + ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}}, + ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}}, + ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}}, + ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}}, + ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}}, + ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}}, + ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}}, + ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}}, + ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}}, + ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}}, + ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}}, + ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}}, + ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}}, + ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}}, + ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}}, + ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}}, + ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}}, + ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}}, + ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}}, + ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}}, + ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}}, + ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}}, + ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}}, + ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}}, + ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}}, + ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}}, + ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}}, + ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}}, + ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}}, + ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}}, + ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}}, + ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}}, + ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}}, + ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}}, + ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}}, + ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}}, + ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}}, + ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}}, + ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}}, + ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}}, + ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}}, + ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}}, + ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}}, + ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}}, + ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}}, + ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}}, + ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}}, + ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}}, + ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}}, + ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}}, + ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}}, + ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}}, + ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}}, + ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}}, + ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}}, + ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}}, + ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}}, + ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}}, + ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}}, + ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}}, + ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}}, + ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}}, + ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}}, + ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}}, + ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}}, + ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}}, + ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}}, + ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}}, + ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}}, + ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}}, + ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}}, + ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}}, + ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}}, + ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}}, + ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}}, + ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}}, + ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}}, + ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}}, + ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}}, + ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}}, + ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}}, + ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}}, + }, + ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee", + ["native3"] = { + ["0"] = {"NO_ADDITIONAL_EFFECT"}, + ["1"] = {"SLEEP_EFFECT"}, + ["10"] = {"ATTACK_UP1_EFFECT"}, + ["100"] = {"SPITE_EFFECT"}, + ["101"] = {"FALSE_SWIPE_EFFECT"}, + ["102"] = {"HEAL_BELL_EFFECT"}, + ["103"] = {"NO_ADDITIONAL_EFFECT"}, + ["104"] = {"TRIPLE_KICK_EFFECT"}, + ["105"] = {"THIEF_EFFECT"}, + ["106"] = {"MEAN_LOOK_EFFECT"}, + ["107"] = {"NIGHTMARE_EFFECT"}, + ["108"] = {"EVASION_UP1_EFFECT"}, + ["109"] = {"CURSE_EFFECT"}, + ["11"] = {"DEFENSE_UP1_EFFECT"}, + ["111"] = {"PROTECT_EFFECT"}, + ["112"] = {"SPIKES_EFFECT"}, + ["113"] = {"FORESIGHT_EFFECT"}, + ["114"] = {"PERISH_SONG_EFFECT"}, + ["115"] = {"SANDSTORM_EFFECT"}, + ["116"] = {"ENDURE_EFFECT"}, + ["117"] = {"ROLLOUT_EFFECT"}, + ["118"] = {"SWAGGER_EFFECT"}, + ["119"] = {"FURY_CUTTER_EFFECT"}, + ["120"] = {"ATTRACT_EFFECT"}, + ["121"] = {"RETURN_EFFECT"}, + ["122"] = {"PRESENT_EFFECT"}, + ["123"] = {"FRUSTRATION_EFFECT"}, + ["124"] = {"SAFEGUARD_EFFECT"}, + ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"}, + ["126"] = {"MAGNITUDE_EFFECT"}, + ["127"] = {"BATON_PASS_EFFECT"}, + ["128"] = {"PURSUIT_EFFECT"}, + ["129"] = {"RAPID_SPIN_EFFECT"}, + ["13"] = {"SP_ATK_UP1_EFFECT"}, + ["130"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["132"] = {"MORNING_SUN_EFFECT"}, + ["133"] = {"SYNTHESIS_EFFECT"}, + ["134"] = {"MOONLIGHT_EFFECT"}, + ["135"] = {"HIDDEN_POWER_EFFECT"}, + ["136"] = {"RAIN_DANCE_EFFECT"}, + ["137"] = {"SUNNY_DAY_EFFECT"}, + ["138"] = {"DEFENSE_UP_HIT_EFFECT"}, + ["139"] = {"ATTACK_UP_HIT_EFFECT"}, + ["140"] = {"ALL_UP_HIT_EFFECT"}, + ["142"] = {"BELLY_DRUM_EFFECT"}, + ["143"] = {"PSYCH_UP_EFFECT"}, + ["144"] = {"MIRROR_COAT_EFFECT"}, + ["145"] = {"CHARGE_EFFECT"}, + ["146"] = {"FLINCH_SIDE_EFFECT1"}, + ["147"] = {"EARTHQUAKE_EFFECT"}, + ["148"] = {"FUTURE_SIGHT_EFFECT"}, + ["149"] = {"GUST_EFFECT"}, + ["150"] = {"FLINCH_SIDE_EFFECT2"}, + ["151"] = {"SOLARBEAM_EFFECT"}, + ["152"] = {"THUNDER_EFFECT"}, + ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["154"] = {"BEAT_UP_EFFECT"}, + ["155"] = {"FLY_EFFECT"}, + ["156"] = {"DEFENSE_UP1_EFFECT"}, + ["157"] = {"HEAL_EFFECT"}, + ["158"] = {"FAKE_OUT_EFFECT"}, + ["159"] = {"UPROAR_EFFECT"}, + ["16"] = {"EVASION_UP1_EFFECT"}, + ["160"] = {"STOCKPILE_EFFECT"}, + ["161"] = {"SPIT_UP_EFFECT"}, + ["162"] = {"SWALLOW_EFFECT"}, + ["164"] = {"HAIL_EFFECT"}, + ["165"] = {"TORMENT_EFFECT"}, + ["166"] = {"FLATTER_EFFECT"}, + ["167"] = {"WILL_O_WISP_EFFECT"}, + ["168"] = {"MEMENTO_EFFECT"}, + ["169"] = {"FACADE_EFFECT"}, + ["17"] = {"SWIFT_EFFECT"}, + ["170"] = {"FOCUS_PUNCH_EFFECT"}, + ["171"] = {"SMELLINGSALT_EFFECT"}, + ["172"] = {"FOLLOW_ME_EFFECT"}, + ["173"] = {"NATURE_POWER_EFFECT"}, + ["174"] = {"CHARGE_UP_EFFECT"}, + ["175"] = {"TAUNT_EFFECT"}, + ["176"] = {"HELPING_HAND_EFFECT"}, + ["177"] = {"TRICK_EFFECT"}, + ["178"] = {"ROLE_PLAY_EFFECT"}, + ["179"] = {"WISH_EFFECT"}, + ["18"] = {"ATTACK_DOWN1_EFFECT"}, + ["180"] = {"ASSIST_EFFECT"}, + ["181"] = {"INGRAIN_EFFECT"}, + ["182"] = {"SUPERPOWER_EFFECT"}, + ["183"] = {"MAGIC_COAT_EFFECT"}, + ["184"] = {"RECYCLE_EFFECT"}, + ["185"] = {"REVENGE_EFFECT"}, + ["186"] = {"BRICK_BREAK_EFFECT"}, + ["187"] = {"YAWN_EFFECT"}, + ["188"] = {"KNOCK_OFF_EFFECT"}, + ["189"] = {"ENDEAVOR_EFFECT"}, + ["19"] = {"DEFENSE_DOWN1_EFFECT"}, + ["190"] = {"ERUPTION_EFFECT"}, + ["191"] = {"SKILL_SWAP_EFFECT"}, + ["192"] = {"IMPRISON_EFFECT"}, + ["193"] = {"REFRESH_EFFECT"}, + ["194"] = {"GRUDGE_EFFECT"}, + ["195"] = {"SNATCH_EFFECT"}, + ["196"] = {"LOW_KICK_EFFECT"}, + ["197"] = {"SECRET_POWER_EFFECT"}, + ["198"] = {"RECOIL_EFFECT"}, + ["199"] = {"TEETER_DANCE_EFFECT"}, + ["2"] = {"POISON_SIDE_EFFECT2"}, + ["20"] = {"SPEED_DOWN1_EFFECT"}, + ["200"] = {"BLAZE_KICK_EFFECT"}, + ["201"] = {"MUD_SPORT_EFFECT"}, + ["202"] = {"POISON_FANG_EFFECT"}, + ["203"] = {"WEATHER_BALL_EFFECT"}, + ["204"] = {"OVERHEAT_EFFECT"}, + ["205"] = {"TICKLE_EFFECT"}, + ["206"] = {"COSMIC_POWER_EFFECT"}, + ["207"] = {"SKY_UPPERCUT_EFFECT"}, + ["208"] = {"BULK_UP_EFFECT"}, + ["209"] = {"POISON_TAIL_EFFECT"}, + ["210"] = {"WATER_SPORT_EFFECT"}, + ["211"] = {"CALM_MIND_EFFECT"}, + ["212"] = {"DRAGON_DANCE_EFFECT"}, + ["213"] = {"CAMOUFLAGE_EFFECT"}, + ["23"] = {"ACCURACY_DOWN1_EFFECT"}, + ["24"] = {"EVASION_DOWN1_EFFECT"}, + ["25"] = {"HAZE_EFFECT"}, + ["26"] = {"BIDE_EFFECT"}, + ["27"] = {"THRASH_PETAL_DANCE_EFFECT"}, + ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"}, + ["3"] = {"DRAIN_HP_EFFECT"}, + ["30"] = {"CONVERSION_EFFECT"}, + ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"}, + ["32"] = {"HEAL_EFFECT"}, + ["33"] = {"POISON_EFFECT"}, + ["34"] = {"PAY_DAY_EFFECT"}, + ["35"] = {"LIGHT_SCREEN_EFFECT"}, + ["36"] = {"TRI_ATTACK_EFFECT"}, + ["37"] = {"HEAL_EFFECT"}, + ["38"] = {"OHKO_EFFECT"}, + ["39"] = {"CHARGE_EFFECT"}, + ["4"] = {"BURN_SIDE_EFFECT1"}, + ["40"] = {"SUPER_FANG_EFFECT"}, + ["41"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["42"] = {"TRAPPING_EFFECT"}, + ["43"] = {"HIGH_CRITICAL_EFFECT"}, + ["44"] = {"ATTACK_TWICE_EFFECT"}, + ["45"] = {"JUMP_KICK_EFFECT"}, + ["46"] = {"MIST_EFFECT"}, + ["47"] = {"FOCUS_ENERGY_EFFECT"}, + ["48"] = {"RECOIL_EFFECT"}, + ["49"] = {"CONFUSION_EFFECT"}, + ["5"] = {"FREEZE_SIDE_EFFECT1"}, + ["50"] = {"ATTACK_UP2_EFFECT"}, + ["51"] = {"DEFENSE_UP2_EFFECT"}, + ["52"] = {"SPEED_UP2_EFFECT"}, + ["53"] = {"SP_ATK_UP2_EFFECT"}, + ["54"] = {"SP_DEF_UP2_EFFECT"}, + ["57"] = {"TRANSFORM_EFFECT"}, + ["58"] = {"ATTACK_DOWN2_EFFECT"}, + ["59"] = {"DEFENSE_DOWN2_EFFECT"}, + ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"}, + ["60"] = {"SPEED_DOWN2_EFFECT"}, + ["62"] = {"SP_DEF_DOWN2_EFFECT"}, + ["65"] = {"REFLECT_EFFECT"}, + ["66"] = {"POISON_EFFECT"}, + ["67"] = {"PARALYZE_EFFECT"}, + ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"}, + ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"}, + ["7"] = {"EXPLODE_EFFECT"}, + ["70"] = {"SPEED_DOWN_SIDE_EFFECT"}, + ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"}, + ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"}, + ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"}, + ["75"] = {"CHARGE_EFFECT"}, + ["76"] = {"CONFUSION_SIDE_EFFECT"}, + ["77"] = {"TWINEEDLE_EFFECT"}, + ["78"] = {"SWIFT_EFFECT"}, + ["79"] = {"SUBSTITUTE_EFFECT"}, + ["8"] = {"DREAM_EATER_EFFECT"}, + ["80"] = {"HYPER_BEAM_EFFECT"}, + ["81"] = {"RAGE_EFFECT"}, + ["82"] = {"MIMIC_EFFECT"}, + ["83"] = {"METRONOME_EFFECT"}, + ["84"] = {"LEECH_SEED_EFFECT"}, + ["85"] = {"SPLASH_EFFECT"}, + ["86"] = {"DISABLE_EFFECT"}, + ["87"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["88"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["89"] = {"COUNTER_EFFECT"}, + ["9"] = {"MIRROR_MOVE_EFFECT"}, + ["90"] = {"ENCORE_EFFECT"}, + ["91"] = {"PAIN_SPLIT_EFFECT"}, + ["92"] = {"SNORE_EFFECT"}, + ["93"] = {"CONVERSION2_EFFECT"}, + ["94"] = {"LOCK_ON_EFFECT"}, + ["95"] = {"SKETCH_EFFECT"}, + ["97"] = {"SLEEP_TALK_EFFECT"}, + ["98"] = {"DESTINY_BOND_EFFECT"}, + ["99"] = {"REVERSAL_EFFECT"}, + }, + ["read3"] = { + ["0"] = "NO_ADDITIONAL_EFFECT", + ["1"] = "SLEEP_EFFECT", + ["10"] = "ATTACK_UP1_EFFECT", + ["100"] = "EFFECT_SPITE", + ["101"] = "EFFECT_FALSE_SWIPE", + ["102"] = "HEAL_BELL", + ["103"] = "EFFECT_PRIORITY_HIT", + ["104"] = "EFFECT_TRIPLE_KICK", + ["105"] = "EFFECT_THIEF", + ["106"] = "EFFECT_MEAN_LOOK", + ["107"] = "EFFECT_NIGHTMARE", + ["108"] = "MINIMIZE", + ["109"] = "EFFECT_CURSE", + ["11"] = "DEFENSE_UP1_EFFECT", + ["110"] = "native:3:110", + ["111"] = "EFFECT_PROTECT", + ["112"] = "EFFECT_SPIKES", + ["113"] = "EFFECT_FORESIGHT", + ["114"] = "EFFECT_PERISH_SONG", + ["115"] = "EFFECT_SANDSTORM", + ["116"] = "EFFECT_ENDURE", + ["117"] = "EFFECT_ROLLOUT", + ["118"] = "SWAGGER", + ["119"] = "EFFECT_FURY_CUTTER", + ["12"] = "native:3:SPEED_UP", + ["120"] = "EFFECT_ATTRACT", + ["121"] = "EFFECT_RETURN", + ["122"] = "PRESENT", + ["123"] = "EFFECT_FRUSTRATION", + ["124"] = "EFFECT_SAFEGUARD", + ["125"] = "EFFECT_SACRED_FIRE", + ["126"] = "EFFECT_MAGNITUDE", + ["127"] = "EFFECT_BATON_PASS", + ["128"] = "PURSUIT", + ["129"] = "RAPID_SPIN", + ["13"] = "SPECIAL_UP1_EFFECT", + ["130"] = "native:3:SONICBOOM", + ["131"] = "native:3:131", + ["132"] = "EFFECT_MORNING_SUN", + ["133"] = "EFFECT_SYNTHESIS", + ["134"] = "EFFECT_MOONLIGHT", + ["135"] = "native:3:HIDDEN_POWER", + ["136"] = "EFFECT_RAIN_DANCE", + ["137"] = "EFFECT_SUNNY_DAY", + ["138"] = "EFFECT_DEFENSE_UP_HIT", + ["139"] = "EFFECT_ATTACK_UP_HIT", + ["14"] = "native:3:SPECIAL_DEFENSE_UP", + ["140"] = "EFFECT_ALL_UP_HIT", + ["141"] = "native:3:141", + ["142"] = "EFFECT_BELLY_DRUM", + ["143"] = "EFFECT_PSYCH_UP", + ["144"] = "EFFECT_MIRROR_COAT", + ["145"] = "native:3:SKULL_BASH", + ["146"] = "TWISTER", + ["147"] = "EARTHQUAKE", + ["148"] = "EFFECT_FUTURE_SIGHT", + ["149"] = "GUST", + ["15"] = "native:3:ACCURACY_UP", + ["150"] = "FLINCH_MINIMIZE_HIT", + ["151"] = "native:3:SOLAR_BEAM", + ["152"] = "THUNDER", + ["153"] = "native:3:TELEPORT", + ["154"] = "EFFECT_BEAT_UP", + ["155"] = "FLY_EFFECT", + ["156"] = "EFFECT_DEFENSE_CURL", + ["157"] = "SOFTBOILED", + ["158"] = "FAKE_OUT", + ["159"] = "UPROAR", + ["16"] = "EVASION_UP1_EFFECT", + ["160"] = "STOCKPILE", + ["161"] = "SPIT_UP", + ["162"] = "SWALLOW", + ["163"] = "native:3:163", + ["164"] = "HAIL", + ["165"] = "TORMENT", + ["166"] = "FLATTER", + ["167"] = "EFFECT_BURN", + ["168"] = "MEMENTO", + ["169"] = "FACADE", + ["17"] = "SWIFT_EFFECT", + ["170"] = "FOCUS_PUNCH", + ["171"] = "SMELLINGSALT", + ["172"] = "FOLLOW_ME", + ["173"] = "NATURE_POWER", + ["174"] = "CHARGE", + ["175"] = "TAUNT", + ["176"] = "HELPING_HAND", + ["177"] = "TRICK", + ["178"] = "ROLE_PLAY", + ["179"] = "WISH", + ["18"] = "ATTACK_DOWN1_EFFECT", + ["180"] = "ASSIST", + ["181"] = "INGRAIN", + ["182"] = "SUPERPOWER", + ["183"] = "MAGIC_COAT", + ["184"] = "RECYCLE", + ["185"] = "REVENGE", + ["186"] = "BRICK_BREAK", + ["187"] = "YAWN", + ["188"] = "KNOCK_OFF", + ["189"] = "ENDEAVOR", + ["19"] = "DEFENSE_DOWN1_EFFECT", + ["190"] = "ERUPTION", + ["191"] = "SKILL_SWAP", + ["192"] = "IMPRISON", + ["193"] = "REFRESH", + ["194"] = "GRUDGE", + ["195"] = "SNATCH", + ["196"] = "LOW_KICK", + ["197"] = "SECRET_POWER", + ["198"] = "DOUBLE_EDGE", + ["199"] = "TEETER_DANCE", + ["2"] = "POISON_SIDE_EFFECT1", + ["20"] = "SPEED_DOWN1_EFFECT", + ["200"] = "BLAZE_KICK", + ["201"] = "MUD_SPORT", + ["202"] = "POISON_FANG", + ["203"] = "WEATHER_BALL", + ["204"] = "OVERHEAT", + ["205"] = "TICKLE", + ["206"] = "COSMIC_POWER", + ["207"] = "SKY_UPPERCUT", + ["208"] = "BULK_UP", + ["209"] = "POISON_TAIL", + ["21"] = "native:3:SPECIAL_ATTACK_DOWN", + ["210"] = "WATER_SPORT", + ["211"] = "CALM_MIND", + ["212"] = "DRAGON_DANCE", + ["213"] = "CAMOUFLAGE", + ["214"] = "native:3:214", + ["215"] = "native:3:215", + ["216"] = "native:3:216", + ["217"] = "native:3:217", + ["218"] = "native:3:218", + ["219"] = "native:3:219", + ["22"] = "native:3:SPECIAL_DEFENSE_DOWN", + ["220"] = "native:3:220", + ["221"] = "native:3:221", + ["222"] = "native:3:222", + ["223"] = "native:3:223", + ["224"] = "native:3:224", + ["225"] = "native:3:225", + ["226"] = "native:3:226", + ["227"] = "native:3:227", + ["228"] = "native:3:228", + ["229"] = "native:3:229", + ["23"] = "ACCURACY_DOWN1_EFFECT", + ["230"] = "native:3:230", + ["231"] = "native:3:231", + ["232"] = "native:3:232", + ["233"] = "native:3:233", + ["234"] = "native:3:234", + ["235"] = "native:3:235", + ["236"] = "native:3:236", + ["237"] = "native:3:237", + ["238"] = "native:3:238", + ["239"] = "native:3:239", + ["24"] = "EVASION_DOWN1_EFFECT", + ["240"] = "native:3:240", + ["241"] = "native:3:241", + ["242"] = "native:3:242", + ["243"] = "native:3:243", + ["244"] = "native:3:244", + ["245"] = "native:3:245", + ["246"] = "native:3:246", + ["247"] = "native:3:247", + ["248"] = "native:3:248", + ["249"] = "native:3:249", + ["25"] = "HAZE_EFFECT", + ["250"] = "native:3:250", + ["251"] = "native:3:251", + ["252"] = "native:3:252", + ["253"] = "native:3:253", + ["254"] = "native:3:254", + ["255"] = "native:3:255", + ["26"] = "BIDE_EFFECT", + ["27"] = "THRASH_PETAL_DANCE_EFFECT", + ["28"] = "SWITCH_AND_TELEPORT_EFFECT", + ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT", + ["3"] = "DRAIN_HP_EFFECT", + ["30"] = "CONVERSION_EFFECT", + ["31"] = "FLINCH_SIDE_EFFECT1", + ["32"] = "HEAL_EFFECT", + ["33"] = "native:3:TOXIC", + ["34"] = "PAY_DAY_EFFECT", + ["35"] = "LIGHT_SCREEN_EFFECT", + ["36"] = "TRI_ATTACK", + ["37"] = "native:3:REST", + ["38"] = "OHKO_EFFECT", + ["39"] = "CHARGE_EFFECT", + ["4"] = "BURN_SIDE_EFFECT1", + ["40"] = "SUPER_FANG_EFFECT", + ["41"] = "native:3:DRAGON_RAGE", + ["42"] = "TRAPPING_EFFECT", + ["43"] = "native:3:HIGH_CRITICAL", + ["44"] = "ATTACK_TWICE_EFFECT", + ["45"] = "JUMP_KICK_EFFECT", + ["46"] = "MIST_EFFECT", + ["47"] = "FOCUS_ENERGY_EFFECT", + ["48"] = "RECOIL_EFFECT", + ["49"] = "CONFUSION_EFFECT", + ["5"] = "FREEZE_SIDE_EFFECT1", + ["50"] = "ATTACK_UP2_EFFECT", + ["51"] = "DEFENSE_UP2_EFFECT", + ["52"] = "SPEED_UP2_EFFECT", + ["53"] = "SPECIAL_ATTACK_UP_2", + ["54"] = "SPECIAL_UP2_EFFECT", + ["55"] = "native:3:ACCURACY_UP_2", + ["56"] = "native:3:EVASION_UP_2", + ["57"] = "TRANSFORM_EFFECT", + ["58"] = "ATTACK_DOWN2_EFFECT", + ["59"] = "DEFENSE_DOWN2_EFFECT", + ["6"] = "PARALYZE_SIDE_EFFECT1", + ["60"] = "SPEED_DOWN2_EFFECT", + ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2", + ["62"] = "SPECIAL_DOWN2_EFFECT", + ["63"] = "native:3:ACCURACY_DOWN_2", + ["64"] = "native:3:EVASION_DOWN_2", + ["65"] = "REFLECT_EFFECT", + ["66"] = "POISON_EFFECT", + ["67"] = "PARALYZE_EFFECT", + ["68"] = "ATTACK_DOWN_SIDE_EFFECT", + ["69"] = "DEFENSE_DOWN_SIDE_EFFECT", + ["7"] = "EXPLODE_EFFECT", + ["70"] = "SPEED_DOWN_SIDE_EFFECT", + ["71"] = "SPECIAL_ATTACK_DOWN_HIT", + ["72"] = "SPECIAL_DOWN_SIDE_EFFECT", + ["73"] = "EFFECT_ACCURACY_DOWN_HIT", + ["74"] = "native:3:EVASION_DOWN_HIT", + ["75"] = "native:3:SKY_ATTACK", + ["76"] = "CONFUSION_SIDE_EFFECT", + ["77"] = "TWINEEDLE_EFFECT", + ["78"] = "VITAL_THROW", + ["79"] = "SUBSTITUTE_EFFECT", + ["8"] = "DREAM_EATER_EFFECT", + ["80"] = "HYPER_BEAM_EFFECT", + ["81"] = "RAGE_EFFECT", + ["82"] = "MIMIC_EFFECT", + ["83"] = "METRONOME_EFFECT", + ["84"] = "LEECH_SEED_EFFECT", + ["85"] = "SPLASH_EFFECT", + ["86"] = "DISABLE_EFFECT", + ["87"] = "SPECIAL_DAMAGE_EFFECT", + ["88"] = "native:3:PSYWAVE", + ["89"] = "native:3:COUNTER", + ["9"] = "MIRROR_MOVE_EFFECT", + ["90"] = "EFFECT_ENCORE", + ["91"] = "PAIN_SPLIT", + ["92"] = "EFFECT_SNORE", + ["93"] = "CONVERSION_2", + ["94"] = "EFFECT_LOCK_ON", + ["95"] = "EFFECT_SKETCH", + ["96"] = "native:3:96", + ["97"] = "EFFECT_SLEEP_TALK", + ["98"] = "DESTINY_BOND", + ["99"] = "FLAIL", + }, + ["reference"] = { + ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"}, + }, + ["reserved"] = { + ["ABSORB"] = true, + ["ACCURACY_DOWN"] = true, + ["ACCURACY_DOWN1_EFFECT"] = true, + ["ACCURACY_DOWN2_EFFECT"] = true, + ["ACCURACY_DOWN_2"] = true, + ["ACCURACY_DOWN_HIT"] = true, + ["ACCURACY_UP"] = true, + ["ACCURACY_UP1_EFFECT"] = true, + ["ACCURACY_UP2_EFFECT"] = true, + ["ACCURACY_UP_2"] = true, + ["AFTER_HIT"] = true, + ["ALL_STATS_UP_HIT"] = true, + ["ALWAYS_HIT"] = true, + ["ASSIST"] = true, + ["ATTACK_DOWN"] = true, + ["ATTACK_DOWN1_EFFECT"] = true, + ["ATTACK_DOWN2_EFFECT"] = true, + ["ATTACK_DOWN_2"] = true, + ["ATTACK_DOWN_HIT"] = true, + ["ATTACK_DOWN_SIDE_EFFECT"] = true, + ["ATTACK_TWICE_EFFECT"] = true, + ["ATTACK_UP"] = true, + ["ATTACK_UP1_EFFECT"] = true, + ["ATTACK_UP2_EFFECT"] = true, + ["ATTACK_UP_2"] = true, + ["ATTACK_UP_HIT"] = true, + ["ATTRACT"] = true, + ["BATON_PASS"] = true, + ["BEAT_UP"] = true, + ["BELLY_DRUM"] = true, + ["BIDE"] = true, + ["BIDE_EFFECT"] = true, + ["BLAZE_KICK"] = true, + ["BRICK_BREAK"] = true, + ["BULK_UP"] = true, + ["BURN_HIT"] = true, + ["BURN_SIDE_EFFECT1"] = true, + ["BURN_SIDE_EFFECT2"] = true, + ["CALM_MIND"] = true, + ["CAMOUFLAGE"] = true, + ["CHARGE"] = true, + ["CHARGE_EFFECT"] = true, + ["CONFUSE"] = true, + ["CONFUSE_HIT"] = true, + ["CONFUSION_EFFECT"] = true, + ["CONFUSION_SIDE_EFFECT"] = true, + ["CONVERSION"] = true, + ["CONVERSION_2"] = true, + ["CONVERSION_EFFECT"] = true, + ["COSMIC_POWER"] = true, + ["COUNTER"] = true, + ["CURSE"] = true, + ["DEFENSE_CURL"] = true, + ["DEFENSE_DOWN"] = true, + ["DEFENSE_DOWN1_EFFECT"] = true, + ["DEFENSE_DOWN2_EFFECT"] = true, + ["DEFENSE_DOWN_2"] = true, + ["DEFENSE_DOWN_HIT"] = true, + ["DEFENSE_DOWN_SIDE_EFFECT"] = true, + ["DEFENSE_UP"] = true, + ["DEFENSE_UP1_EFFECT"] = true, + ["DEFENSE_UP2_EFFECT"] = true, + ["DEFENSE_UP_2"] = true, + ["DEFENSE_UP_HIT"] = true, + ["DESTINY_BOND"] = true, + ["DISABLE"] = true, + ["DISABLE_EFFECT"] = true, + ["DOUBLE_EDGE"] = true, + ["DOUBLE_HIT"] = true, + ["DRAGON_DANCE"] = true, + ["DRAGON_RAGE"] = true, + ["DRAIN_HP_EFFECT"] = true, + ["DREAM_EATER"] = true, + ["DREAM_EATER_EFFECT"] = true, + ["EARTHQUAKE"] = true, + ["EFFECT_01"] = true, + ["EFFECT_1E"] = true, + ["EFFECT_ACCURACY_DOWN"] = true, + ["EFFECT_ACCURACY_DOWN_2"] = true, + ["EFFECT_ACCURACY_DOWN_HIT"] = true, + ["EFFECT_ACCURACY_UP"] = true, + ["EFFECT_ACCURACY_UP_2"] = true, + ["EFFECT_ALL_UP_HIT"] = true, + ["EFFECT_ALWAYS_HIT"] = true, + ["EFFECT_ATTACK_DOWN"] = true, + ["EFFECT_ATTACK_DOWN_2"] = true, + ["EFFECT_ATTACK_DOWN_HIT"] = true, + ["EFFECT_ATTACK_UP"] = true, + ["EFFECT_ATTACK_UP_2"] = true, + ["EFFECT_ATTACK_UP_HIT"] = true, + ["EFFECT_ATTRACT"] = true, + ["EFFECT_BATON_PASS"] = true, + ["EFFECT_BEAT_UP"] = true, + ["EFFECT_BELLY_DRUM"] = true, + ["EFFECT_BIDE"] = true, + ["EFFECT_BURN_HIT"] = true, + ["EFFECT_CONFUSE"] = true, + ["EFFECT_CONFUSE_HIT"] = true, + ["EFFECT_CONVERSION"] = true, + ["EFFECT_CONVERSION2"] = true, + ["EFFECT_COUNTER"] = true, + ["EFFECT_CURSE"] = true, + ["EFFECT_DEFENSE_CURL"] = true, + ["EFFECT_DEFENSE_DOWN"] = true, + ["EFFECT_DEFENSE_DOWN_2"] = true, + ["EFFECT_DEFENSE_DOWN_HIT"] = true, + ["EFFECT_DEFENSE_UP"] = true, + ["EFFECT_DEFENSE_UP_2"] = true, + ["EFFECT_DEFENSE_UP_HIT"] = true, + ["EFFECT_DEFROST_OPPONENT"] = true, + ["EFFECT_DESTINY_BOND"] = true, + ["EFFECT_DISABLE"] = true, + ["EFFECT_DOUBLE_HIT"] = true, + ["EFFECT_DREAM_EATER"] = true, + ["EFFECT_EARTHQUAKE"] = true, + ["EFFECT_ENCORE"] = true, + ["EFFECT_ENDURE"] = true, + ["EFFECT_EVASION_DOWN"] = true, + ["EFFECT_EVASION_DOWN_2"] = true, + ["EFFECT_EVASION_DOWN_HIT"] = true, + ["EFFECT_EVASION_UP"] = true, + ["EFFECT_EVASION_UP_2"] = true, + ["EFFECT_FAKE_OUT"] = true, + ["EFFECT_FALSE_SWIPE"] = true, + ["EFFECT_FLAME_WHEEL"] = true, + ["EFFECT_FLINCH_HIT"] = true, + ["EFFECT_FLY"] = true, + ["EFFECT_FOCUS_ENERGY"] = true, + ["EFFECT_FORCE_SWITCH"] = true, + ["EFFECT_FORESIGHT"] = true, + ["EFFECT_FREEZE_HIT"] = true, + ["EFFECT_FRUSTRATION"] = true, + ["EFFECT_FURY_CUTTER"] = true, + ["EFFECT_FUTURE_SIGHT"] = true, + ["EFFECT_GUST"] = true, + ["EFFECT_HEAL"] = true, + ["EFFECT_HEAL_BELL"] = true, + ["EFFECT_HIDDEN_POWER"] = true, + ["EFFECT_HYPER_BEAM"] = true, + ["EFFECT_JUMP_KICK"] = true, + ["EFFECT_LEECH_HIT"] = true, + ["EFFECT_LEECH_SEED"] = true, + ["EFFECT_LEVEL_DAMAGE"] = true, + ["EFFECT_LIGHT_SCREEN"] = true, + ["EFFECT_LOCK_ON"] = true, + ["EFFECT_MAGNITUDE"] = true, + ["EFFECT_MEAN_LOOK"] = true, + ["EFFECT_METRONOME"] = true, + ["EFFECT_MIMIC"] = true, + ["EFFECT_MIRROR_COAT"] = true, + ["EFFECT_MIRROR_MOVE"] = true, + ["EFFECT_MIST"] = true, + ["EFFECT_MOONLIGHT"] = true, + ["EFFECT_MORNING_SUN"] = true, + ["EFFECT_MULTI_HIT"] = true, + ["EFFECT_NIGHTMARE"] = true, + ["EFFECT_NORMAL_HIT"] = true, + ["EFFECT_OHKO"] = true, + ["EFFECT_PAIN_SPLIT"] = true, + ["EFFECT_PARALYZE"] = true, + ["EFFECT_PARALYZE_HIT"] = true, + ["EFFECT_PAY_DAY"] = true, + ["EFFECT_PERISH_SONG"] = true, + ["EFFECT_POISON"] = true, + ["EFFECT_POISON_HIT"] = true, + ["EFFECT_POISON_MULTI_HIT"] = true, + ["EFFECT_PRESENT"] = true, + ["EFFECT_PRIORITY_HIT"] = true, + ["EFFECT_PROTECT"] = true, + ["EFFECT_PSYCH_UP"] = true, + ["EFFECT_PSYWAVE"] = true, + ["EFFECT_PURSUIT"] = true, + ["EFFECT_RAGE"] = true, + ["EFFECT_RAIN_DANCE"] = true, + ["EFFECT_RAMPAGE"] = true, + ["EFFECT_RAPID_SPIN"] = true, + ["EFFECT_RAZOR_WIND"] = true, + ["EFFECT_RECOIL_HIT"] = true, + ["EFFECT_REFLECT"] = true, + ["EFFECT_RESET_STATS"] = true, + ["EFFECT_RETURN"] = true, + ["EFFECT_REVERSAL"] = true, + ["EFFECT_ROLLOUT"] = true, + ["EFFECT_SACRED_FIRE"] = true, + ["EFFECT_SAFEGUARD"] = true, + ["EFFECT_SANDSTORM"] = true, + ["EFFECT_SELFDESTRUCT"] = true, + ["EFFECT_SKETCH"] = true, + ["EFFECT_SKULL_BASH"] = true, + ["EFFECT_SKY_ATTACK"] = true, + ["EFFECT_SLEEP"] = true, + ["EFFECT_SLEEP_TALK"] = true, + ["EFFECT_SNORE"] = true, + ["EFFECT_SOLARBEAM"] = true, + ["EFFECT_SPEED_DOWN"] = true, + ["EFFECT_SPEED_DOWN_2"] = true, + ["EFFECT_SPEED_DOWN_HIT"] = true, + ["EFFECT_SPEED_UP"] = true, + ["EFFECT_SPEED_UP_2"] = true, + ["EFFECT_SPIKES"] = true, + ["EFFECT_SPITE"] = true, + ["EFFECT_SPLASH"] = true, + ["EFFECT_SP_ATK_DOWN"] = true, + ["EFFECT_SP_ATK_DOWN_2"] = true, + ["EFFECT_SP_ATK_DOWN_HIT"] = true, + ["EFFECT_SP_ATK_UP"] = true, + ["EFFECT_SP_ATK_UP_2"] = true, + ["EFFECT_SP_DEF_DOWN"] = true, + ["EFFECT_SP_DEF_DOWN_2"] = true, + ["EFFECT_SP_DEF_DOWN_HIT"] = true, + ["EFFECT_SP_DEF_UP"] = true, + ["EFFECT_SP_DEF_UP_2"] = true, + ["EFFECT_STATIC_DAMAGE"] = true, + ["EFFECT_STOMP"] = true, + ["EFFECT_SUBSTITUTE"] = true, + ["EFFECT_SUNNY_DAY"] = true, + ["EFFECT_SUPER_FANG"] = true, + ["EFFECT_SWAGGER"] = true, + ["EFFECT_SYNTHESIS"] = true, + ["EFFECT_TELEPORT"] = true, + ["EFFECT_THIEF"] = true, + ["EFFECT_THUNDER"] = true, + ["EFFECT_TOXIC"] = true, + ["EFFECT_TRANSFORM"] = true, + ["EFFECT_TRAP_TARGET"] = true, + ["EFFECT_TRIPLE_KICK"] = true, + ["EFFECT_TRI_ATTACK"] = true, + ["EFFECT_TWISTER"] = true, + ["EFFECT_UNUSED_25"] = true, + ["EFFECT_UNUSED_2B"] = true, + ["EFFECT_UNUSED_4E"] = true, + ["EFFECT_UNUSED_6E"] = true, + ["EFFECT_UNUSED_82"] = true, + ["EFFECT_UNUSED_83"] = true, + ["ENCORE"] = true, + ["ENDEAVOR"] = true, + ["ENDURE"] = true, + ["ERUPTION"] = true, + ["EVASION_DOWN"] = true, + ["EVASION_DOWN1_EFFECT"] = true, + ["EVASION_DOWN2_EFFECT"] = true, + ["EVASION_DOWN_2"] = true, + ["EVASION_DOWN_HIT"] = true, + ["EVASION_UP"] = true, + ["EVASION_UP1_EFFECT"] = true, + ["EVASION_UP2_EFFECT"] = true, + ["EVASION_UP_2"] = true, + ["EXPLODE_EFFECT"] = true, + ["EXPLOSION"] = true, + ["FACADE"] = true, + ["FAKE_OUT"] = true, + ["FALSE_SWIPE"] = true, + ["FLAIL"] = true, + ["FLATTER"] = true, + ["FLINCH_HIT"] = true, + ["FLINCH_MINIMIZE_HIT"] = true, + ["FLINCH_SIDE_EFFECT1"] = true, + ["FLINCH_SIDE_EFFECT2"] = true, + ["FLY"] = true, + ["FLY_EFFECT"] = true, + ["FOCUS_ENERGY"] = true, + ["FOCUS_ENERGY_EFFECT"] = true, + ["FOCUS_PUNCH"] = true, + ["FOLLOW_ME"] = true, + ["FORESIGHT"] = true, + ["FREEZE_HIT"] = true, + ["FREEZE_SIDE_EFFECT1"] = true, + ["FREEZE_SIDE_EFFECT2"] = true, + ["FRUSTRATION"] = true, + ["FURY_CUTTER"] = true, + ["FUTURE_SIGHT"] = true, + ["GRUDGE"] = true, + ["GUST"] = true, + ["HAIL"] = true, + ["HAZE"] = true, + ["HAZE_EFFECT"] = true, + ["HEAL_BELL"] = true, + ["HEAL_EFFECT"] = true, + ["HELPING_HAND"] = true, + ["HIDDEN_POWER"] = true, + ["HIGH_CRITICAL"] = true, + ["HIT"] = true, + ["HYPER_BEAM_EFFECT"] = true, + ["IMPRISON"] = true, + ["INGRAIN"] = true, + ["JUMP_KICK_EFFECT"] = true, + ["KNOCK_OFF"] = true, + ["LEECH_SEED"] = true, + ["LEECH_SEED_EFFECT"] = true, + ["LEVEL_DAMAGE"] = true, + ["LIGHT_SCREEN"] = true, + ["LIGHT_SCREEN_EFFECT"] = true, + ["LOCK_ON"] = true, + ["LOW_KICK"] = true, + ["MAGIC_COAT"] = true, + ["MAGNITUDE"] = true, + ["MEAN_LOOK"] = true, + ["MEMENTO"] = true, + ["METRONOME"] = true, + ["METRONOME_EFFECT"] = true, + ["MIMIC"] = true, + ["MIMIC_EFFECT"] = true, + ["MINIMIZE"] = true, + ["MIRROR_COAT"] = true, + ["MIRROR_MOVE"] = true, + ["MIRROR_MOVE_EFFECT"] = true, + ["MIST"] = true, + ["MIST_EFFECT"] = true, + ["MOONLIGHT"] = true, + ["MORNING_SUN"] = true, + ["MUD_SPORT"] = true, + ["MULTI_HIT"] = true, + ["NATURE_POWER"] = true, + ["NIGHTMARE"] = true, + ["NO_ADDITIONAL_EFFECT"] = true, + ["OHKO"] = true, + ["OHKO_EFFECT"] = true, + ["OVERHEAT"] = true, + ["PAIN_SPLIT"] = true, + ["PARALYZE"] = true, + ["PARALYZE_EFFECT"] = true, + ["PARALYZE_HIT"] = true, + ["PARALYZE_SIDE_EFFECT1"] = true, + ["PARALYZE_SIDE_EFFECT2"] = true, + ["PAY_DAY"] = true, + ["PAY_DAY_EFFECT"] = true, + ["PERISH_SONG"] = true, + ["POISON"] = true, + ["POISON_EFFECT"] = true, + ["POISON_FANG"] = true, + ["POISON_HIT"] = true, + ["POISON_SIDE_EFFECT1"] = true, + ["POISON_SIDE_EFFECT2"] = true, + ["POISON_TAIL"] = true, + ["PRESENT"] = true, + ["PROTECT"] = true, + ["PSYCH_UP"] = true, + ["PSYWAVE"] = true, + ["PURSUIT"] = true, + ["QUICK_ATTACK"] = true, + ["RAGE"] = true, + ["RAGE_EFFECT"] = true, + ["RAIN_DANCE"] = true, + ["RAMPAGE"] = true, + ["RAPID_SPIN"] = true, + ["RAZOR_WIND"] = true, + ["RECHARGE"] = true, + ["RECOIL"] = true, + ["RECOIL_25"] = true, + ["RECOIL_EFFECT"] = true, + ["RECOIL_IF_MISS"] = true, + ["RECOVER"] = true, + ["RECYCLE"] = true, + ["REFLECT"] = true, + ["REFLECT_EFFECT"] = true, + ["REFRESH"] = true, + ["REST"] = true, + ["RESTORE_HP"] = true, + ["RETURN"] = true, + ["REVENGE"] = true, + ["ROAR"] = true, + ["ROLE_PLAY"] = true, + ["ROLLOUT"] = true, + ["SAFEGUARD"] = true, + ["SANDSTORM"] = true, + ["SECONDARY"] = true, + ["SECRET_POWER"] = true, + ["SECRET_POWER_BY_TERRAIN"] = true, + ["SEMI_INVULNERABLE"] = true, + ["SKETCH"] = true, + ["SKILL_SWAP"] = true, + ["SKULL_BASH"] = true, + ["SKY_ATTACK"] = true, + ["SKY_UPPERCUT"] = true, + ["SLEEP"] = true, + ["SLEEP_EFFECT"] = true, + ["SLEEP_TALK"] = true, + ["SMELLINGSALT"] = true, + ["SNATCH"] = true, + ["SNORE"] = true, + ["SOFTBOILED"] = true, + ["SOLAR_BEAM"] = true, + ["SONICBOOM"] = true, + ["SPECIAL_ATTACK_DOWN"] = true, + ["SPECIAL_ATTACK_DOWN_2"] = true, + ["SPECIAL_ATTACK_DOWN_HIT"] = true, + ["SPECIAL_ATTACK_UP"] = true, + ["SPECIAL_ATTACK_UP_2"] = true, + ["SPECIAL_DAMAGE_EFFECT"] = true, + ["SPECIAL_DEFENSE_DOWN"] = true, + ["SPECIAL_DEFENSE_DOWN_2"] = true, + ["SPECIAL_DEFENSE_DOWN_HIT"] = true, + ["SPECIAL_DEFENSE_UP"] = true, + ["SPECIAL_DEFENSE_UP_2"] = true, + ["SPECIAL_DOWN1_EFFECT"] = true, + ["SPECIAL_DOWN2_EFFECT"] = true, + ["SPECIAL_DOWN_SIDE_EFFECT"] = true, + ["SPECIAL_UP1_EFFECT"] = true, + ["SPECIAL_UP2_EFFECT"] = true, + ["SPEED_DOWN"] = true, + ["SPEED_DOWN1_EFFECT"] = true, + ["SPEED_DOWN2_EFFECT"] = true, + ["SPEED_DOWN_2"] = true, + ["SPEED_DOWN_HIT"] = true, + ["SPEED_DOWN_SIDE_EFFECT"] = true, + ["SPEED_UP"] = true, + ["SPEED_UP1_EFFECT"] = true, + ["SPEED_UP2_EFFECT"] = true, + ["SPEED_UP_2"] = true, + ["SPIKES"] = true, + ["SPITE"] = true, + ["SPIT_UP"] = true, + ["SPLASH"] = true, + ["SPLASH_EFFECT"] = true, + ["STATUS_SETUP"] = true, + ["STAT_CHANGES"] = true, + ["STOCKPILE"] = true, + ["SUBSTITUTE"] = true, + ["SUBSTITUTE_EFFECT"] = true, + ["SUNNY_DAY"] = true, + ["SUPERPOWER"] = true, + ["SUPER_FANG"] = true, + ["SUPER_FANG_EFFECT"] = true, + ["SWAGGER"] = true, + ["SWALLOW"] = true, + ["SWIFT_EFFECT"] = true, + ["SWITCH_AND_TELEPORT_EFFECT"] = true, + ["SYNTHESIS"] = true, + ["TAUNT"] = true, + ["TEETER_DANCE"] = true, + ["TELEPORT"] = true, + ["THAW_HIT"] = true, + ["THIEF"] = true, + ["THRASH"] = true, + ["THRASH_PETAL_DANCE_EFFECT"] = true, + ["THUNDER"] = true, + ["TICKLE"] = true, + ["TORMENT"] = true, + ["TOXIC"] = true, + ["TRANSFORM"] = true, + ["TRANSFORM_EFFECT"] = true, + ["TRAP"] = true, + ["TRAPPING_EFFECT"] = true, + ["TRICK"] = true, + ["TRIPLE_KICK"] = true, + ["TRI_ATTACK"] = true, + ["TWINEEDLE"] = true, + ["TWINEEDLE_EFFECT"] = true, + ["TWISTER"] = true, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true, + ["UPROAR"] = true, + ["VITAL_THROW"] = true, + ["WATER_SPORT"] = true, + ["WEATHER_BALL"] = true, + ["WILL_O_WISP"] = true, + ["WISH"] = true, + ["YAWN"] = true, + }, + ["variants"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23}, + ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18}, + ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68}, + ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44}, + ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10}, + ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50}, + ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26}, + ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4}, + ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4}, + ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39}, + ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49}, + ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76}, + ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30}, + ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19}, + ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69}, + ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11}, + ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51}, + ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86}, + ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3}, + ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8}, + ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24}, + ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16}, + ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7}, + ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31}, + ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31}, + ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155}, + ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47}, + ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5}, + ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5}, + ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25}, + ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32}, + ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80}, + ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45}, + ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84}, + ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35}, + ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83}, + ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82}, + ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9}, + ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46}, + ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0}, + ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38}, + ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67}, + ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6}, + ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6}, + ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34}, + ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66}, + ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2}, + ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2}, + ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81}, + ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48}, + ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65}, + ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1}, + ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87}, + ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72}, + ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13}, + ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54}, + ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20}, + ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70}, + ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52}, + ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85}, + ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79}, + ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40}, + ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27}, + ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57}, + ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42}, + ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29}, + }, +} diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index f71329141..93a258afe 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -4,6 +4,7 @@ local Merge = require("src.mods.Merge") local Schemas = require("src.mods.Schemas") local Shapes = require("src.mods.UnifiedSchemas") local Ids = require("src.mods.UnifiedIds") +local Effects = require("src.mods.UnifiedEffects") local U = {} local copy, DELETE = Merge.deepCopy, Merge.DELETE local function unsupported(reason) error({ unsupported = reason }, 0) end @@ -160,7 +161,8 @@ readRecord = function(ctx, kind, id, raw) elseif kind == "moves" then out.type = typeRead(raw.type) out.category = raw.category or raw.class - if type(raw.effect) ~= "string" then out.effect = nil end + out.effect = Effects.read(raw.effect, ctx.gen, raw) + out.secondaryChance = Effects.chance(raw, ctx.gen) elseif kind == "items" then if type(raw.effect) ~= "string" then out.effect = nil end if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end @@ -272,16 +274,29 @@ local function writeRecord(ctx, kind, id, value, old, mode) out.type = typeWrite(ctx, value.type) if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end end + local probability = value.secondaryChance if value.effect and value.effect ~= DELETE then - reference(ctx, "move_effects", value.effect) - if ctx.gen > 1 and (not old or value.effect ~= old.effect) then - -- GBA's common battle core can execute authored records directly. - -- Native builtin IDs still need their own numeric-script adapters; - -- Gen1's GBA catalog does not expose those builtins as shared content. - local authored = ctx.gen == 3 and not require("src.battle.MoveEffects").RECORDS[value.effect] - if not authored then unsupported("changing move effects requires a native effect adapter") end - if old and old.gen3Effect ~= nil then out.gen3Effect = DELETE end - out.highCrit = false + local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect) + return ctx.loader.content.move_effects:get(effect) ~= nil + end) + if not plan then unsupported(changed) end + out.effect = plan.effect + if changed then + out._api3EffectId = plan._api3EffectId + if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end + if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end + end + end + out.secondaryChance = nil + if probability ~= nil then + if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end + if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen) + and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then + -- Detached records preserve the exact original native chance byte. + else + out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5) + out._api3EffectChance = probability == DELETE and DELETE or true + if ctx.gen == 3 then out.secondaryChance = probability end end end if value.category ~= nil and old and old.class ~= nil then diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua new file mode 100644 index 000000000..2083f29a1 --- /dev/null +++ b/src/mods/UnifiedEffects.lua @@ -0,0 +1,135 @@ +-- Canonical effect IDs over this host's shared native battle handlers. +local C = require("src.mods.MoveEffectContract") +local E = {} +local numbers, nativeNames, variants = {}, {}, {} +for number in pairs(C.native3) do + numbers[#numbers + 1] = tonumber(number) +end +table.sort(numbers) +for _, number in ipairs(numbers) do + for _, name in ipairs(C.native3[tostring(number)]) do + if nativeNames[name] == nil then + nativeNames[name] = number + end + end +end +for id in pairs(C.variants) do + variants[#variants + 1] = id +end +table.sort(variants) +local function native(row, gen, chance) + if gen == 1 then + return row.native + end + local names = C.native3[tostring(row.semantic)] + if not names then + return nil + end + return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1] +end +local function opaque(value, gen) + return "native:" .. gen .. ":" .. tostring(value) +end +function E.chance(raw, gen) + if gen ~= 2 and gen ~= 3 then + return nil + end + if type(raw.effectChance) == "number" then + return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5))) + end + return gen == 3 and raw.secondaryChance or nil +end +function E.read(value, gen, raw) + if value == nil then + return nil + end + if gen == 1 or gen > 3 then + return type(value) == "string" and value or nil + end + raw = raw or {} + local probability = E.chance(raw, gen) + local authored = raw._api3EffectId + local binding = type(authored) == "string" and C.bindings[authored] + if binding and binding[tostring(gen)] then + local row = binding[tostring(gen)] + for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do + if name == value then + return authored + end + end + end + if authored == value and not C.reserved[authored] then + return authored + end + local semantic = nativeNames[value] + if gen == 3 and type(raw.gen3Effect) == "number" then + local names = C.native3[tostring(raw.gen3Effect)] + for _, name in ipairs(names or {}) do + if name == value then + semantic = raw.gen3Effect + end + end + elseif value == "HEAL_EFFECT" and raw.id == "REST" then + semantic = 37 + end + if not semantic then + return opaque(value, gen) + end + for _, id in ipairs(variants) do + local row = C.variants[id] + if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then + return id + end + end + local id = C.read3[tostring(semantic)] + if id and C.bindings[id] and C.bindings[id][tostring(gen)] then + return id + end + return opaque(value, gen) +end +function E.write(id, gen, old, registered) + if type(id) ~= "string" or id == "" then + return nil, "move effect must be a string id" + end + id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id + if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then + return { effect = old.effect }, false + end + if gen > 3 then + return nil, "changing move effects has no native adapter for this generation" + end + if id:match("^native:") then + return nil, "native effect token requires its original record" + end + local binding = C.bindings[id] + local row = binding and binding[tostring(gen)] + if not row then + if binding or C.reserved[id] or not registered(id) then + return nil, "effect has no native handler: " .. id + end + return { effect = id, _api3EffectId = gen > 1 and id or nil }, true + end + if + gen > 1 + and ( + (row.semantic == 37 and (not old or old.id ~= "REST")) + or row.semantic == 32 and old and old.id == "REST" + or row.semantic == 130 and (not old or old.id ~= "SONICBOOM") + or row.semantic == 41 and old and old.id == "SONICBOOM" + ) + then + return nil, "native effect depends on move identity: " .. id + end + local target = native(row, gen) + if not target or not registered(target) then + return nil, "effect has no native handler: " .. id + end + local out = { effect = target } + if gen > 1 then + out._api3EffectId = id + out.probability = row.chance + out.gen3Effect = gen == 3 and row.semantic or nil + end + return out, true +end +return E diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua index 10d2f6bb2..aa9a91cbe 100644 --- a/src/mods/UnifiedSchemas.lua +++ b/src/mods/UnifiedSchemas.lua @@ -34,7 +34,7 @@ local U = { } }, moves = { fields = { id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), - pp = f.int(0, 64), effect = f.opt(id), + pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)), category = f.opt(f.enum { "physical", "special", "status" }), priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), } }, diff --git a/tests/api3_contract/effects.lua b/tests/api3_contract/effects.lua new file mode 100644 index 000000000..15430e813 --- /dev/null +++ b/tests/api3_contract/effects.lua @@ -0,0 +1,13 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$") + return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = f.exports.report() + assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT") + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua new file mode 100644 index 000000000..a01378e23 --- /dev/null +++ b/tests/api3_contract/mods/effects/main.lua @@ -0,0 +1,33 @@ +local mod = ... +local moves = mod.content.moves +local report = { initial = assert(moves:get("POUND")).effect, effects = {} } +for _, id in ipairs({ + "SLEEP_EFFECT", + "BURN_SIDE_EFFECT1", + "BURN_SIDE_EFFECT2", + "SPECIAL_UP1_EFFECT", + "SPECIAL_DOWN_SIDE_EFFECT", + "NO_ADDITIONAL_EFFECT", +}) do + assert(moves:patch("POUND", { effect = id })) + local row = assert(moves:get("POUND")) + assert(row.effect == id) + report.effects[#report.effects + 1] = row.effect + if mod.generation > 1 then + assert(moves:patch("POUND", { secondaryChance = 25 })) + assert(moves:get("POUND").secondaryChance == 25) + report.chance = 25 + end + local detached = assert(moves:get("POUND")) + assert(moves:override("POUND", detached)) + assert(moves:get("POUND").effect == id) +end +local value, why = moves:patch("POUND", { effect = "native:1:not_original" }) +assert(value == nil and type(why) == "string") +assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" })) +report.final = moves:get("POUND").effect +report.preflight = true +mod.exports.report = function() + return report +end diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json new file mode 100644 index 000000000..48e0d38a3 --- /dev/null +++ b/tests/api3_contract/mods/effects/manifest.json @@ -0,0 +1 @@ +{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..5e7493d19 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,388 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version, modId) + modId = modId or "warp" + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/" .. modId then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { modId } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports[modId]) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index b7dcb960d..923eaab87 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -3,8 +3,12 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = nil local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] assert( - root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "effects"), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +if mode == "effects" then + dofile(codeRoot .. "/effects.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua new file mode 100644 index 000000000..8d2c0c6af --- /dev/null +++ b/tests/engine/unified_move_effects.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local E = require("src.mods.UnifiedEffects") +local C = require("src.mods.MoveEffectContract") +local Native = require("src.battle.MoveEffects").RECORDS +local function registered(id) + return Native[id] ~= nil +end +local accepted = 0 +for id, bindings in pairs(C.bindings) do + for g, row in pairs(bindings) do + local gen = tonumber(g) + local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" } + local plan, why = E.write(id, gen, old, registered) + if plan then + accepted = accepted + 1 + T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id) + if gen > 1 then + plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5) + end + T.eq( + E.read(plan.effect, gen, plan), + ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id, + "authored canonical effect read " .. g .. ":" .. id + ) + else + T.check(type(why) == "string", "unrepresentable mapping has reason") + end + end +end +T.check(accepted > 300, "reference matrix exercises hundreds of native bindings") +local Contract = require("tests.api3_contract.effects") +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared effect contract: " .. tostring(why)) +end +for gen = 1, 3 do + local custom = assert(E.write("mod:custom", gen, nil, function(id) + return id == "mod:custom" + end)) + T.eq(custom.effect, "mod:custom", "registered custom native effect selected") + T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity") + T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused") + T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused") + T.eq( + E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect, + "unhandled", + "original opaque identity retained" + ) +end +for _, id in ipairs({ + "FOLLOW_ME", + "HELPING_HAND", + "MAGIC_COAT", + "SNATCH", + "IMPRISON", + "ASSIST", + "NATURE_POWER", + "SECRET_POWER", +}) do + T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) +end +for _, v in ipairs({ "gold", "firered" }) do + Version.set(v) + local gen = Version.generation() + local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered)) + local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan }) + T.eq(user.stages.spatk, 1, "actual handler raises Special Attack") + T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage") + T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense") + plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered)) + plan.effectChance = 85 + Native[plan.effect].run({ battle = { + data = {}, + rng = function() + return 0 + end, + }, user = user, target = target, move = plan }) + T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense") + plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered)) + plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5) + plan.id, plan.type = "POUND", "FIRE" + local battle = { + data = {}, + rng = function() + return 25 + end, + } + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, "BRN", "native burn lands below converted threshold") + target.mon.status = nil + battle.rng = function() + return 26 + end + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, nil, "native burn misses at converted threshold") +end +for gen = 2, 3 do + local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 } + local public = E.read(old.effect, gen, old) + local plan, changed = E.write(public, gen, old, registered) + T.eq(plan.effect, old.effect, "original raw effect roundtrip") + T.eq(changed, false, "roundtrip does not rewrite original odds") + T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent") + T.eq( + E.write("NO_ADDITIONAL_EFFECT", 4, old, registered), + nil, + "later generation changing effect still unavailable" + ) + T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior") +end +-- Registry operations use the adapter before journaling; refusals are atomic. +local function load(gen, entry, odds) + Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered") + local data = require("tests.api3_contract.run").dataFor("gen2", gen) + local loader + local fs = { + read = function(path) + if path:match("manifest.json$") then + return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}' + end + end, + getInfo = function(path) + if path:match("^mods/probe/.+%.%w+$") then + return { type = "file" } + end + if path == "mods" or path == "mods/probe" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "probe" } or {} + end, + load = function() + return function(api) + entry(api, loader, data) + end + end, + } + data.moves.POUND.effect = "BURN_SIDE_EFFECT1" + data.moves.POUND.effectChance = odds or 27 + if gen == 3 then + data.moves.POUND.gen3Effect = 4 + data.moves.POUND.secondaryChance = 11 + end + loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() }) + loader._game = function() + error("content must not bootstrap Game") + end + loader:load(data) + T.eq(#loader.errors, 0, "public effect mod loads") + return data +end +for gen = 2, 3 do + local data = load(gen, function(mod, loader) + local moves = mod.content.moves + local row = assert(moves:get("POUND")) + T.eq(row.secondaryChance, 11, "public native odds projection") + assert(moves:override("POUND", row)) + T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds") + local operations = #loader.content.moves.ops.POUND + T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused") + T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation") + T.check(not pcall(function() + moves:patch("POUND", { secondaryChance = 101 }) + end), "invalid percent rejected") + T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation") + assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 })) + T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit") + assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 })) + T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained") + end) + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[data.moves.POUND.effect].run({ + battle = { + data = {}, + rng = function() + return 0 + end, + }, + user = {}, + target = target, + move = data.moves.POUND, + }) + T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default") +end +for gen = 2, 3 do + local data = load(gen, function(mod) + local moves = mod.content.moves + assert(moves:override("POUND", assert(moves:get("POUND")))) + T.eq( + moves:get("POUND").native._api3EffectChance, + nil, + "untouched zero-byte roundtrip preserves native fallback" + ) + assert(moves:patch("POUND", { secondaryChance = 0 })) + T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics") + end, 0) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 48ba0eb9b..ea9d58e89 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold"): + for mode in ("normal", "rollback", "scaffold", "effects"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua new file mode 100644 index 000000000..47f75c4a6 --- /dev/null +++ b/tools/move-effect-contract/export_reference.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local E = require("src.mods.UnifiedEffects") +local M1 = require("src.battle.g3u.EffectMap1") +local M2 = require("src.battle.g3u.EffectMap2") +local N = require("src.core.game3.battle.effect_ids") +local ids = {} +for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do + for k in pairs(t) do + ids[k] = true + end +end +local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} } +for id in pairs(ids) do + out.reserved[id] = true +end +for id, row in pairs(M1.EFFECTS) do + out.variants[id] = { semantic = row.effect, chance = row.chance } +end +for id in pairs(ids) do + local row = {} + for gen = 1, 3 do + local native, chance = E.write(id, gen) + if native then + local semantic = gen == 3 and native + or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect + or nil + if gen == 2 and native == "EFFECT_BURN" then + semantic = N.WILL_O_WISP + end + row[tostring(gen)] = + { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic } + end + end + if next(row) then + out.bindings[id] = row + end +end +for i = 0, 255 do + out.read3[tostring(i)] = E.read(i, 3, {}) +end +for id in pairs(M2.EFFECTS) do + out.read2[id] = E.read(id, 2, {}) +end +print(require("src.link.Json").encode(out)) diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py new file mode 100644 index 000000000..cb53f7e34 --- /dev/null +++ b/tools/move-effect-contract/generate.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Project the pinned reference effect contract onto host native handler names.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +ROOT = Path(__file__).resolve().parents[2] + + +def lua(v): + if v is None: + return "nil" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "{" + ",".join(lua(x) for x in v) + "}" + return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", required=True) + p.add_argument("--check", action="store_true") + a = p.parse_args() + ref = a.gen1.resolve() + def git(*args): + return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip() + if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"): + p.error("reference must be clean at " + PIN) + raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref) + data = json.loads(raw) + src = ROOT / "src/import/RomExtractorGen3.lua" + block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0] + data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row) + for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)} + data.pop("read2") + files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua", + "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"] + data["reference"] = {"commit": PIN, "sha256": { + f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}} + data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest() + lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"] + for group, rows in sorted(data.items()): + lines.append(" [" + lua(group) + "] = {") + for key, value in sorted(rows.items() if isinstance(rows, dict) else []): + lines.append(" [" + lua(key) + "] = " + lua(value) + ",") + if not isinstance(rows, dict): + lines[-1] = " [" + lua(group) + "] = " + lua(rows) + "," + else: + lines.append(" },") + lines.append("}") + text = "\n".join(lines) + "\n" + target = ROOT / "src/mods/MoveEffectContract.lua" + if a.check: + if target.read_text() != text: + raise SystemExit("effect contract differs; regenerate") + print("PASS pinned effect bindings and host native-name map") + else: + target.write_text(text) + print("Wrote", target) + + +if __name__ == "__main__": + main() From a7988f2c31e79a97a833aece6e12015dc86bca8a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:53:41 +0100 Subject: [PATCH 71/90] Reuse current effect-contract fixtures with the native command stack --- tests/api3_contract/run.lua | 1 + tests/engine/unified_move_effects.lua | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua index 0024a01d9..c9fe68f51 100644 --- a/tests/api3_contract/run.lua +++ b/tests/api3_contract/run.lua @@ -253,4 +253,5 @@ function M.run(root, backend, version, failing, scaffold) print("API3_CONTRACT " .. Json.encode(result)) return result end +M.dataFor = dataFor return M diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua index 8d2c0c6af..0130bae06 100644 --- a/tests/engine/unified_move_effects.lua +++ b/tests/engine/unified_move_effects.lua @@ -52,8 +52,6 @@ for gen = 1, 3 do ) end for _, id in ipairs({ - "FOLLOW_ME", - "HELPING_HAND", "MAGIC_COAT", "SNATCH", "IMPRISON", @@ -63,6 +61,12 @@ for _, id in ipairs({ }) do T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) end +local helpingHand = assert(E.write("HELPING_HAND", 3, nil, registered)) +T.eq(helpingHand.effect, "HELPING_HAND_EFFECT", "canonical Helping Hand selects the native handler") +T.eq(helpingHand.gen3Effect, 176, "canonical Helping Hand keeps native semantic number") +local followMe = assert(E.write("FOLLOW_ME", 3, nil, registered)) +T.eq(followMe.effect, "FOLLOW_ME_EFFECT", "canonical Follow Me selects newly implemented native handler") +T.eq(followMe.gen3Effect, 172, "canonical Follow Me keeps native effect number") for _, v in ipairs({ "gold", "firered" }) do Version.set(v) local gen = Version.generation() From cb6ddfadc2d29b878efcb0eb4f76869dc7b8c16a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:05:20 +0100 Subject: [PATCH 72/90] Skip the native move-end script when Sleep Talk cannot call a move --- src/battle/Gen3SleepTalk.lua | 13 ++++++-- tests/engine/gen3_sleep_talk_composition.lua | 33 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index 49960b103..a7f86c882 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -67,10 +67,19 @@ function S.status(id, base) statusCache[base] = out return out end +local function noCall(ctx) + -- Native call_moves returns without the move-end script on these failures. + -- The outer action still performs its separate after-action item sweep. + local frame = ctx.battle._gbaMoveFrame + if frame then + frame.finished = true + end + ctx.say(Strings("But, it failed!")) +end function S.pick(ctx) local user = ctx.user if user.mon.status ~= "SLP" then - ctx.say(Strings("But, it failed!")) + noCall(ctx) return end local pool = {} @@ -92,7 +101,7 @@ function S.pick(ctx) end end if #pool == 0 then - ctx.say(Strings("But, it failed!")) + noCall(ctx) return end return pool[ctx.rng(1, #pool)] diff --git a/tests/engine/gen3_sleep_talk_composition.lua b/tests/engine/gen3_sleep_talk_composition.lua index 005d6d15d..b41feaa6c 100644 --- a/tests/engine/gen3_sleep_talk_composition.lua +++ b/tests/engine/gen3_sleep_talk_composition.lua @@ -1,7 +1,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") for _, version in ipairs({ "firered", "emerald" }) do - for _, mode in ipairs({ "delegate", "primary_delegate", "override", "status_override", "fallback" }) do + for _, mode in ipairs({ + "delegate", + "primary_delegate", + "override", + "status_override", + "fallback", + "awake", + "empty", + }) do local entry if mode ~= "fallback" then entry = [[return function(mod) @@ -33,10 +41,22 @@ for _, version in ipairs({ "firered", "emerald" }) do c.user.mon.moves = { { id = f.ids.SLEEP_TALK, pp = 10 }, { id = f.ids.TACKLE, pp = 0 }, { id = f.ids.SCRATCH, pp = 10 } } c.user.curMoves = c.user.mon.moves + if mode == "awake" then + c.user.mon.status, c.user.sleepTurns = nil, nil + end + if mode == "empty" then + c.user.curMoves[2].id, c.user.curMoves[3].id = f.ids.METRONOME, f.ids.MIRROR_MOVE + end + c.user.lastMove = f.ids.SCRATCH local originalEffects, originalStatuses = b.data.move_effects, b.data.statuses if mode == "fallback" then b.data.move_effects, b.data.statuses = nil, nil end + local reads = {} + local offReads = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads[#reads + 1] = { trigger = ctx.trigger, parameter = ctx.parameter, item = ctx.item } + return nextRead(ctx) + end) local used = {} local off = f.e.api.events:on("battle.move_used", function(e) used[#used + 1] = { move = e.move.id, target = e.target.mon.nickname, called = e.isCalled or false } @@ -48,7 +68,15 @@ for _, version in ipairs({ "firered", "emerald" }) do return hi end b:executeAction(c.user, c.user, c.user.curMoves[1]) - if mode == "status_override" then + if mode == "awake" or mode == "empty" then + T.eq(#used, 1, "failed Sleep Talk emits only its caller event") + T.eq(c.user.curMoves[1].pp, 9, "failed executed caller still spends its PP") + T.eq(c.user.lastMove, f.ids.SCRATCH, "failed caller preserves previous move bookkeeping") + T.eq(#reads, 2, "failed caller performs only the separate after-action item reads") + for _, read in ipairs(reads) do + T.eq(read.trigger, "residual", "no failed-call move-end sweep is inserted") + end + elseif mode == "status_override" then T.eq(#used, 0, "authored sleep interruption owns cancellation") T.eq(f.e.calls, 0, "cancelled caller never selects a nested move") T.eq(c.user.curMoves[1].pp, 10, "authored cancellation spends no PP") @@ -96,6 +124,7 @@ for _, version in ipairs({ "firered", "emerald" }) do ) T.same(f.result.loader.errors, {}, "record callbacks complete without swallowed runtime errors") off() + offReads() b.data.move_effects, b.data.statuses = originalEffects, originalStatuses f.close() end From 491d3a2f0b997f8f5c801ebcd12524e70ba5cf54 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:16:23 +0100 Subject: [PATCH 73/90] Document native Sleep Talk acceptance, prerequisites and remaining hook gaps --- docs/api3-portability.md | 5 +- docs/native-gen3-sleep-talk.md | 134 +++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen3-sleep-talk.md diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bcff38735..c61cc6019 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -126,8 +126,9 @@ process. The shared harness constructs native-shaped synthetic datasets; the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds -eight comparisons to the sixteen normal/rollback cases: 24 comparisons and -48 isolated loader runs. It compares JSON projections of public results and +eight comparisons to the sixteen normal/rollback cases. This native-command +stack also includes eight effect cases: 32 configured comparisons and 64 +isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/native-gen3-sleep-talk.md b/docs/native-gen3-sleep-talk.md new file mode 100644 index 000000000..64a69ced9 --- /dev/null +++ b/docs/native-gen3-sleep-talk.md @@ -0,0 +1,134 @@ +# Native GBA Sleep Talk and authored move calls + +The shared core previously cancelled Sleep Talk at the ordinary sleep check. +Its legacy effect also selected forbidden calls, reused the caller's target +and remembered the resulting move instead of the selected caller. + +Install GBA sleep and Sleep Talk adapters per dataset before mods capture +builtin handlers. Sleeping move numbers 214/173 can proceed, native wake lets +an ordinary move act, Early Bird decrements twice, and Uproar respects +Soundproof across all active slots. Keep the existing fork's party-side +remaining sleep counter synchronized. Other generations retain their current +status behavior and shared builtin statics are unchanged. + +Sleep Talk reads the current first four slots, including zero-PP choices. +Exclude the native move-number blacklist, two-turn scripts, Disable and active +Encore restrictions. Fixed-number sleep admission remains native: a custom +move with effect 97 does not automatically become move 214. Named authored +moves need no imported numeric index. Dispatch the resulting move as called, +retarget user/foe/spread calls and use controller slot order for all-other- +battlers attacks. Only the caller spends PP. Native bookkeeping remembers the +caller; failed calls preserve the previous move and skip the move-end script +while retaining the separate after-action item sweep. + +Stacks on #226 and reuses the existing [#121 effect adapter](https://github.com/UNDERdecoded/Gen2Recomped/pull/121). +The adapter was absent from this isolated native-command stack and is already +in the combined branch. Reuse its production implementation unchanged; adapt +its harness to this stack's available modes and reuse the current Follow Me/ +Helping Hand assertions. A separate clean #226+#121 prerequisite is tested. +The abandoned narrow adapter is superseded by #121 in the final tree. + +## Accepted evidence + +Feature runtime `cb6ddfadc2d29b878efcb0eb4f76869dc7b8c16a` and combined +runtime `4941d8d6c753925ab47180aaa80bf37b665ff7b6` are clean, tested commits. +Later commits document acceptance/publication. + +- **1,068 focused checks** pass: 846 command/public-authored-content, 162 + effect/status/composition/failed-call/legacy and 60 real link/compatibility. + The 154-check native protocol suite and the reused adapter's 860 host checks + also pass. +- **116 workers / 77,668 paired observations** agree with unchanged read-only + Gen1 `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new modes add **648 + command rows and five public authored-content rows per FireRed/Emerald + profile**. Command cases cover singles/doubles/all actor slots, choices, + wake/Early Bird/Uproar/Soundproof, exclusions, edited forbidden metadata, + current slots, zero PP, Disable/Encore, called execution, multi-hit/Triple + Kick/Snore/Recover/Rage and spread calls. These are controlled native-shaped + inputs rather than cartridge copies. +- The identical API-3 authored entry registers a named move without an index + and its own effect through both public loaders. Ordinary, zero-PP, awake, + primary/stat-change and patched-Fly cases execute the actual registered + handlers. The source fixture mounts the loader's merged authored row/name + and binds its actual merged registries; it does not recreate the handler or + formula. Mounts are fixture-owned and restored in memory, with no Gen1 file + changes. Hook/event failures must also leave both loaders' error lists empty. +- Every bounded RNG call/value, selected public move/hit event and all recorded + battler HP/PP/status/active sleep/last-move/stage/Substitute/Rage observations + remain in those comparisons. No failed rows or RNG draws are filtered out. +- Full feature: **130/139 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines across nine suites** equals clean prerequisite + `63bf2a9b9b856027c27c65ae1031febbb9e5d7e5` (**127/136**). Its full command's + nonzero exit remains retained. Independent baseline repairs are in the + combined runtime: **249/249 engine suites**, SDK **3/3**, B30, the same full + comparison and imported tests pass there. +- Unchanged prerequisite fails **108 assertions** and all **648/648** complete + FireRed command rows differ. Its authored-content mode fails **eight + assertions**, with **4/5** rows differing. The same final fixtures are used. +- Two real link roles complete four turns: sleeping Sleep Talk, blocked + ordinary move, awake Sleep Talk failure and independently selected ordinary + move. Every paired completed hash/component, sleep state and learned PP + agrees. Command revision **9** rejects revision 8 before party exchange; + party codec stays **1**. Raw fixture `b9ec4e8a94f4ba6e` becomes + `f1bec52ffc687ea3`, independently audited with the unchanged #226 hasher and + only the explicit revision token changed. Other-generation goldens stay. +- **Twelve CPU-only scenarios with actual local FireRed/Emerald imports** pass + public move/menu submission and completed turns: three calls followed by an + ordinary move on waking, zero-PP children, empty/forbidden pools, Early Bird + wake and awake failure. Extracted attack metadata stays intact. Native + Sleep Talk's DEPENDS target is observed rather than assuming a self target; + assertions run outside callbacks and loader errors are checked explicitly. + No ROM/cache/save assets are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +ROM-free reproduction with maintained LuaJIT +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk.lua +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk_link.lua +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/sleep-talk-contracts.json +``` + +Optional imported-data check (root contains `firered/data/generated` and +`emerald/data/generated`): + +```sh +nice -n 19 "$LUA" tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua \ + /path/to/local/Gen2Recomp +``` + +Feature evidence is in `tmp/sleep-talk-qa/accepted-evidence.json`, +`full-final-accepted.log`, `contracts-final-accepted.json` and +`imported-final-accepted.log`. Combined evidence uses +`tmp/sleep-talk-accepted-evidence.json`, `tmp/sleep-talk-full-final-accepted.log`, +`tmp/sleep-talk-contracts-final-accepted.json` and +`tmp/sleep-talk-imported-final-accepted.log`. Prerequisite evidence is retained +in the clean baseline worktree's `tmp/sleep-talk-baseline-qa/`. +Initial fixture errors, unmatched spread order, missing prerequisite probes, +the invalid unmerged integration run and all negative reports remain retained. + +## Remaining boundaries + +An extra **complete `held_item.trigger` probe still differs**. Raw source +`tmp/sleep-talk-qa/phase-gen1.log` and final host `phase-gen2-fixed.log` retain +all observed fields/reads. Empty source items carry numeric zero while the +host omits that field. A valid called attack has missing/extra check/damage/ +flinch reads (10 source reads, 11 host reads). Failed awake/empty calls now have +two reads on each engine, and blocked ordinary actions have four, but the +complete payloads still differ. These are unresolved findings, not passing +held-item-trace evidence; the accepted matrix above covers its stated scope. + +Complete held-item/choice-item and attack-canceller orchestration (including +Truant, freeze and recharge), arbitrary child-controller/interactive timing, +Ruby/Sapphire scheduling, full catalogs, global RNG/selection, graphics and +full playthrough parity remain separate work. Host builtin capture/delegation +and party-counter preservation are scoped host checks. The native Gen1 GBA +catalog still does not expose those builtin records for capture. The older +general API-3 checker's historical Gen1 pin is not new evidence against the +unchanged reference used here. No Gen1 development or publication is required +or performed. Hosted CI is recorded separately on the PR. From 0ce22b5af2c2a231d7aed36751126688a54fad3e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:42:53 +0100 Subject: [PATCH 74/90] Align GBA held damage command reads and pre-stage item modifiers --- .../workflows/gba-damage-base-contract.yml | 6 +- src/battle/Damage.lua | 39 ++-- src/battle/Gen3DamageStats.lua | 54 +++++ src/battle/HeldItemRead.lua | 1 + src/link/Fingerprint.lua | 2 +- src/mods/CallbackRecords.lua | 2 + tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_held_damage.lua | 5 + tests/engine/gen3_held_damage_link.lua | 98 +++++++++ tests/held_item/damage.lua | 187 ++++++++++++++++++ tests/held_item/damage_worker.lua | 5 + tools/held-damage-contract-check/check.py | 65 ++++++ 12 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/battle/Gen3DamageStats.lua create mode 100644 tests/engine/gen3_held_damage.lua create mode 100644 tests/engine/gen3_held_damage_link.lua create mode 100644 tests/held_item/damage.lua create mode 100644 tests/held_item/damage_worker.lua create mode 100644 tools/held-damage-contract-check/check.py diff --git a/.github/workflows/gba-damage-base-contract.yml b/.github/workflows/gba-damage-base-contract.yml index 75fe6e0f1..33febed1a 100644 --- a/.github/workflows/gba-damage-base-contract.yml +++ b/.github/workflows/gba-damage-base-contract.yml @@ -21,11 +21,15 @@ jobs: - name: Install runtime run: sudo apt-get update && sudo apt-get install -y luajit - name: Check GBA damage and older-profile retention - run: luajit tests/engine/gba_damage_base.lua + run: | + luajit tests/engine/gba_damage_base.lua + luajit tests/engine/gen3_held_damage.lua + luajit tests/engine/gen3_held_damage_link.lua - name: Compare actual ordinary damage calculators run: | mkdir -p /tmp/gba-damage-base python3 tools/gba-damage-base-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/records.json + python3 tools/held-damage-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/held-records.json - uses: actions/upload-artifact@v6 if: always() with: diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index e0bebdc16..aa80b3ccc 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -295,18 +295,14 @@ function Damage.compute(ruleset, attacker, defender, move, opts) local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" - -- GBA set-damage effects use the native base command without a critical - -- roll, variance or battle.damage hook. Its stat modifiers precede stages. + -- The GBA base command resolves both held effects once. Its stat modifiers + -- precede stages for ordinary attacks and scripted set-damage alike. local rawAtk, rawDef = attacker.curStats[atkStat], defender.curStats[defStat] - if opts.gbaSetDamage then + if gbaBase then local ability = Abilities.of(attacker) if not special and (ability == "HUGE_POWER" or ability == "PURE_POWER") then rawAtk = rawAtk * 2 end - local tn, td = HoldItems.damageMultiplier(attacker, move) - rawAtk = math.floor(rawAtk * tn / td) - local an, ad = HoldItems.attackMultiplier(attacker, move, special) - rawAtk = math.floor(rawAtk * an / ad) - local dn, dd = HoldItems.defenceMultiplier(defender, move, special) - rawDef = math.floor(rawDef * dn / dd) + rawAtk, rawDef = require("src.battle.Gen3DamageStats").held( + attacker, defender, move, special, rawAtk, rawDef, opts.battle) local kind = Abilities.normalizeType(move.type) if special and Abilities.of(defender) == "THICK_FAT" and (kind == "FIRE" or kind == "ICE") then rawAtk = math.floor(rawAtk / 2) @@ -326,7 +322,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) end local applyStage = Stats.applyStage - if opts.gbaSetDamage then + if gbaBase then applyStage = function(value, stage) stage = math.max(-6, math.min(6, stage or 0)) if stage >= 0 then return math.floor(value * (2 + stage) / 2) end @@ -338,8 +334,12 @@ function Damage.compute(ruleset, attacker, defender, move, opts) atk = attacker.curStats[atkStat] dfn = defender.curStats[defStat] else - atk = applyStage(rawAtk, stageOf(attacker, atkStat)) - dfn = applyStage(rawDef, stageOf(defender, defStat)) + local attackStage, defenseStage = stageOf(attacker, atkStat), stageOf(defender, defStat) + if gbaBase and crit then + attackStage, defenseStage = math.max(0, attackStage), math.min(0, defenseStage) + end + atk = applyStage(rawAtk, attackStage) + dfn = applyStage(rawDef, defenseStage) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special @@ -417,7 +417,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- the defence and THICK FAT halves what a FIRE or ICE move gets to work -- with. A Gen 1 or Gen 2 Pokemon has no ability at all and every one of -- these answers 1/1, so nothing below this line changes for them. - if not opts.gbaSetDamage then + if not gbaBase then local an, ad = Abilities.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = Abilities.defenceMultiplier(defender, move, special) @@ -428,7 +428,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- species-locked doublings -- THICK CLUB, LIGHT BALL, METAL POWDER, the -- two DEEP SEA halves and SOUL DEW. A battler with no `items` view (every -- Gen 1 and Gen 2 one) answers 1/1 here too. - if not opts.gbaSetDamage then + if not gbaBase then local an, ad = HoldItems.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = HoldItems.defenceMultiplier(defender, move, special) @@ -463,9 +463,13 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if opts.powerMultiplier and opts.powerMultiplier ~= 1 then power = math.max(1, math.floor(power * opts.powerMultiplier)) end - if opts.gbaSetDamage then + if gbaBase then local pn, pd = Abilities.damageMultiplier(attacker, move) power = math.floor(power * pn / pd) + local kind = Abilities.normalizeType(move.type) + for _, muted in pairs(opts.sports or {}) do + if muted == kind then power = math.floor(power / 2) end + end end d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50) @@ -502,7 +506,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d / 2) end end - if opts.gbaSetDamage and special then + if special then local fn, fd = Abilities.flashFireBoost(attacker, move) d = math.floor(d * fn / fd) end @@ -533,6 +537,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if crit and ruleset.critMultiplier and ruleset.critBeforeType then d = math.floor(d * ruleset.critMultiplier) end + if gbaBase and attacker.charged and Abilities.normalizeType(move.type) == "ELECTRIC" then d = d * 2 end -- Native Helping Hand scales the calculated base after the critical -- multiplier, before STAB/type rounding. Fixed damage and confusion's @@ -600,7 +605,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- a Pokemon's own type hits half again as hard. Applied here because -- the cartridge applies it to the finished damage rather than the stat, -- which is not the same number once the floors are counted. - if not opts.gbaSetDamage then + if not gbaBase then local pn, pd = Abilities.damageMultiplier(attacker, move) if pn ~= 1 or pd ~= 1 then d = math.floor(d * pn / pd) end -- FLASH FIRE, once something has lit it: every FIRE move this Pokemon diff --git a/src/battle/Gen3DamageStats.lua b/src/battle/Gen3DamageStats.lua new file mode 100644 index 000000000..273342938 --- /dev/null +++ b/src/battle/Gen3DamageStats.lua @@ -0,0 +1,54 @@ +-- CalculateBaseDamage reads each holder once, then reuses those answers for +-- type and species/stat items. Never retain an answer across native commands. +local HoldItems = require("src.battle.HoldItems") +local D = {} +local function species(battler, battle) + local id = battler.species or battler.mon.species + local def = battle and battle.data and battle.data.pokemon and battle.data.pokemon[id] + return tonumber(id) or tonumber(def and (def.index or def.dex)), id +end +local function isSpecies(battler, battle, numbers, names) + local number, name = species(battler, battle) + return numbers[number] or names[name] +end +function D.held(attacker, defender, move, special, attack, defense, battle) + local aEffect, parameter = HoldItems.effectOf(attacker, "damage") + local dEffect = HoldItems.effectOf(defender, "damage") + local kind = require("src.battle.Abilities").normalizeType(move.type) + if HoldItems.TYPE_POWER[aEffect] == kind then + attack = math.floor(attack * (100 + (parameter or 0)) / 100) + end + if not special then + if aEffect == "CHOICE_BAND" then + attack = math.floor(attack * 3 / 2) + end + if + aEffect == "THICK_CLUB" + and isSpecies(attacker, battle, { [104] = true, [105] = true }, { CUBONE = true, MAROWAK = true }) + then + attack = attack * 2 + end + if dEffect == "METAL_POWDER" and isSpecies(defender, battle, { [132] = true }, { DITTO = true }) then + defense = defense * 2 + end + else + local dewNumbers, dewNames = { [407] = true, [408] = true }, { LATIAS = true, LATIOS = true } + if aEffect == "SOUL_DEW" and isSpecies(attacker, battle, dewNumbers, dewNames) then + attack = math.floor(attack * 3 / 2) + end + if dEffect == "SOUL_DEW" and isSpecies(defender, battle, dewNumbers, dewNames) then + defense = math.floor(defense * 3 / 2) + end + if aEffect == "DEEP_SEA_TOOTH" and isSpecies(attacker, battle, { [373] = true }, { CLAMPERL = true }) then + attack = attack * 2 + end + if dEffect == "DEEP_SEA_SCALE" and isSpecies(defender, battle, { [373] = true }, { CLAMPERL = true }) then + defense = defense * 2 + end + if aEffect == "LIGHT_BALL" and isSpecies(attacker, battle, { [25] = true }, { PIKACHU = true }) then + attack = attack * 2 + end + end + return attack, defense +end +return D diff --git a/src/battle/HeldItemRead.lua b/src/battle/HeldItemRead.lua index 4d4bf74fc..e75a3893d 100644 --- a/src/battle/HeldItemRead.lua +++ b/src/battle/HeldItemRead.lua @@ -134,6 +134,7 @@ function M.read(holder, def, effect, parameter, trigger, generation) value = math.floor(value * 256 / 100) end local item = holder.mon.item or holder.mon.heldItem + if generation == 3 and item == nil then item = 0 end local view if def then view = {} diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 13dae168c..3d84d3b45 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 9 or nil + return require("src.core.GameVersion").isGen3() and 10 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index b7a808c20..c268198b2 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -342,6 +342,8 @@ function R.new(loader, name, generation, api3) out[k] = read("moves", v) elseif kind == "action" and k == "id" and not raw.special then out[k] = read("moves", v) + elseif name == "held_item.trigger" and generation == 3 and kind == "context" and k == "item" and v == 0 then + out[k] = 0 -- Native empty-hand sentinel; it is not a content item ID. elseif idRoles[k] and type(v) ~= "table" then out[k] = read(idRoles[k], v) elseif role and type(v) == "table" then diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index ee0583886..fdc695275 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash audited against #214; native hash is #226 with command revision 9. +-- Legacy hash retained; native hash independently audits revision 9 -> 10 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "f1bec52ffc687ea3" +local NATIVE = "3a2adb7fa1caee3b" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 9 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 10 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_held_damage.lua b/tests/engine/gen3_held_damage.lua new file mode 100644 index 000000000..2b4e2185b --- /dev/null +++ b/tests/engine/gen3_held_damage.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/held_item/damage.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA held damage command") diff --git a/tests/engine/gen3_held_damage_link.lua b/tests/engine/gen3_held_damage_link.lua new file mode 100644 index 000000000..8d474cc3e --- /dev/null +++ b/tests/engine/gen3_held_damage_link.lua @@ -0,0 +1,98 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.held_item.fixture").new(".", "gen2", version, 3, "unified") + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 9 + T.eq(current.nativeBattleRevision, 10, "native held damage advertises revised commands") + T.eq(H.checkCompat(current, older), "subset", "old held commands cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "revised held commands reject symmetrically") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller refuses before party exchange") + T.eq(state.noticeExits, true, "revision mismatch cannot start a battle") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for i, game in ipairs({ ga, gb }) do + local mon = game.save.party[1] + mon.nickname = i == 1 and "HOST" or "GUEST" + mon.moves = { { id = f.ids.TACKLE, pp = 10 } } + end + -- The same public override is active in both simulations. It must be read + -- afresh for each base command, never separately by type/stat consumers. + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" then + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { holder = ctx.mon.nickname, item = ctx.item, parameter = ctx.parameter } + if ctx.mon.nickname == "HOST" then + return "HELD_NORMAL_BOOST", 13 + end + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + for _, battle in ipairs({ a, b }) do + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.curStats.attack, actor.curStats.defense, actor.curTypes = 51, 51, { "NORMAL" } + actor.stages.attack, actor.stages.defense = 1, -1 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + battle.moveIndex = 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both menu-driven turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "every active/volatile/bench component agrees") + end + T.same(seen[a], seen[b], "both real link roles see the same ordered held damage reads") + T.eq(#(seen[a] or {}), 8, "two reads per attack across four actual attacks") + for _, battle in ipairs({ a, b }) do + T.eq(battle.player.mon.moves[1].pp, 8, "two public menu selections spend real learned PP") + T.check(battle.player.mon.hp < 1000 and battle.enemy.mon.hp < 1000, "real attacks damage both battlers") + T.eq(battle.result, nil, "no hidden link or choice failure ends the mirror") + end + T.eq(#f.result.errors, 0, "all real callback chains remain error-free") + f.close() +end +T.finish("native held damage real link menus and command compatibility") diff --git a/tests/held_item/damage.lua b/tests/held_item/damage.lua new file mode 100644 index 000000000..cc749032f --- /dev/null +++ b/tests/held_item/damage.lua @@ -0,0 +1,187 @@ +-- Shared inputs; execute each engine's actual calculator and public hook chain. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/held_item/fixture.lua").new(root, backend, version, 3, "unified") + local T, rows = f.T, {} + local scenarios = { + { name = "empty" }, + { name = "normal-boost", item = "NORMAL_POWER" }, + { name = "choice-band", item = "CHOICE_BAND" }, + { name = "thick-club", item = "THICK_CLUB", species = 104 }, + { name = "thick-club-wrong-species", item = "THICK_CLUB" }, + { name = "metal-powder", targetItem = "METAL_POWDER", targetSpecies = 132 }, + { name = "metal-powder-wrong-species", targetItem = "METAL_POWDER" }, + { name = "fire-boost", item = "FIRE_POWER", special = true }, + { name = "light-ball", item = "LIGHT_BALL", species = 25, special = true }, + { name = "light-ball-wrong-category", item = "LIGHT_BALL", species = 25 }, + { name = "deep-sea-tooth", item = "DEEP_SEA_TOOTH", species = 373, special = true }, + { name = "deep-sea-scale", targetItem = "DEEP_SEA_SCALE", targetSpecies = 373, special = true }, + { + name = "soul-dew-both", + item = "SOUL_DEW", + targetItem = "SOUL_DEW", + species = 407, + targetSpecies = 408, + special = true, + }, + { name = "override-empty", effect = "HELD_NORMAL_BOOST", parameter = 13 }, + { name = "override-choice", effect = "HELD_CHOICE_BAND" }, + { name = "suppress-choice", item = "CHOICE_BAND", suppress = true }, + { name = "stateful-read", effect = "HELD_NORMAL_BOOST", parameter = 13, stateful = true }, + { name = "huge-power-club", item = "THICK_CLUB", species = 104, ability = "HUGE_POWER" }, + { name = "pure-power-band", item = "CHOICE_BAND", ability = "PURE_POWER" }, + { name = "hustle-band", item = "CHOICE_BAND", ability = "HUSTLE" }, + { name = "guts-burn-band", item = "CHOICE_BAND", ability = "GUTS", status = "BRN" }, + { name = "burn-band", item = "CHOICE_BAND", status = "BRN" }, + { + name = "marvel-scale", + targetItem = "METAL_POWDER", + targetSpecies = 132, + targetAbility = "MARVEL_SCALE", + targetStatus = "BRN", + }, + { name = "thick-fat-boost", item = "FIRE_POWER", special = true, targetAbility = "THICK_FAT" }, + { name = "blaze-boost", item = "FIRE_POWER", special = true, ability = "BLAZE", pinch = true }, + { + name = "plus-light-ball", + item = "LIGHT_BALL", + species = 25, + special = true, + ability = "PLUS", + targetAbility = "MINUS", + }, + { name = "rain-fire", item = "FIRE_POWER", special = true, weather = "RAIN" }, + { name = "sun-fire", item = "FIRE_POWER", special = true, weather = "SUN" }, + { name = "water-sport", item = "FIRE_POWER", special = true, sport = true }, + { name = "screen-band", item = "CHOICE_BAND", screen = true }, + { name = "screen-fire", item = "FIRE_POWER", special = true, screen = true }, + } + local function item(effect) + for _, row in ipairs(f.catalog) do + if row[3] == effect then + return row[1] + end + end + assert(effect == nil, "fixture item missing: " .. tostring(effect)) + end + for _, scenario in ipairs(scenarios) do + for _, stat in ipairs({ 17, 51, 257 }) do + for _, stage in ipairs({ -3, -1, 0, 1, 3 }) do + for _, crit in ipairs({ false, true }) do + local b = f.reset(item(scenario.item)) + f.hold("enemy", item(scenario.targetItem)) + local user, target = b.player, b.enemy + user.mon.status, target.mon.status = scenario.status, scenario.targetStatus + if scenario.pinch then + user.mon.hp = 100 + end + if native then + user.type1, user.type2, target.type1, target.type2 = 0, 0, 0, 0 + user.expTracedAbility, target.expTracedAbility = scenario.ability, scenario.targetAbility + else + user.curTypes, target.curTypes = { "NORMAL" }, { "NORMAL" } + user.abilityOverride, target.abilityOverride = scenario.ability, scenario.targetAbility + end + local attackStat, defenseStat = + scenario.special and "spAtk" or "attack", scenario.special and "spDef" or "defense" + if native then + user.mon[attackStat], target.mon[defenseStat] = stat, 51 + user.stages[attackStat], target.stages[defenseStat] = stage, -stage + else + attackStat, defenseStat = attackStat:lower(), defenseStat:lower() + user.curStats[attackStat], target.curStats[defenseStat] = stat, 51 + user.stages[attackStat], target.stages[defenseStat] = stage, -stage + end + user.species, target.species = scenario.species or 4, scenario.targetSpecies or 4 + local trace, reads, draws = {}, 0, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + definition = ctx.def and ctx.def.id, + actualBattle = ctx.battle == b, + } + if ctx.mon.nickname == "PLAYER" then + reads = reads + 1 + if scenario.suppress then + return nil, 0 + end + if scenario.effect then + if scenario.stateful and reads % 2 == 0 then + return "HELD_CHOICE_BAND", 0 + end + return scenario.effect, scenario.parameter or 0 + end + end + return nextRead(ctx) + end) + local function rng(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local function calculate() + if native then + return require("src.core.game3.battle.damage").calc( + user, + target, + { id = "TACKLE", numId = 33, power = 35, type = scenario.special and 10 or 0, effect = 0 }, + { + forceCrit = crit, + rng = rng, + adapter = require("src.core.game3.battle.adapter").new(b), + st = b, + weather = scenario.weather, + waterSport = scenario.sport, + reflect = scenario.screen, + lightScreen = scenario.screen, + } + ) + else + return b:computeDamage(user, target, { + id = f.ids.TACKLE, + index = 33, + power = 35, + type = scenario.special and "FIRE" or "NORMAL", + effect = "NO_ADDITIONAL_EFFECT", + gen3Effect = 0, + }, { + forceCrit = crit, + rng = rng, + weather = scenario.weather, + sports = scenario.sport and { "FIRE" } or nil, + screens = scenario.screen and { reflect = true, lightScreen = true } or nil, + }) + end + end + local damage = calculate() + local repeated = scenario.stateful and calculate() or nil + off() + T.eq(#trace, scenario.stateful and 4 or 2, "one held read per actor in each GBA base command") + T.eq(reads, scenario.stateful and 2 or 1, "answers expire before the next native command") + T.eq(trace[1] and trace[1].holder, "PLAYER", "attacker held read first") + T.eq(trace[2] and trace[2].holder, "ENEMY", "defender held read second") + T.eq(#f.result.errors, 0, "public callback errors remain empty") + rows[#rows + 1] = { + case = scenario.name, + stat = stat, + stage = stage, + crit = crit, + damage = damage, + repeated = repeated, + trace = trace, + draws = draws, + userHp = user.mon.hp, + targetHp = target.mon.hp, + } + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/held_item/damage_worker.lua b/tests/held_item/damage_worker.lua new file mode 100644 index 000000000..2676c9626 --- /dev/null +++ b/tests/held_item/damage_worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/held_item/damage.lua").run(root, backend, version) +print("HELD_DAMAGE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA held damage command") diff --git a/tools/held-damage-contract-check/check.py b/tools/held-damage-contract-check/check.py new file mode 100644 index 000000000..b3a1a6894 --- /dev/null +++ b/tools/held-damage-contract-check/check.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Compare complete held-effect command reads and calculated damage, without ROMs.""" +import argparse +import hashlib +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(): + 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("--report", type=Path, required=True) + 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 = [host / "tests/held_item" / name for name in ("fixture.lua", "damage.lua", "damage_worker.lua")] + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "runs": [], "differences": [], + "fixture_sha256": {str(p.relative_to(host)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in paths}} + failed = False + for version in ("firered", "emerald"): + observations = [] + for backend, engine in (("gen1", source), ("gen2", host)): + run = subprocess.run([args.lua, str(host / "tests/held_item/damage_worker.lua"), str(host), + backend, version], cwd=engine, capture_output=True, text=True, timeout=60) + lines = [line.removeprefix("HELD_DAMAGE ") for line in run.stdout.splitlines() + if line.startswith("HELD_DAMAGE ")] + value = json.loads(lines[0]) if len(lines) == 1 else None + failed |= run.returncode != 0 or value is None + observations.append(value) + report["runs"].append({"backend": backend, "version": version, "exit": run.returncode, + "stdout": run.stdout, "stderr": run.stderr, "observations": value}) + if all(value is not None for value in observations): + left, right = observations + if len(left) != len(right): + report["differences"].append({"version": version, "error": "row count"}) + for index, (reference, host_value) in enumerate(zip(left, right)): + if reference != host_value: + report["differences"].append({"version": version, "row": index, + "reference": reference, "host": host_value}) + equal = observations[0] is not None and observations[0] == observations[1] + failed |= not equal + print("PASS" if equal else "FAIL", version) + if git(source, "status", "--porcelain"): + raise RuntimeError("reference changed") + report["passed"] = not failed + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) From 5f8a96751c0f9c70e27b918c2ffe588bc4776181 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:49:57 +0100 Subject: [PATCH 75/90] Keep set-damage sport gating and exercise actual GBA link profiles --- src/battle/Damage.lua | 6 ++++-- tests/engine/gen3_held_damage_link.lua | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index aa80b3ccc..bbeab35cd 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -467,7 +467,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) local pn, pd = Abilities.damageMultiplier(attacker, move) power = math.floor(power * pn / pd) local kind = Abilities.normalizeType(move.type) - for _, muted in pairs(opts.sports or {}) do + -- Spit Up's set-damage opcode does not forward the field sport flags. + for _, muted in pairs(not opts.gbaSetDamage and opts.sports or {}) do if muted == kind then power = math.floor(power / 2) end end end @@ -537,7 +538,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if crit and ruleset.critMultiplier and ruleset.critBeforeType then d = math.floor(d * ruleset.critMultiplier) end - if gbaBase and attacker.charged and Abilities.normalizeType(move.type) == "ELECTRIC" then d = d * 2 end + if gbaBase and not opts.gbaSetDamage and attacker.charged + and Abilities.normalizeType(move.type) == "ELECTRIC" then d = d * 2 end -- Native Helping Hand scales the calculated base after the critical -- multiplier, before STAB/type rounding. Fixed damage and confusion's diff --git a/tests/engine/gen3_held_damage_link.lua b/tests/engine/gen3_held_damage_link.lua index 8d474cc3e..ea732ba0e 100644 --- a/tests/engine/gen3_held_damage_link.lua +++ b/tests/engine/gen3_held_damage_link.lua @@ -22,6 +22,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) for i, game in ipairs({ ga, gb }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" local mon = game.save.party[1] mon.nickname = i == 1 and "HOST" or "GUEST" mon.moves = { { id = f.ids.TACKLE, pp = 10 } } @@ -63,6 +64,7 @@ for _, version in ipairs({ "firered", "emerald" }) do ga.stack:push(a) gb.stack:push(b) for _, battle in ipairs({ a, b }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked battle uses the GBA damage profile") for _, actor in ipairs({ battle.player, battle.enemy }) do actor.mon.hp, actor.mon.stats.hp = 1000, 1000 actor.curStats.attack, actor.curStats.defense, actor.curTypes = 51, 51, { "NORMAL" } From a2358df87c4d9a2dac9afda56fdf5f7fcf9b0c1d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:50:56 +0100 Subject: [PATCH 76/90] Document native held damage command scope and remaining controller gaps --- docs/native-gba-held-damage.md | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/native-gba-held-damage.md diff --git a/docs/native-gba-held-damage.md b/docs/native-gba-held-damage.md new file mode 100644 index 000000000..7544854e4 --- /dev/null +++ b/docs/native-gba-held-damage.md @@ -0,0 +1,71 @@ +# Native GBA held damage commands + +The ordinary GBA calculator previously read the attacker's held effect twice: +once for stat items, then again for type items on finished damage. A stateful +`held_item.trigger` consumer could therefore supply incompatible answers inside +one native command. Type boosts also rounded at the wrong point. + +Resolve attacker and defender held effects once per base-damage command, in +that order, and reuse the attacker answer. Apply type/stat item modifiers before +stat stages, using the current battler species. Share the native pre-stage +ability modifiers with ordinary and scripted set-damage commands. Preserve GBA +stage flooring and critical-stage bypass; apply pinch abilities and sports to +power for ordinary attacks and Flash Fire before the final base `+2`. Preserve +Spit Up's separate opcode, which does not forward field sport flags. Other profile paths retain +their existing code. + +Native GBA empty hands expose `item = 0`, including the public callback view. +Zero is a native sentinel, not a content registry item. Other generations and +ordinary saved-mon item views retain their existing semantics. + +This stacks on #228. Gen1 remains a clean, read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; no Gen1 source is patched or published. + +## Validation and reproduction + +The shared fixture executes the actual calculators and public API-3 hook chains +in both engines. It compares every recorded read field, ordered actor identity, +calculated damage, bounded RNG call/value and HP observation. Inputs include +empty hands, native stat/type items, species restrictions, stages, criticals, +ability combinations, burn, weather, screens, public substitutions/suppression +and a stateful answer changing between consecutive commands. Fixtures own their +in-memory inputs; no comparison rows or RNG calls are filtered. + +```sh +nice -n 19 "$LUA" tests/engine/gen3_held_damage.lua +nice -n 19 "$LUA" tests/engine/gen3_held_damage_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/held-damage-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/held-damage-contracts.json +nice -n 19 python3 tools/gba-damage-base-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/ordinary-damage-contracts.json +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/enemy-contracts.json +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +``` + +The native command revision is 10; peers at revision 9 cannot enter a full +battle. Party codec remains 1. The native fixture hash changes from +`f1bec52ffc687ea3` to `3a2adb7fa1caee3b`. An independent copy of the unchanged +parent hasher with only the revision token changed produces that result; GB, +GSC, DS and BW fixture hashes remain `e33d251961f2a41d`. + +## Remaining boundaries + +This is base-command parity, not complete held-item script parity. The full +Sleep Talk attack trace still differs: the Choice Band check/binding is absent +and King’s Rock/Shell Bell still run separate reads. Equal total read counts do +not establish ordered payload equality. Keep the raw full-phase failure probe. +Failed awake/empty Sleep Talk and blocked ordinary-move traces are separate. + +Unbadged controlled inputs are compared here. Full badge/obedience/controller +orchestration, arbitrary imported catalogs and playthrough/graphics parity are +not established by these calculator tests. The real-link fixture must explicitly +select its GBA profile: the generic link helper creates a Red save with Gen1 +options. Fresh/imported save rule selection requires its own audit. + +Historical held-item workflows using a modified reference are not proof against +unchanged Gen1. The new damage comparison pins the clean reference above. From 9ad4f6ab90524eda893d0d6dbfb6f12ab71f9e9f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:59:32 +0100 Subject: [PATCH 77/90] Preserve sport-before-pinch rounding and cover charge and Flash Fire --- src/battle/Damage.lua | 4 ++-- tests/held_item/damage.lua | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index bbeab35cd..1de670f50 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -464,13 +464,13 @@ function Damage.compute(ruleset, attacker, defender, move, opts) power = math.max(1, math.floor(power * opts.powerMultiplier)) end if gbaBase then - local pn, pd = Abilities.damageMultiplier(attacker, move) - power = math.floor(power * pn / pd) local kind = Abilities.normalizeType(move.type) -- Spit Up's set-damage opcode does not forward the field sport flags. for _, muted in pairs(not opts.gbaSetDamage and opts.sports or {}) do if muted == kind then power = math.floor(power / 2) end end + local pn, pd = Abilities.damageMultiplier(attacker, move) + power = math.floor(power * pn / pd) end d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50) diff --git a/tests/held_item/damage.lua b/tests/held_item/damage.lua index cc749032f..eff20446a 100644 --- a/tests/held_item/damage.lua +++ b/tests/held_item/damage.lua @@ -56,6 +56,16 @@ function M.run(root, backend, version) { name = "water-sport", item = "FIRE_POWER", special = true, sport = true }, { name = "screen-band", item = "CHOICE_BAND", screen = true }, { name = "screen-fire", item = "FIRE_POWER", special = true, screen = true }, + { + name = "blaze-water-sport", + item = "FIRE_POWER", + special = true, + ability = "BLAZE", + pinch = true, + sport = true, + }, + { name = "flash-fire", item = "FIRE_POWER", special = true, ability = "FLASH_FIRE", flash = true }, + { name = "charged-boost", item = "ELECTRIC_POWER", special = true, electric = true, charged = true }, } local function item(effect) for _, row in ipairs(f.catalog) do @@ -79,9 +89,11 @@ function M.run(root, backend, version) if native then user.type1, user.type2, target.type1, target.type2 = 0, 0, 0, 0 user.expTracedAbility, target.expTracedAbility = scenario.ability, scenario.targetAbility + user.expFlashFire, user.expCharged = scenario.flash, scenario.charged else user.curTypes, target.curTypes = { "NORMAL" }, { "NORMAL" } user.abilityOverride, target.abilityOverride = scenario.ability, scenario.targetAbility + user.flashFire, user.charged = scenario.flash, scenario.charged end local attackStat, defenseStat = scenario.special and "spAtk" or "attack", scenario.special and "spDef" or "defense" @@ -128,7 +140,13 @@ function M.run(root, backend, version) return require("src.core.game3.battle.damage").calc( user, target, - { id = "TACKLE", numId = 33, power = 35, type = scenario.special and 10 or 0, effect = 0 }, + { + id = "TACKLE", + numId = 33, + power = 35, + type = scenario.electric and 13 or scenario.special and 10 or 0, + effect = 0, + }, { forceCrit = crit, rng = rng, @@ -145,7 +163,7 @@ function M.run(root, backend, version) id = f.ids.TACKLE, index = 33, power = 35, - type = scenario.special and "FIRE" or "NORMAL", + type = scenario.electric and "ELECTRIC" or scenario.special and "FIRE" or "NORMAL", effect = "NO_ADDITIONAL_EFFECT", gen3Effect = 0, }, { @@ -160,6 +178,9 @@ function M.run(root, backend, version) local damage = calculate() local repeated = scenario.stateful and calculate() or nil off() + if scenario.name == "blaze-water-sport" and stat == 257 and stage == 0 and not crit then + T.eq(damage, 42, "unchanged reference anchor: sport floors before pinch ability") + end T.eq(#trace, scenario.stateful and 4 or 2, "one held read per actor in each GBA base command") T.eq(reads, scenario.stateful and 2 or 1, "answers expire before the next native command") T.eq(trace[1] and trace[1].holder, "PLAYER", "attacker held read first") From deb5f3ef21fbbe700a58ae3f5b4621aa7a38fb9d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:08:06 +0100 Subject: [PATCH 78/90] Apply verified formatting to shared held damage fixtures --- tests/held_item/damage.lua | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/held_item/damage.lua b/tests/held_item/damage.lua index eff20446a..ad0fdfb8b 100644 --- a/tests/held_item/damage.lua +++ b/tests/held_item/damage.lua @@ -137,27 +137,22 @@ function M.run(root, backend, version) end local function calculate() if native then - return require("src.core.game3.battle.damage").calc( - user, - target, - { - id = "TACKLE", - numId = 33, - power = 35, - type = scenario.electric and 13 or scenario.special and 10 or 0, - effect = 0, - }, - { - forceCrit = crit, - rng = rng, - adapter = require("src.core.game3.battle.adapter").new(b), - st = b, - weather = scenario.weather, - waterSport = scenario.sport, - reflect = scenario.screen, - lightScreen = scenario.screen, - } - ) + return require("src.core.game3.battle.damage").calc(user, target, { + id = "TACKLE", + numId = 33, + power = 35, + type = scenario.electric and 13 or scenario.special and 10 or 0, + effect = 0, + }, { + forceCrit = crit, + rng = rng, + adapter = require("src.core.game3.battle.adapter").new(b), + st = b, + weather = scenario.weather, + waterSport = scenario.sport, + reflect = scenario.screen, + lightScreen = scenario.screen, + }) else return b:computeDamage(user, target, { id = f.ids.TACKLE, From d2665df673e87fddbf94f3670a1e9f278744680c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:13:56 +0100 Subject: [PATCH 79/90] Record native held damage acceptance and remaining parity boundaries --- docs/native-gba-held-damage.md | 41 +++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/native-gba-held-damage.md b/docs/native-gba-held-damage.md index 7544854e4..00a827ca5 100644 --- a/docs/native-gba-held-damage.md +++ b/docs/native-gba-held-damage.md @@ -23,6 +23,34 @@ This stacks on #228. Gen1 remains a clean, read-only reference at ## Validation and reproduction +Accepted full-test runtimes are feature +`9ad4f6ab90524eda893d0d6dbfb6f12ab71f9e9f` and combined +`7a681ca8b81be0e77d3447ff3069459cda0bb974`. Later commits only apply +AST-verified fixture formatting and document acceptance/publication. The +formatted fixture is checked again against both engines on clean commits. + +- **10,202 focused assertions**, **48 real-link assertions** and the feature's + **154 protocol assertions** pass. Both linked roles select the GBA profile, + complete two menu-driven turns, observe matching ordered reads and agree on + every completed hash/component and real learned PP. +- **124 workers / 86,620 paired observations** agree with unchanged Gen1: + 2,040 held-command rows, 6,912 ordinary base-damage rows and the existing + 77,668 selection/execution observations. The new matrix includes sport-before- + pinch flooring, Flash Fire, Charge and stateful successive commands. +- Full feature: **132/141 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines in nine suites** equals clean #228 + `491d3a2f0b997f8f5c801ebcd12524e70ba5cf54` (130/139); its nonzero exit is + retained. Combined: **251/251 engine suites**, SDK **3/3**, B30. +- The unchanged prerequisite fails **2,041 assertions per profile**, with all + 1,020 complete command rows differing. The additional isolated sport/pinch + probe retained 24 differing rows across both profiles, including native 42 + versus host 44 damage; corrected ordering passes. +- Twelve existing CPU-only imported FireRed/Emerald Sleep Talk scenarios pass + again on both branches. These are regression checks of actual loaded attack + metadata and public menu submissions, not certification of the entire item + catalog. No assets or normal SAVE are distributed/issued. +- Scoped StyLua (including AST verification), actionlint and whitespace pass. + The shared fixture executes the actual calculators and public API-3 hook chains in both engines. It compares every recorded read field, ordered actor identity, calculated damage, bounded RNG call/value and HP observation. Inputs include @@ -47,6 +75,14 @@ nice -n 19 python3 tools/enemy-action-contract-check/check.py \ nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick ``` +Local evidence is retained under `tmp/held-damage-qa/` in each worktree: +`full-complete.log`, `enemy-complete.json`, `held-complete.json`, +`base-complete.json`, `held-formatted.json`, `imported-complete.log` and +`accepted-evidence.json`. Feature evidence also retains baseline/negative, +initial mismatched-fixture, set-damage gating, link-profile and formatting +failures. The clean prerequisite's full log is in the Sleep Talk worktree's +`tmp/sleep-talk-qa/held-damage-baseline-full.log`. + The native command revision is 10; peers at revision 9 cannot enter a full battle. Party codec remains 1. The native fixture hash changes from `f1bec52ffc687ea3` to `3a2adb7fa1caee3b`. An independent copy of the unchanged @@ -65,7 +101,10 @@ Unbadged controlled inputs are compared here. Full badge/obedience/controller orchestration, arbitrary imported catalogs and playthrough/graphics parity are not established by these calculator tests. The real-link fixture must explicitly select its GBA profile: the generic link helper creates a Red save with Gen1 -options. Fresh/imported save rule selection requires its own audit. +options. A separate fresh-save probe reproduces this for +`SaveData.newGame({version = "firered"})` and Emerald: the actual constructed +battle still selects `gen1_faithful`. Native defaults need a separate fix; +these calculator/link tests explicitly exercise the GBA profile. Historical held-item workflows using a modified reference are not proof against unchanged Gen1. The new damage comparison pins the clean reference above. From 2fbfd30c1710b7ad7bc70fc44ea73ec32fa68a94 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:23:13 +0100 Subject: [PATCH 80/90] Clarify existing native-default fix and isolate command-stack probe scope --- docs/native-gba-held-damage.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/native-gba-held-damage.md b/docs/native-gba-held-damage.md index 00a827ca5..5bb53c18e 100644 --- a/docs/native-gba-held-damage.md +++ b/docs/native-gba-held-damage.md @@ -101,10 +101,13 @@ Unbadged controlled inputs are compared here. Full badge/obedience/controller orchestration, arbitrary imported catalogs and playthrough/graphics parity are not established by these calculator tests. The real-link fixture must explicitly select its GBA profile: the generic link helper creates a Red save with Gen1 -options. A separate fresh-save probe reproduces this for -`SaveData.newGame({version = "firered"})` and Emerald: the actual constructed -battle still selects `gen1_faithful`. Native defaults need a separate fix; -these calculator/link tests explicitly exercise the GBA profile. +options. On this isolated command stack, a fresh-save probe reproduces +`gen1_faithful` for `SaveData.newGame({version = "firered"})` and Emerald. +The existing [#162 automatic-default fix](https://github.com/UNDERdecoded/Gen2Recomped/pull/162) +already resolves this and is integrated in the combined fork: the same probe +there selects `gen3_emerald` for both games. Its 926-check regression suite +also passes, including preservation of explicit selections. No duplicate +defaults fix is needed. These calculator/link fixtures explicitly select GBA. Historical held-item workflows using a modified reference are not proof against unchanged Gen1. The new damage comparison pins the clean reference above. From 497575a85c068c947c9f15f9306867272de2ef61 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:21:01 +0100 Subject: [PATCH 81/90] Prepare native GBA Choice Band commands and selection wiring; validation pending --- docs/native-gen3-choice-band.md | 52 ++++ src/battle/BattleCommands.lua | 41 ++++ src/battle/BattleState.lua | 31 ++- src/battle/EnemyMoveChoice.lua | 6 +- src/battle/GbaItemSchedule.lua | 5 +- src/battle/Gen3BatonPass.lua | 18 +- src/battle/Gen3Builtins.lua | 1 + src/battle/Gen3ChoiceBand.lua | 166 +++++++++++++ src/battle/Gen3MoveSelection.lua | 137 +++++++++++ src/battle/Gen3SleepTalk.lua | 2 +- src/battle/TrainerAI.lua | 8 +- src/link/Fingerprint.lua | 2 +- src/link/LinkBattle.lua | 9 +- src/mods/BattleIntents.lua | 3 + src/mods/BattleSnapshots.lua | 3 + tests/enemy_actions/choice_band.lua | 246 +++++++++++++++++++ tests/engine/gen3_choice_band.lua | 5 + tools/enemy-action-contract-check/worker.lua | 4 +- 18 files changed, 716 insertions(+), 23 deletions(-) create mode 100644 docs/native-gen3-choice-band.md create mode 100644 src/battle/Gen3ChoiceBand.lua create mode 100644 src/battle/Gen3MoveSelection.lua create mode 100644 tests/enemy_actions/choice_band.lua create mode 100644 tests/engine/gen3_choice_band.lua diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md new file mode 100644 index 000000000..0e70d073d --- /dev/null +++ b/docs/native-gen3-choice-band.md @@ -0,0 +1,52 @@ +# Native GBA Choice Band and move selection + +Development branch; validation and publication are pending. Do not use this +branch as the accepted combined game-testing snapshot. Its parent is #230. + +The native move-end command always performs its Choice Band held-effect read +unless the action was disobedient. A new choice remembers the selected native +caller, excludes Struggle and successful Baton Pass, and is cleared when the +move disappears from the learned slots. Losing the item does not erase a +still-learned choice. Each later planner query obtains a fresh held answer. + +Manual admission is a separate consumer: it checks physical item 186, not +mutable held-effect metadata. The AI's four-slot limitations command uses the +held-effect hook and combines Choice Band with PP, Disable, Encore, Taunt, +Torment and Imprison. The ordinary wild controller only uses that mask for the +all-unusable shortcut; otherwise it draws nonempty native slots before the +planner applies the choice. Manual Torment also retains the Struggle exception. + +Planning binds the actual forced learned slot's PP and priority while keeping +the declared turn event separate. A later replacement of the move table must +rebind PP to the current slot. Native caller provenance is explicit: Sleep +Talk and the unchanged native Mirror Move/Metronome selectors differ from a +custom handler's `ctx.callMove`. Active Baton Pass caller frames follow the +replacement battler slot. Generic custom callers retain their own move-end +command rather than inheriting the native caller's early return. + +Choice identity participates in GBA link state and public-intent staleness. +The native command revision is 11; the party codec remains 1. The independent +revision-token fingerprint audit and updated native golden are still pending. + +## Required validation before a PR + +- Execute `tests/engine/gen3_choice_band.lua` and compare the same complete + zero-damage move-end command inputs with unchanged, clean Gen1 + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The fixture records every held + payload and RNG call; it does not filter the trace. These are command tests, + not proof of the complete damaging attack script. +- Extend actual planned-turn tests to physical/effect-only items, suppression, + loss/reacquisition, first matching slot/duplicates, empty PP, Struggle, + Encore/Disable/Taunt/Torment/Imprison, priority, targets and move replacement. +- Exercise public snapshots/submissions and real manual refusal/return paths, + including stale offers and preservation of other-generation behavior. +- Exercise native and custom called moves, failures, Transform and real + synchronous/asynchronous Baton Pass. Retain and report any remaining full + held-script trace mismatch; King's Rock/Shell Bell remains a separate gap. +- Run actual linked roles, old-peer refusal, independent hash audit, + maintained-LuaJIT cross-engine comparisons and complete feature/combined + suites. Compare the exact inherited FAIL Counter with the clean parent. +- Run imported-data scenarios, scoped AST-verified formatting and actionlint. + Publish a regular English Gen2 PR only after acceptance; Gen1 stays read-only. + +No runtime or contract test has yet been run for this development branch. diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua index 7a1559729..614eba704 100644 --- a/src/battle/BattleCommands.lua +++ b/src/battle/BattleCommands.lua @@ -57,6 +57,43 @@ function Methods.chooseMove(self, index) self.moveIndex = index local mv = moves[self.moveIndex] local mvDef = mv and self.data.moves[mv.id] + local nativeReason = require("src.battle.Gen3MoveSelection").manualReason(self, chooser, mv, self.moveIndex) + if require("src.core.GameVersion").isGen3() and not nativeReason then + if Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex, self.phase = 1, "targetSelect" + else + self:chooseAction(mv) + end + return true + elseif nativeReason then + local message + if nativeReason == "choice" then + local locked + for _, def in pairs(self.data.moves) do + if require("src.battle.Gen3ChoiceBand").identity(self, def) == chooser.choicedMove then locked = def; break end + end + local item = require("src.battle.HoldItems").defOf(chooser) + message = Strings("%s allows use of\nonly %s!", item and item.name or "CHOICE BAND", + locked and locked.name or tostring(chooser.choicedMove)) + elseif nativeReason == "pp" then + message = self:romText("_MoveNoPPText", "No PP left for\nthis move!") + elseif nativeReason == "disabled" then + message = self:romText("_MoveDisabledText", "The move is\ndisabled!") + elseif nativeReason == "taunt" then + message = Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id) + elseif nativeReason == "torment" then + message = Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name) + elseif nativeReason == "imprison" then + message = Strings("%s can't use the\nsealed %s!", chooser.name, mvDef and mvDef.name or mv.id) + else + message = Strings("That move can't\nbe selected!") + end + self:say(message) + self.phase, self.afterQueue = "messages", "moveSelect" + return true + end if require("src.battle.Gen3Disable").blocked(chooser, mv, self.moveIndex) then self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) self.phase = "messages" @@ -175,6 +212,10 @@ local function moveReason(b, actor, slot, move) if not move or not b.data.moves[move.id] then return "invalid move slot" end + if require("src.core.GameVersion").isGen3() then + local why = require("src.battle.Gen3MoveSelection").manualReason(b, actor, move, slot) + return why and ("move is blocked by native GBA rules: " .. why) or nil + end if require("src.battle.Gen3Disable").blocked(actor, move, slot) then return "move is disabled" end diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 69e8cf116..712c3c5b1 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4041,6 +4041,12 @@ end function BattleState:playerHasPP(who) who = who or self:menuBattler() if not (who and who.curMoves) then return false end + if GameVersion.isGen3() then + for slot = 1, 4 do + if not require("src.battle.Gen3MoveSelection").manualReason(self, who, who.curMoves[slot], slot) then return true end + end + return false + end for i, mv in ipairs(who.curMoves) do if mv.pp > 0 and not require("src.battle.Gen3Disable").blocked(who, mv, i) then return true end end @@ -4351,6 +4357,8 @@ function BattleState:update(dt) if not self:updateQueue() then if self.afterQueue == "menu" then self.phase = "menu" + elseif self.afterQueue == "moveSelect" then + self.phase = "moveSelect" elseif self.afterQueue == "finish" then self:finish() end @@ -5455,7 +5463,10 @@ function BattleState:vanillaEnemyAction(battler) battler = battler or self.enemy if not battler then return nil end local locked = self:lockedAction(battler) - if locked then return locked end + local encoreOnly = GameVersion.isGen3() and not battler.charging and not battler.rageMove + and not (battler.thrashTurns and battler.thrashTurns > 0) + and locked == require("src.battle.Gen3Encore").menu(battler) + if locked and not encoreOnly then return locked end if self.palace then return require("src.battle.Gen3Palace").choose(self, battler) end -- an ai_classes brain (or one on the trainer record) supersedes the -- class action and move scoring entirely @@ -5554,8 +5565,8 @@ function BattleState:resolveTurn(playerAction) local selectedPlayerAction, selectedEnemyAction = playerAction, enemyAction local nativeActions if GameVersion.isGen3() then - local Encore = require("src.battle.Gen3Encore") - playerAction, enemyAction = Encore.plan(self.player, playerAction), Encore.plan(self.enemy, enemyAction) + local Choice = require("src.battle.Gen3ChoiceBand") + playerAction, enemyAction = Choice.plan(self, self.player, playerAction), Choice.plan(self, self.enemy, enemyAction) local Rage = require("src.battle.Gen3Rage") if self:isDouble() then nativeActions = {} @@ -5565,7 +5576,7 @@ function BattleState:resolveTurn(playerAction) local action if enemyActions and not b.isPlayer and not b.ally then action = enemyActions[pos] else action = self:actionFor(b, playerAction, enemyAction) end - action = Encore.plan(b, action) + action = Choice.plan(self, b, action) nativeActions[pos] = action Rage.selected(self, b, action) end @@ -7647,6 +7658,7 @@ function BattleState:executeAction(user, target, action) end if user.mon.hp <= 0 then return end if not action then return end + if GameVersion.isGen3() then action = require("src.battle.Gen3ChoiceBand").execution(user, action) end local explicitTarget = false if GameVersion.generation() == 3 and self:isDouble() and not action.special then local selected @@ -8265,6 +8277,8 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni end if pick then if record.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, pick) + elseif record.gbaNativeChoiceCaller and record.gbaNativeChoiceCaller == record.callsMove then + require("src.battle.Gen3ChoiceBand").call(ctx, pick, target) else self:performMove(user, target, { id = pick, pp = 1 }, true) end end return @@ -8410,12 +8424,15 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end local Schedule = require("src.battle.GbaItemSchedule") local parent = self._gbaMoveFrame - local frame = { parent = parent, move = self:moveDef(moveInst) } + local caller = self._gbaNextCaller + self._gbaNextCaller = nil + local frame = { parent = parent, user = user, move = self:moveDef(moveInst), moveId = moveInst.id, + calledBy = isCalled and caller and caller.parent == parent and caller.move or nil } 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, spreadContinuation) - if not frame.finished and not frame.called and not frame.suspended then Schedule.moveEnd(self) end + if not frame.finished and not frame.nativeCalled and not frame.suspended then Schedule.moveEnd(self) end self._gbaMoveFrame = parent if not isCalled and not parent and not self._gbaActionFrame and not frame.suspended then Schedule.afterAction(self) end end @@ -9977,7 +9994,7 @@ function BattleState:nonMoveAction() local native, action = GameVersion.isGen3() if native then action = self:enemyAction() - action = require("src.battle.Gen3Encore").plan(self.enemy, action) + action = require("src.battle.Gen3ChoiceBand").plan(self, self.enemy, action) local Rage = require("src.battle.Gen3Rage") Rage.selected(self, self.player, nil) Rage.selected(self, self.enemy, action) diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index a6cf7a10d..1f4d90278 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -82,14 +82,16 @@ end function M.slot(action) return slots[action] or action end -function M.withMove(action, id) +function M.withMove(action, id, forcedSlot, forcedIndex) local copy = shallow(action) copy.id = id if nativeViews[action] then nativeViews[copy] = shallow(nativeViews[action]) + if forcedIndex then nativeViews[copy].slot = forcedIndex end end + if forcedIndex then copy.slot = forcedIndex end publicReferences[copy] = publicReferences[action] - return bind(copy, M.slot(action)) + return bind(copy, forcedSlot or M.slot(action)) end function M.exhausted(action) return slots[action] ~= nil and action.pp <= 0 diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index 6fce5d666..6c4812e31 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -10,9 +10,10 @@ function Schedule.battlers(battle) return list end -function Schedule.moveEnd(battle) - local frame = battle._gbaMoveFrame +function Schedule.moveEnd(battle, completedFrame) + local frame = completedFrame or battle._gbaMoveFrame if frame then + require("src.battle.Gen3ChoiceBand").moveEnd(battle, frame) frame.finished = true end for _, b in ipairs(Schedule.battlers(battle)) do diff --git a/src/battle/Gen3BatonPass.lua b/src/battle/Gen3BatonPass.lua index c7d50f934..c557a1c2a 100644 --- a/src/battle/Gen3BatonPass.lua +++ b/src/battle/Gen3BatonPass.lua @@ -251,6 +251,15 @@ function M.select(battle, pending, index) end return true end +local function switchContext(ctx, user, index, party, frame) + ctx.user = M.switch(ctx.battle, user, index, party) + -- The native battler slot is replaced in place; host battlers are distinct + -- tables. Every active caller frame must follow that same slot replacement. + while frame do + if frame.user == user then frame.user = ctx.user end + frame = frame.parent + end +end function M.await(battle, pending) if battle._gen3BatonPending ~= pending then return @@ -261,14 +270,14 @@ function M.await(battle, pending) end if pending.choice then battle._gen3BatonPending = nil - pending.ctx.user = M.switch(battle, pending.user, pending.choice, pending.party) + switchContext(pending.ctx, pending.user, pending.choice, pending.party, pending.frame) if pending.frame then local frame = pending.frame while frame do frame.finished = true frame = frame.parent end - require("src.battle.GbaItemSchedule").moveEnd(battle) + require("src.battle.GbaItemSchedule").moveEnd(battle, pending.frame) require("src.battle.GbaItemSchedule").afterAction(battle) end else @@ -326,6 +335,7 @@ function M.perform(ctx) local battle, user = ctx.battle, ctx.user local candidates, party = M.candidates(battle, user) if #candidates == 0 then + if battle._gbaMoveFrame then battle._gbaMoveFrame.failed = true end battle:cancelMoveAnim() battle:sayNext(Strings("But, it failed!")) return @@ -343,7 +353,7 @@ function M.perform(ctx) index = i end end - ctx.user = M.switch(battle, user, index, party) + switchContext(ctx, user, index, party, battle._gbaMoveFrame) return end if @@ -356,7 +366,7 @@ function M.perform(ctx) chosen = i end end - ctx.user = M.switch(battle, user, chosen, party) + switchContext(ctx, user, chosen, party, battle._gbaMoveFrame) return end battle._gen3BatonPending = pending diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index 6383d051a..a9f2cf0c4 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -5,6 +5,7 @@ function M.record(id, base) base = require("src.battle.Gen3MultiHit").builtin(id, base) base = require("src.battle.Gen3Rage").builtin(id, base) base = require("src.battle.Gen3TripleKick").builtin(id, base) + base = require("src.battle.Gen3ChoiceBand").builtin(id, base) return require("src.battle.Gen3SleepTalk").builtin(id, base) end return M diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua new file mode 100644 index 000000000..97725c9c7 --- /dev/null +++ b/src/battle/Gen3ChoiceBand.lua @@ -0,0 +1,166 @@ +-- GBA Choice Band owns a learned move identity. Move-end and AI/planner +-- consumers read held effects; manual selection checks the physical item. +local V = require("src.core.GameVersion") +local Held = require("src.battle.HoldItems") +local Choices = require("src.battle.EnemyMoveChoice") +local C = {} +local planned = setmetatable({}, { __mode = "k" }) +local builtinCache = setmetatable({}, { __mode = "k" }) +function C.signature(user) + local value = user.choicedMove + if value == false then + value = nil + end + return ":choice=" .. type(value) .. ":" .. tostring(value) +end +local function definition(battle, move) + local id = move + if type(move) == "table" then + id = move.id + end + return battle.data.moves[id] or type(move) == "table" and move or nil +end +function C.identity(battle, move) + local def = definition(battle, move) + local id = move + if type(move) == "table" then + id = move.id + end + return def and (tonumber(def.index or def.numId) or def.id or id) or id +end +local function learned(battle, user, identity) + local moves = user.curMoves or user.mon.moves or {} + for index = 1, 4 do + local move = moves[index] + if move and C.identity(battle, move) == identity then + return move, index + end + end +end +function C.moveEnd(battle, frame) + if not V.isGen3() or not (frame and frame.user) then + return + end + local user = frame.user + local selected = frame.calledBy or frame.moveId or frame.move + local chosen = definition(battle, selected) + local identity = chosen and C.identity(battle, selected) + -- The read is part of the command even for an existing lock or Struggle. + if + not frame.notObeyed + and Held.effectOf(user, "check") == "CHOICE_BAND" + and identity ~= 165 + and not user.choicedMove + then + local baton = chosen and (tonumber(chosen.gen3Effect) == 127 or chosen.effect == "BATON_PASS_EFFECT") + if not (baton and not frame.failed) then + user.choicedMove = identity + end + end + if user.choicedMove and not learned(battle, user, user.choicedMove) then + user.choicedMove = nil + end +end +function C.selectionBlocked(battle, user, move) + if not V.isGen3() or not user then + return false + end + local identity = user.choicedMove + if not identity or identity == 0 or identity == 65535 then + return false + end + local item + if user.item ~= nil then + item = tonumber(user.item) or 0 + else + item = require("src.battle.Gen3AIItemKnowledge").item(battle, user) + end + return item == 186 and C.identity(battle, move) ~= identity +end +function C.plan(battle, user, action) + if not V.isGen3() then + return action + end + action = require("src.battle.Gen3Encore").plan(user, action) + if + not (user and action) + or action.special + or action.kind and action.kind ~= "move" + or user.charging + or user.thrashTurns and user.thrashTurns > 0 + or user.rageMove + or (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove + then + return action + end + if + user.choicedMove + and Held.effectOf(user, "check") == "CHOICE_BAND" + and C.identity(battle, action) ~= user.choicedMove + and C.identity(battle, action) ~= 165 + then + local forced, index = learned(battle, user, user.choicedMove) + if forced and (tonumber(forced.pp) or 1) > 0 then + local out = Choices.withMove(action, forced.id, forced, index) + if battle:isDouble() then + out.targetType = tonumber(definition(battle, forced).target) or 0 + end + planned[out] = { index = index, id = forced.id } + return out + end + end + return action +end +function C.execution(user, action) + local plan = planned[action] + if plan then + local slot = (user.curMoves or user.mon.moves or {})[plan.index] + if slot and Choices.slot(action) ~= slot then + local rebound = Choices.withMove(action, plan.id, slot, plan.index) + planned[rebound] = plan + return rebound + end + end + return action +end +function C.call(ctx, pick, target, suppressAnimation, spreadContinuation) + local battle = ctx.battle + local previous, parent = battle._gbaNextCaller, battle._gbaMoveFrame + if parent then + parent.nativeCalled = true + end + battle._gbaNextCaller = { parent = parent, move = ctx.move.id } + local ok, err = pcall( + battle.performMove, + battle, + ctx.user, + target, + { id = pick, pp = 1 }, + true, + suppressAnimation, + spreadContinuation + ) + battle._gbaNextCaller = previous + if not ok then + battle._gbaMoveFrame = parent + error(err, 0) + end + ctx.user = parent and parent.user or ctx.user + ctx.user.lastMove = ctx.move.id +end +function C.builtin(id, base) + if not V.isGen3() or not base or (id ~= "METRONOME_EFFECT" and id ~= "MIRROR_MOVE_EFFECT") then + return base + end + if builtinCache[base] then + return builtinCache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.gbaNativeChoiceCaller = base.callsMove + builtinCache[base] = out + return out +end +return C diff --git a/src/battle/Gen3MoveSelection.lua b/src/battle/Gen3MoveSelection.lua new file mode 100644 index 000000000..f8a9ff643 --- /dev/null +++ b/src/battle/Gen3MoveSelection.lua @@ -0,0 +1,137 @@ +-- The native AI scans four slots once, through the held-effect hook. Manual +-- admission uses physical Choice Band identity and never performs that read. +local V = require("src.core.GameVersion") +local Choice = require("src.battle.Gen3ChoiceBand") +local Selection = {} +local function moves(user) + return user.curMoves or user.mon.moves or {} +end +local function imprisoned(battle, user, move) + local identity = Choice.identity(battle, move) + for _, foe in ipairs(battle:foesOf(user)) do + if foe.imprison then + for slot = 1, 4 do + local other = moves(foe)[slot] + if other and Choice.identity(battle, other) == identity then + return true + end + end + end + end + return false +end +local function reason(battle, user, move, slot, heldChoice, manual) + if not move or not battle.data.moves[move.id] or Choice.identity(battle, move) == 0 then + return "empty" + end + local why + if require("src.battle.Gen3Disable").blocked(user, move, slot) then + why = "disabled" + end + if + user.tormented + and (not manual or Choice.identity(battle, move) ~= 165) + and Choice.identity(battle, user.lastMove) == Choice.identity(battle, move) + then + why = "torment" + end + if (tonumber(user.tauntTurns) or 0) > 0 and (tonumber(battle.data.moves[move.id].power) or 0) == 0 then + why = "taunt" + end + if imprisoned(battle, user, move) then + why = "imprison" + end + if heldChoice and user.choicedMove and user.choicedMove ~= Choice.identity(battle, move) then + why = "choice" + end + return why +end +function Selection.manualReason(battle, user, move, slot) + if not V.isGen3() or not user then + return nil + end + local why = reason(battle, user, move, slot, false, true) + if Choice.selectionBlocked(battle, user, move) then + why = "choice" + end + if move and tonumber(move.pp) and move.pp <= 0 then + why = "pp" + end + return why +end +function Selection.aiMask(battle, user) + if not V.isGen3() then + return nil + end + local effect = require("src.battle.HoldItems").effectOf(user, "check") + local mask = {} + for slot = 1, 4 do + local move = moves(user)[slot] + local bad = reason(battle, user, move, slot, effect == "CHOICE_BAND") ~= nil + if move and tonumber(move.pp) == 0 then + bad = true + end + if + (tonumber(user.encoreTurns) or 0) > 0 + and user.encoreMove + and Choice.identity(battle, move) ~= Choice.identity(battle, user.encoreMove) + then + bad = true + end + mask[slot] = bad + end + return mask +end +function Selection.ordinaryWild(battle) + if not V.isGen3() or battle.kind ~= "wild" then + return false + end + local kinds = battle.kinds or {} + if V.get() == "emerald" then + return not (battle.firstBattleKind or kinds.firstBattle or battle.safari or battle.roamer) + end + return not ( + battle.roamer + or battle.safari + or battle.firstBattle + or battle.wildScripted + or battle.legendary + or (tonumber(battle.aiFlags) or 0) ~= 0 + ) +end +function Selection.wildMove(battle, user, mask, rng) + if mask[1] and mask[2] and mask[3] and mask[4] then + return { id = "STRUGGLE", pp = 1, struggle = true } + end + local move, index, found + for _ = 1, 1000 do + index = rng(0, 3) + 1 + move = moves(user)[index] + local identity = move and Choice.identity(battle, move) + if identity and identity ~= 0 and battle.data.moves[move.id] then + found = true + break + end + end + if not found then + for slot = 1, 4 do + if not mask[slot] then + move = moves(user)[slot] + return require("src.battle.EnemyMoveChoice").withMove(move, move.id, move, slot) + end + end + return { id = "STRUGGLE", pp = 1, struggle = true } + end + if not move then + return { id = "STRUGGLE", pp = 1, struggle = true } + end + local action = require("src.battle.EnemyMoveChoice").withMove(move, move.id, move, index) + action.scores = { 0, 0, 0, 0 } + if battle:isDouble() then + local targetType = tonumber(battle.data.moves[move.id].target) or 0 + action.target = math.floor(targetType / 16) % 2 == 1 and user.position + or math.floor(rng(0, 65535) / 2) % 2 * 2 + end + return action +end +return Selection diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index a7f86c882..ea79c90b4 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -130,7 +130,7 @@ function S.call(ctx, pick) targets = { tonumber(move.target) == 16 and user or b:defaultTargetFor(user) } end for i, target in ipairs(targets) do - b:performMove(user, target, { id = pick, pp = 1 }, true, i > 1, i > 1) + require("src.battle.Gen3ChoiceBand").call(ctx, pick, target, i > 1, i > 1) end user.lastMove = ctx.move.id end diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index debe8b331..d8ee090a8 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -331,13 +331,19 @@ end function TrainerAI.chooseMove(battler, rng, battle) rng = rng or love.math.random + local nativeMask = battle and require("src.battle.Gen3MoveSelection").aiMask(battle, battler) + if nativeMask and require("src.battle.Gen3MoveSelection").ordinaryWild(battle) then + return require("src.battle.Gen3MoveSelection").wildMove(battle, battler, nativeMask, rng) + end -- 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. local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP local usable = {} for i, mv in ipairs(battler.curMoves) do - if not require("src.battle.Gen3Disable").blocked(battler, mv, i) and (unlimited or mv.pp > 0) then + if (nativeMask and i <= 4 and not nativeMask[i]) + or (not nativeMask and not require("src.battle.Gen3Disable").blocked(battler, mv, i) + and (unlimited or mv.pp > 0)) then table.insert(usable, mv) end end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 3d84d3b45..1eabaf1e9 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -21,7 +21,7 @@ local Fingerprint = {} -- Runtime command semantics are part of native GBA lockstep compatibility, -- even when both peers load byte-identical data. Keep older game digests. local function nativeBattleRevision() - return require("src.core.GameVersion").isGen3() and 10 or nil + return require("src.core.GameVersion").isGen3() and 11 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index f6acbc77f..8cdceb04b 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -166,6 +166,7 @@ local function activeStr(b, role) .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Encore").signature(b) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Disable").signature(b) or "") .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Rage").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3ChoiceBand").signature(b) or "") end local function benchStr(party) @@ -436,8 +437,8 @@ function LinkBattle.new(game, net, opts) local theirAction = decodeTheirAction(s, theirMsg) local selectedMine,selectedTheirs=myAction,theirAction if require("src.core.GameVersion").isGen3() then - local Encore=require("src.battle.Gen3Encore") - myAction,theirAction=Encore.plan(s.player,myAction),Encore.plan(s.enemy,theirAction) + local Choice=require("src.battle.Gen3ChoiceBand") + myAction,theirAction=Choice.plan(s,s.player,myAction),Choice.plan(s,s.enemy,theirAction) local Rage=require("src.battle.Gen3Rage") Rage.selected(s,s.player,myAction);Rage.selected(s,s.enemy,theirAction) end @@ -844,8 +845,8 @@ function LinkBattle.newSpectator(game, net, opts) and decodeWireAction(s, guestMsg, s.enemy) or nil local selectedHost,selectedGuest=hostAction,guestAction if require("src.core.GameVersion").isGen3() then - local Encore=require("src.battle.Gen3Encore") - hostAction,guestAction=Encore.plan(s.player,hostAction),Encore.plan(s.enemy,guestAction) + local Choice=require("src.battle.Gen3ChoiceBand") + hostAction,guestAction=Choice.plan(s,s.player,hostAction),Choice.plan(s,s.enemy,guestAction) local Rage=require("src.battle.Gen3Rage") Rage.selected(s,s.player,hostAction);Rage.selected(s,s.enemy,guestAction) end diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua index 1def1dcb0..3792d3b13 100644 --- a/src/mods/BattleIntents.lua +++ b/src/mods/BattleIntents.lua @@ -150,6 +150,9 @@ function Intents.context(game, b, top, at) for _, key in ipairs(BATTLER_FLAGS) do flags[key] = type(actor[key]) == "table" and true or actor[key] end + if require("src.core.GameVersion").isGen3() then + flags.choicedMove, flags.imprison = actor.choicedMove, actor.imprison + end values.battlers[pos] = flags for slot, mv in ipairs(actor.curMoves or {}) do refs["move" .. pos .. ":" .. slot] = mv diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua index 4c2c108ba..c92abb2d0 100644 --- a/src/mods/BattleSnapshots.lua +++ b/src/mods/BattleSnapshots.lua @@ -374,6 +374,9 @@ local function build(game, battle, covered, ids) or (actor.tauntTurns ~= nil and (def.power or 0) == 0) or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) or false + if require("src.core.GameVersion").isGen3() then + row.disabled = require("src.battle.Gen3MoveSelection").manualReason(battle, actor, move, slot) ~= nil + end -- 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 diff --git a/tests/enemy_actions/choice_band.lua b/tests/enemy_actions/choice_band.lua new file mode 100644 index 000000000..50b80ee78 --- /dev/null +++ b/tests/enemy_actions/choice_band.lua @@ -0,0 +1,246 @@ +-- Controlled native-shaped inputs. The source engine and its handlers remain +-- unchanged; all mounted item/move tables belong to this fixture. +local M = {} +local NUMBERS = { TACKLE = 33, SCRATCH = 10, STRUGGLE = 165, SLEEP_TALK = 214, BATON_PASS = 226 } +function M.fixture(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 226, "BATON_PASS", 127, "BATON_PASS_EFFECT", 0, 16 }, + }) do + local number, name, effect, binding, power, target = unpack(row) + ids[name] = native and name or ("MOVE_%03d"):format(number) + names[number] = name + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[ids[name]] = { + id = ids[name], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + for _, row in ipairs({ + { 186, "CHOICE_BAND", "CHOICE_BAND", 29 }, + { 529, "IMITATION_BAND", "CHOICE_BAND", 29 }, + { 500, "PLAIN_TOKEN", nil, 0 }, + }) do + local number, name, effect, byte = unpack(row, 1, 4) + if native then + data.gen3Items._byId[number] = { + name = name:gsub("_", " "), + pocket = "ITEMS", + price = 10, + holdEffect = byte, + holdEffectParam = 0, + } + else + local id = ("ITEM_%03d"):format(number) + data.items[id] = { + id = id, + index = number, + name = name:gsub("_", " "), + price = 10, + holdEffect = effect, + gen3HoldEffect = byte, + holdEffectParam = 0, + } + end + end + if native then + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.constants.generation = 3 + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + ) + if native then + 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 + function f.hold(user, number) + user.mon.item = native and (number or 0) or number and ("ITEM_%03d"):format(number) or nil + if native then + user.item = number or 0 + end + end + function f.moves(user, names, pp) + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = {}, {}, {} + for slot, name in ipairs(names) do + user.mon.moves[slot], user.mon.pp[slot], user.mon.maxPp[slot] = NUMBERS[name], pp or 10, 10 + end + else + user.mon.moves = {} + for slot, name in ipairs(names) do + user.mon.moves[slot] = { id = f.ids[name], pp = pp or 10 } + end + user.curMoves = user.mon.moves + end + end + function f.moveId(name) + return native and NUMBERS[name] or f.ids[name] + end + return f +end +function M.run(root, backend, version) + local native, f = backend == "gen1", M.fixture(root, backend, version) + local T, rows = f.T, {} + local cases = { + { name = "first-choice", move = "TACKLE", expected = 33 }, + { name = "second-slot", move = "SCRATCH", expected = 10 }, + { name = "existing-choice", move = "SCRATCH", previous = 33, expected = 33 }, + { name = "empty-hand", move = "TACKLE", item = false }, + { name = "ordinary-item", move = "TACKLE", item = 500 }, + { name = "effect-on-other-item", move = "TACKLE", item = 529, expected = 33 }, + { name = "empty-hand-substitution", move = "TACKLE", item = false, force = true, expected = 33 }, + { name = "suppressed-effect", move = "TACKLE", suppress = true }, + { name = "suppressed-existing", move = "SCRATCH", suppress = true, previous = 33, expected = 33 }, + { name = "lost-item-keeps-choice", move = "SCRATCH", item = false, previous = 33, expected = 33 }, + { name = "lost-move-clears-after-read", move = "TACKLE", previous = 10, learned = { "TACKLE" } }, + { name = "lost-move-without-item", move = "TACKLE", previous = 10, learned = { "TACKLE" }, item = false }, + { name = "struggle-does-not-bind", move = "STRUGGLE" }, + { name = "struggle-keeps-choice", move = "STRUGGLE", previous = 33, expected = 33 }, + { name = "successful-baton", move = "BATON_PASS", learned = { "BATON_PASS", "TACKLE" } }, + { + name = "failed-baton", + move = "BATON_PASS", + failed = true, + learned = { "BATON_PASS", "TACKLE" }, + expected = 226, + }, + { + name = "native-called-provenance", + move = "TACKLE", + caller = "SLEEP_TALK", + learned = { "SLEEP_TALK", "SCRATCH" }, + expected = 214, + }, + { + name = "native-called-baton", + move = "BATON_PASS", + caller = "SLEEP_TALK", + learned = { "SLEEP_TALK", "SCRATCH" }, + expected = 214, + }, + { name = "custom-called-without-native-caller", move = "TACKLE", called = true, expected = 33 }, + { name = "disobedience-command-guard", move = "TACKLE", notObeyed = true }, + } + for _, case in ipairs(cases) do + for _, enemy in ipairs({ false, true }) do + local c = f.fresh("TACKLE", { enemy = enemy }) + local b, user, target = c.battle, c.user, c.target + if not native then + b:syncSides() + end + user.mon.nickname, target.mon.nickname = "USER", "TARGET" + f.moves(user, case.learned or { "TACKLE", "SCRATCH" }) + f.hold(user, case.item == nil and 186 or case.item or nil) + f.hold(target, nil) + user.choicedMove = case.previous + local trace, draws = {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if ctx.trigger == "check" and ctx.mon.nickname == "USER" then + if case.suppress then + return nil, 0 + end + if case.force then + return "HELD_CHOICE_BAND", 0 + end + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + if native then + local Moves = require("src.core.game3.battle.moves") + local id = f.moveId(case.move) + require("src.core.game3.battle.engine").moveEndEffects({ + st = b, + adapter = require("src.core.game3.battle.adapter").new(b), + user = user, + target = target, + moveId = id, + move = Moves.get(id), + opts = { calledBy = case.caller and f.moveId(case.caller), called = case.called }, + firstDmg = 0, + failed = case.failed, + notObeyed = case.notObeyed, + }) + else + b._gbaMoveFrame = { + user = user, + moveId = f.moveId(case.move), + move = b.data.moves[f.moveId(case.move)], + calledBy = case.caller and f.moveId(case.caller), + failed = case.failed, + notObeyed = case.notObeyed, + } + require("src.battle.GbaItemSchedule").moveEnd(b) + b._gbaMoveFrame = nil + end + off() + T.eq(user.choicedMove, case.expected, "native move-end binds or cleans the learned choice") + T.eq(#trace, case.notObeyed and 2 or 3, "complete zero-damage move-end held read sequence") + T.eq(#draws, 0, "choice command consumes no RNG") + T.eq(#f.result.errors, 0, "public hook chain remains error-free") + rows[#rows + 1] = { + case = case.name, + enemy = enemy, + choice = user.choicedMove, + trace = trace, + draws = draws, + userHp = user.mon.hp, + targetHp = target.mon.hp, + } + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_choice_band.lua b/tests/engine/gen3_choice_band.lua new file mode 100644 index 000000000..5e63d1460 --- /dev/null +++ b/tests/engine/gen3_choice_band.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_band.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band move-end command") diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 85915e96d..db72b840b 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "sleep_talk_content" then +if mode == "choice_band_command" then + rows = dofile(root .. "/tests/enemy_actions/choice_band.lua").run(root, backend, version) +elseif mode == "sleep_talk_content" then rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").content(root, backend, version) elseif mode == "sleep_talk" then rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version) From be88b1fd55bf565b65a2d7928f515e053c3b1953 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:10:03 +0100 Subject: [PATCH 82/90] Validate native Choice Band planning, public guards and canonical link held reads --- docs/native-gen3-choice-band.md | 42 +++- src/battle/Damage.lua | 5 +- src/battle/GbaItemSchedule.lua | 16 ++ src/battle/Gen3ChoiceBand.lua | 19 +- src/battle/HeldItems.lua | 3 + src/battle/HoldItems.lua | 11 +- src/battle/TurnOrder.lua | 9 +- src/link/LinkBattle.lua | 12 +- tests/enemy_actions/choice_band.lua | 46 +++- tests/enemy_actions/choice_planning.lua | 230 +++++++++++++++++++ tests/enemy_actions/choice_selection.lua | 189 +++++++++++++++ tests/enemy_actions/choice_wild.lua | 150 ++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_choice_link.lua | 161 +++++++++++++ tests/engine/gen3_choice_planning.lua | 5 + tests/engine/gen3_choice_public.lua | 118 ++++++++++ tests/engine/gen3_choice_selection.lua | 5 + tests/engine/gen3_choice_wild.lua | 5 + tests/engine/gen3_held_damage_link.lua | 2 +- tools/enemy-action-contract-check/check.py | 43 +++- tools/enemy-action-contract-check/worker.lua | 8 +- 21 files changed, 1062 insertions(+), 23 deletions(-) create mode 100644 tests/enemy_actions/choice_planning.lua create mode 100644 tests/enemy_actions/choice_selection.lua create mode 100644 tests/enemy_actions/choice_wild.lua create mode 100644 tests/engine/gen3_choice_link.lua create mode 100644 tests/engine/gen3_choice_planning.lua create mode 100644 tests/engine/gen3_choice_public.lua create mode 100644 tests/engine/gen3_choice_selection.lua create mode 100644 tests/engine/gen3_choice_wild.lua diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index 0e70d073d..ffbc1402e 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -26,7 +26,45 @@ command rather than inheriting the native caller's early return. Choice identity participates in GBA link state and public-intent staleness. The native command revision is 11; the party codec remains 1. The independent -revision-token fingerprint audit and updated native golden are still pending. +revision-token audit reproduces `3e2986da83083f86` using the unchanged #230 +hasher and only revision 10 -> 11; older-generation digests remain unchanged. + +GBA Struggle skips STAB and the type chart while retaining its normal base, +critical and random-damage commands. The new planned-execution comparison +exposed this existing damage gap, including excess recoil. + +Real link simulations run held scans and planner/speed reads in canonical +host/guest order. The initial item-entry batch waits for both send-outs; +later individual switches keep their normal entry command. Host, guest and +the actual spectator compare complete ordered held traces, including entry, +priority, damage, move-end and residual calls. A stateful damage override +depends on all earlier reads. No payloads are filtered from this comparison. + +## Development evidence + +Before final fixture formatting, focused comparisons pass against the clean, +unchanged Gen1 reference: 80 move-end rows, 180 selection-mask/manual rows, +228 planned/executed rows and 288 ordinary-wild selection/planning rows. +The planner rows retain all recorded RNG calls, events, HP, learned moves, +current/replaced PP and complete planner held payloads. Their execution phase +does not record the full held-script trace; the separate command and link +probes have their explicitly different boundaries. + +Public Loader snapshots/submission, stale offers and the actual keyboard / +message queue refusal return pass 134 assertions. Actual host/guest/spectator +menus and two completed turns pass 86 assertions, with all captured held +reads and peer state components equal. Final formatted reruns, full suites, +negative controls, more caller/switch coverage and import acceptance are +still required. These are development results, not PR acceptance. + +Retained local evidence lives under `tmp/choice-band-qa/`: failed +`planning-first`, `planning-unbound-fixed`, `wild-first`, `public-first`, +`link-first`, `link-canonical-order` and `link-spectator-first` probes; +passing `selection-fixed`, `planning-struggle-fixed`, `wild-input-fixed`, +`public-helper-fixed`, `link-canonical-all-phases`, +`link-spectator-input-fixed` and `hash-audit` results. Early planner/wild/UI +fixture errors and genuine runtime mismatches are kept separately in those +raw outputs. Initial full link traces failed even when turn hashes matched. ## Required validation before a PR @@ -49,4 +87,4 @@ revision-token fingerprint audit and updated native golden are still pending. - Run imported-data scenarios, scoped AST-verified formatting and actionlint. Publish a regular English Gen2 PR only after acceptance; Gen1 stays read-only. -No runtime or contract test has yet been run for this development branch. +No complete feature/combined acceptance or PR publication is claimed yet. diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 1de670f50..ad9be84c3 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -547,7 +547,10 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then d = require("src.battle.Gen3HelpingHand").damage(attacker, d) end local mult = 10 - if not opts.typeless then + -- Native GBA Struggle skips only STAB/type calculation. It still uses + -- the ordinary base, critical and random-damage commands. + local gbaStruggle = gbaBase and (move.id == "STRUGGLE" or tonumber(move.index or move.numId) == 165) + if not opts.typeless and not gbaStruggle then -- STAB local stab = false for _, t in ipairs(attacker.curTypes) do diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index 6c4812e31..3749b64c5 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -7,9 +7,25 @@ function Schedule.battlers(battle) for _, b in battle:activeBattlers() do list[#list + 1] = b end + if battle.linkRole == "guest" and not battle:isDouble() then + list[1], list[2] = list[2], list[1] + end return list end +function Schedule.initialLinkEntry(battle, holder) + local pending = battle._gbaInitialItemEntries + if not pending then return nil end + pending[holder] = true + if not (pending[battle.player] and pending[battle.enemy]) then return false end + battle._gbaInitialItemEntries = nil + local activated = false + for _, actor in ipairs(Schedule.battlers(battle)) do + if battle:holdItemTrigger(actor, "entry") then activated = true end + end + return activated +end + function Schedule.moveEnd(battle, completedFrame) local frame = completedFrame or battle._gbaMoveFrame if frame then diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index 97725c9c7..f490c845c 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -81,16 +81,17 @@ function C.plan(battle, user, action) if not V.isGen3() then return action end - action = require("src.battle.Gen3Encore").plan(user, action) - if - not (user and action) - or action.special - or action.kind and action.kind ~= "move" - or user.charging - or user.thrashTurns and user.thrashTurns > 0 + if not (user and action) or action.special or action.kind and action.kind ~= "move" then + return action + end + local locked = user.charging + or user.thrashTurns and user.thrashTurns > 0 and user.thrashMove or user.rageMove - or (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove - then + if locked then + return Choices.withMove(action, locked.id) + end + action = require("src.battle.Gen3Encore").plan(user, action) + if (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove then return action end if diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 02f7a16ac..cffbe23bf 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -471,6 +471,9 @@ end function HeldItems.onEntry(battle, holder) if not (battle and isAlive(holder)) then return false end if require("src.core.GameVersion").isGen3() then + if battle._gbaInitialItemEntries then + return require("src.battle.GbaItemSchedule").initialLinkEntry(battle, holder) + end return battle:holdItemTrigger(holder, "entry") end -- PLATINUM'S AMULET COIN counts on EITHER side: battle_lib.c's diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index ee0f9b3f8..5c41ba24d 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -49,8 +49,15 @@ end -- no name for comes back as a number and matches no rule below, which is -- the same silence an unknown ability gets. local function nativeEffect(battler, native) - if type(native) == "table" then return native.effect, native.parameter, HoldItems.defOf(battler) end - local def = HoldItems.defOf(battler) + local def, item = HoldItems.defOf(battler) + -- The native Choice Band entry is intrinsic (ITEM_HOLD[186]); mutable + -- descriptive metadata cannot remove it. Public hook substitutions still + -- run below, and item ids in other generations keep their existing reader. + if require("src.core.GameVersion").isGen3() + and (tonumber(item) or tonumber(def and def.index)) == 186 then + return "CHOICE_BAND", 0, def + end + if type(native) == "table" then return native.effect, native.parameter, def end if not def then return nil end local effect,param=require('src.inventory.PolishedBerries').held(def,battler.mon) if effect then return effect,param,def end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index e07f326e8..1c56ede90 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -84,7 +84,14 @@ function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie, battle, data) if require("src.core.GameVersion").isGen3() and battle then local pa, pb = priority(aMove), priority(bMove) local O, C = require("src.battle.Gen3AIOrder"), require("src.battle.Gen3OrderCore") - local sa, sb = O.speed(a, battle, true), O.speed(b, battle, true) + local sa, sb + if invertTie then + sb = O.speed(b, battle, true) + sa = O.speed(a, battle, true) + else + sa = O.speed(a, battle, true) + sb = O.speed(b, battle, true) + end if pa ~= pb then return pa > pb end if sa ~= sb then return sa > sb end return (C.result(sa, sb, function() return rng(0, 1) end) == 0) ~= (invertTie == true) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 8cdceb04b..c0a039ffb 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -255,6 +255,7 @@ function LinkBattle.new(game, net, opts) or "RATTATA", 5) self.kind = "link" self.linkRole = role + if require("src.core.GameVersion").isGen3() then self._gbaInitialItemEntries = {} end self.net = net -- BattleState:update only runs while it's the top of the state -- stack, but the player can push PartyMenu/ChoiceBox/NamingScreen on @@ -438,7 +439,15 @@ function LinkBattle.new(game, net, opts) local selectedMine,selectedTheirs=myAction,theirAction if require("src.core.GameVersion").isGen3() then local Choice=require("src.battle.Gen3ChoiceBand") - myAction,theirAction=Choice.plan(s,s.player,myAction),Choice.plan(s,s.enemy,theirAction) + -- Held hooks can be stateful. Both simulations query the canonical + -- host actor first, as the spectator does, regardless of local side. + if role == "host" then + myAction = Choice.plan(s,s.player,myAction) + theirAction = Choice.plan(s,s.enemy,theirAction) + else + theirAction = Choice.plan(s,s.enemy,theirAction) + myAction = Choice.plan(s,s.player,myAction) + end local Rage=require("src.battle.Gen3Rage") Rage.selected(s,s.player,myAction);Rage.selected(s,s.enemy,theirAction) end @@ -745,6 +754,7 @@ function LinkBattle.newSpectator(game, net, opts) local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species or "RATTATA", 5) self.kind = "link" -- exact same visual treatment as a real link battle + if require("src.core.GameVersion").isGen3() then self._gbaInitialItemEntries = {} end self.spectating = true -- Tournament.lua's marker: don't report a result for this one self.net = net game.linkNet = net diff --git a/tests/enemy_actions/choice_band.lua b/tests/enemy_actions/choice_band.lua index 50b80ee78..e368043c5 100644 --- a/tests/enemy_actions/choice_band.lua +++ b/tests/enemy_actions/choice_band.lua @@ -1,7 +1,7 @@ -- Controlled native-shaped inputs. The source engine and its handlers remain -- unchanged; all mounted item/move tables belong to this fixture. local M = {} -local NUMBERS = { TACKLE = 33, SCRATCH = 10, STRUGGLE = 165, SLEEP_TALK = 214, BATON_PASS = 226 } +local NUMBERS = { TACKLE = 33, SCRATCH = 10, FLY = 19, STRUGGLE = 165, SLEEP_TALK = 214, BATON_PASS = 226 } function M.fixture(root, backend, version) local native = backend == "gen1" local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( @@ -115,6 +115,50 @@ function M.fixture(root, backend, version) function f.moveId(name) return native and NUMBERS[name] or f.ids[name] end + function f.at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + function f.field(double) + local b = f.fresh("TACKLE").battle + b.double = double + if not native then + b:syncSides() + end + if double then + local Copy = require("src.mods.Merge").deepCopy + for id = 2, 3 do + local mon = Copy(f.at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = f.at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + f.moves(a, { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + f.hold(a, nil) + if native then + a.mon.maxHp, a.type1, a.type2, a.ability, a.mon.ability = 400, 0, 0, 0, 0 + a.mon.attack, a.mon.defense, a.mon.spAtk, a.mon.spDef, a.mon.speed = 50, 50, 50, 50, 100 - id * 10 + else + a.curTypes, a.abilityOverride, a.mon.stats.hp = { "NORMAL" }, false, 400 + a.curStats.attack, a.curStats.defense, a.curStats.spatk, a.curStats.spdef = 50, 50, 50, 50 + a.curStats.speed = 100 - id * 10 + end + end + b.phase, b.queue, b.current = "moveSelect", {}, nil + return b + end return f end function M.run(root, backend, version) diff --git a/tests/enemy_actions/choice_planning.lua b/tests/enemy_actions/choice_planning.lua new file mode 100644 index 000000000..628208622 --- /dev/null +++ b/tests/enemy_actions/choice_planning.lua @@ -0,0 +1,230 @@ +-- A one-action native plan isolates forced-move resolution without recreating +-- its formula or consuming the separate whole-turn ordering word. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local function number(value) + if native then + return tonumber(value) + end + local def = f.data.moves[value] + return def and tonumber(def.index or def.numId) + end + local function view(user) + local pp, learned = {}, {} + for slot = 1, 4 do + pp[slot] = native and user.mon.pp[slot] or user.curMoves[slot].pp + learned[slot] = number(native and user.mon.moves[slot] or user.curMoves[slot].id) + end + return { + hp = user.mon.hp, + choice = user.choicedMove, + pp = pp, + learned = learned, + last = number(user[native and "lastMoveId" or "lastMove"]), + } + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "bound", + "unbound", + "same_move", + "empty_item", + "effect_only_item", + "empty_override", + "suppressed", + "metadata_cleared", + "struggle", + "struggle_ghost", + "struggle_rock", + "first_duplicate_empty", + "duplicate_selected", + "missing_choice", + "locked_pp_empty", + "encore", + "charging", + "replace_moves", + "replace_reordered_moves", + }) do + local b = f.field(double) + local user, targetId = f.at(b, id), id % 2 == 0 and id + 1 or id - 1 + local target = f.at(b, targetId) + if kind ~= "unbound" then + user.choicedMove = 33 + end + local item = 186 + if kind == "empty_item" or kind == "empty_override" then + item = nil + end + if kind == "effect_only_item" then + item = 529 + end + f.hold(user, item) + if native then + f.data.gen3Items._byId[186].holdEffect = kind == "metadata_cleared" and 0 or 29 + else + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + end + local struggle = kind:match("^struggle") ~= nil + local selected = kind == "same_move" and 1 or struggle and 4 or 2 + if kind == "struggle_ghost" or kind == "struggle_rock" then + if native then + target.type1 = kind == "struggle_ghost" and 7 or 5 + target.type2 = target.type1 + else + target.curTypes = { kind == "struggle_ghost" and "GHOST" or "ROCK" } + end + end + if kind == "first_duplicate_empty" or kind == "duplicate_selected" then + f.moves(user, { "TACKLE", "SCRATCH", "TACKLE", "STRUGGLE" }) + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + if kind == "duplicate_selected" then + selected = 3 + end + end + if kind == "locked_pp_empty" then + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + end + if kind == "missing_choice" then + f.moves(user, { "SCRATCH", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + end + if kind == "encore" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("TACKLE") + user[native and "expEncoreSlot" or "encoreSlot"] = 1 + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + end + if kind == "charging" then + if native then + user.expLockedMove = 19 + else + user.charging = { id = f.ids.FLY, pp = 1 } + end + end + local trace, draws, events = {}, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local plan, plannedMove, plannedSlot, targetType, plannedTarget + if native then + local action = + { kind = "move", move = user.mon.moves[selected], slot = selected, target = targetId } + local sourcePlan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [id] = action } + ) + plan = sourcePlan[1] + plannedMove, plannedSlot, targetType, plannedTarget = + plan.move, plan.slot, plan.targetType, plan.target + else + local slot = user.curMoves[selected] + local action = require("src.battle.EnemyMoveChoice").withMove(slot, slot.id, slot, selected) + action.kind, action.target = "move", targetId + action.targetType = tonumber(b.data.moves[slot.id].target) or 0 + plan = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + plannedMove, plannedSlot, targetType, plannedTarget = + plan.id, plan.slot, plan.targetType, plan.target + end + off() + T.eq(#draws, 0, "one-action native forced planning consumes no whole-turn RNG") + T.eq( + #trace, + (kind == "unbound" or kind == "encore" or kind == "charging") and 0 or 1, + "only an applicable Choice Band planner performs the held check" + ) + local plannerTrace = Copy(trace) + local oldPP, oldSlot + if kind == "replace_moves" or kind == "replace_reordered_moves" then + oldSlot = native and user.mon.pp or user.curMoves + oldPP = native and oldSlot[plannedSlot] or oldSlot[plannedSlot].pp + f.moves( + user, + kind == "replace_moves" and { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" } + or { "SCRATCH", "TACKLE", "SLEEP_TALK", "STRUGGLE" }, + 7 + ) + end + -- Charging is a planner-only input here. Its actual release needs the + -- real charge setup; all ordinary and Encore plans execute normally. + if kind ~= "charging" then + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + targetId, + plan.move, + plan.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + else + b:executeAction(user, target, plan) + end + stop() + noCrit() + end + local retainedPP = oldSlot and (native and oldSlot[plannedSlot] or oldSlot[plannedSlot].pp) + if oldSlot then + T.eq(retainedPP, oldPP, "replaced learned records do not spend old PP") + end + T.eq(#f.result.errors, 0, "planner and actual move callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + move = number(plannedMove), + slot = plannedSlot, + targetType = targetType, + target = type(plannedTarget) == "table" and plannedTarget.id or plannedTarget, + plannerTrace = plannerTrace, + draws = draws, + events = events, + user = view(user), + targetState = view(target), + replacedOldPP = retainedPP, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_selection.lua b/tests/enemy_actions/choice_selection.lua new file mode 100644 index 000000000..59cb565da --- /dev/null +++ b/tests/enemy_actions/choice_selection.lua @@ -0,0 +1,189 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double) + local b = f.fresh("TACKLE").battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local user = at(b, id) + user.mon.nickname, user.mon.hp = "P" .. id, 400 + f.moves(user, { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + f.hold(user, nil) + end + b.phase, b.queue, b.current = "moveSelect", {}, nil + return b + end + local function pp(user, slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "unbound", + "bound", + "effect_only_item", + "empty_override", + "suppressed", + "metadata_cleared", + "locked_pp_empty", + "all_pp_empty", + "missing_locked_move", + "disabled_locked", + "encore_other", + "taunted_status_lock", + "tormented_lock", + "tormented_struggle", + "imprisoned_lock", + }) do + local b = fresh(double) + local user, foe = at(b, id), at(b, id % 2 == 0 and id + 1 or id - 1) + f.hold(user, kind == "effect_only_item" and 529 or kind ~= "empty_override" and 186 or nil) + user.choicedMove = kind ~= "unbound" and 33 or nil + if native then + f.data.gen3Items._byId[186].holdEffect = kind == "metadata_cleared" and 0 or 29 + else + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + end + if kind == "locked_pp_empty" then + pp(user, 1, 0) + end + if kind == "all_pp_empty" then + for slot = 1, 4 do + pp(user, slot, 0) + end + end + if kind == "missing_locked_move" then + f.moves(user, { "SCRATCH", "SLEEP_TALK", "BATON_PASS", "STRUGGLE" }) + end + if kind == "disabled_locked" then + user[native and "expDisabledMove" or "disabledMove"] = f.moveId("TACKLE") + user[native and "expDisableTurns" or "disabledTurns"] = 3 + if not native then + user.disabledSlot = 1 + end + end + if kind == "encore_other" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("SCRATCH") + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + user[native and "expEncoreSlot" or "encoreSlot"] = 2 + end + if kind == "taunted_status_lock" then + user.choicedMove = 214 + user[native and "expTauntedTurns" or "tauntTurns"] = 2 + end + if kind == "tormented_lock" or kind == "tormented_struggle" then + user[native and "expTormented" or "tormented"] = true + user[native and "lastMoveId" or "lastMove"] = + f.moveId(kind == "tormented_struggle" and "STRUGGLE" or "TACKLE") + if kind == "tormented_struggle" then + user.choicedMove = 165 + f.hold(user, 529) + end + end + if kind == "imprisoned_lock" then + foe[native and "expImprison" or "imprison"] = true + f.moves(foe, { "TACKLE" }) + end + local trace, draws = {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if ctx.mon.nickname == user.mon.nickname then + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + if kind == "suppressed" then + return nil, 0 + end + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local mask + if native then + mask = require("src.core.game3.battle.engine").moveLimitations( + user, + require("src.core.game3.battle.adapter").new(b) + ) + else + mask = require("src.battle.Gen3MoveSelection").aiMask(b, user) + end + local aiAllowed, manualAllowed = {}, {} + for slot = 1, 4 do + aiAllowed[slot] = not mask[slot] + if native then + manualAllowed[slot] = require("src.core.game3.battle.commands").moveUsable(b, slot, id) + else + manualAllowed[slot] = require("src.battle.Gen3MoveSelection").manualReason( + b, + user, + user.curMoves[slot], + slot + ) == nil + end + end + off() + T.eq(#trace, 1, "AI limitations read the holder once; manual admission does not read the held effect") + T.eq(#draws, 0, "read-only native limitations consume no RNG") + T.eq(#f.result.errors, 0, "complete held query has no swallowed callback failures") + if kind == "tormented_struggle" then + T.eq(manualAllowed[4], true, "manual Torment keeps its Struggle exception") + end + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + aiAllowed = aiAllowed, + manualAllowed = manualAllowed, + choice = user.choicedMove, + trace = trace, + draws = draws, + hp = user.mon.hp, + foeHp = foe.mon.hp, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_wild.lua b/tests/enemy_actions/choice_wild.lua new file mode 100644 index 000000000..56b7cfda5 --- /dev/null +++ b/tests/enemy_actions/choice_wild.lua @@ -0,0 +1,150 @@ +-- Exercise the actual ordinary-wild controller, including its deliberately +-- unfiltered nonempty-slot draw before forced-move planning. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, rows = f.T, {} + local function number(id) + if native then + return require("src.mods.Gen3Compat").moveId(id) + end + local def = f.data.moves[id] + return def and tonumber(def.index or def.numId) + end + for _, double in ipairs({ false, true }) do + for id = 1, double and 3 or 1, 2 do + for _, kind in ipairs({ + "bound", + "unbound", + "all_pp_empty", + "locked_pp_empty", + "effect_only_item", + "empty_override", + "suppressed", + "encore", + "taunt", + "torment", + "retry_empty", + "bounded_empty_fallback", + }) do + for pick = 0, 3 do + local b = f.field(double) + local user = f.at(b, id) + f.hold(user, kind == "effect_only_item" and 529 or 186) + if kind == "empty_override" then + f.hold(user, nil) + end + if kind ~= "unbound" then + user.choicedMove = 33 + end + local function setPP(slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + if kind == "all_pp_empty" then + for slot = 1, 4 do + setPP(slot, 0) + end + elseif kind == "locked_pp_empty" then + setPP(1, 0) + end + if kind == "encore" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("SCRATCH") + user[native and "expEncoreSlot" or "encoreSlot"] = 2 + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + elseif kind == "taunt" then + user[native and "expTauntedTurns" or "tauntTurns"] = 3 + elseif kind == "torment" then + user[native and "expTormented" or "tormented"] = true + user[native and "lastMoveId" or "lastMove"] = f.moveId("TACKLE") + elseif kind == "retry_empty" or kind == "bounded_empty_fallback" then + if native then + user.mon.moves[pick + 1] = 0 + else + user.curMoves[pick + 1] = { id = 0, pp = 0 } + end + user.choicedMove = nil + end + local trace, draws, selections = {}, {}, 0 + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + selections = selections + 1 + value = pick + if kind == "retry_empty" and selections > 1 then + value = (pick + 1) % 4 + end + end + draws[#draws + 1] = { lo, hi, value } + return value + end + local action + if native then + action = require("src.core.game3.battle.ai").chooseAction(b, id) + else + action = b:vanillaEnemyAction(user) + end + local selected = { + move = number(native and action.move or action.id), + slot = action.slot, + target = action.target, + scores = action.scores, + } + local plan + if native then + plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [id] = action } + )[1] + else + plan = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + off() + T.eq(#f.result.errors, 0, "ordinary wild callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + pick = pick, + selected = selected, + planned = { + move = number(native and plan.move or plan.id), + slot = plan.slot, + target = plan.target, + }, + trace = trace, + draws = draws, + hp = user.mon.hp, + choice = user.choicedMove, + } + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index fdc695275..e0989e786 100644 --- a/tests/engine/gen3_battle_protocol.lua +++ b/tests/engine/gen3_battle_protocol.lua @@ -4,9 +4,9 @@ local V, F, H = require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") local Copy = require("src.mods.Merge").deepCopy local data, before = T.fixtures.fresh(), V.get() --- Legacy hash retained; native hash independently audits revision 9 -> 10 only. +-- Legacy hash retained; native hash independently audits revision 10 -> 11 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "3a2adb7fa1caee3b" +local NATIVE = "3e2986da83083f86" for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do V.set(version) local gba = V.isGen3() @@ -15,7 +15,7 @@ for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "fire gba and NATIVE or LEGACY, "runtime command revision participates in cached fingerprints" ) - T.eq(F.nativeBattleRevision(), gba and 10 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 11 or nil, "only GBA advertises the new command revision") local game = { data = data, save = { player = { name = "RULES" } } } local current = H.hello(game, "battle") local legacy = Copy(current) diff --git a/tests/engine/gen3_choice_link.lua b/tests/engine/gen3_choice_link.lua new file mode 100644 index 000000000..2faf2e39d --- /dev/null +++ b/tests/engine/gen3_choice_link.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 10 + T.eq(current.nativeBattleRevision, 11, "native Choice Band advertises revised commands") + T.eq(H.checkCompat(current, older), "subset", "old held commands cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "revised held commands reject symmetrically") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller refuses before party exchange") + T.eq(state.noticeExits, true, "revision mismatch cannot start a battle") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + gs.save.version, gs.save.options.ruleset = version, "gen3_emerald" + for i, game in ipairs({ ga, gb }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + local mon = game.save.party[1] + mon.nickname = i == 1 and "HOST" or "GUEST" + mon.moves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + mon.item = "ITEM_186" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + } + -- A stateful consumer uses every earlier read, including entry/priority + -- and residual calls. Reordering cannot be hidden by equal final counts. + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + return "HELD_NORMAL_BOOST", 10 + #trace % 3 + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local messages = inbox + inbox = {} + return messages + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked battle uses the GBA damage profile") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.curStats.attack, actor.curStats.defense, actor.curTypes = 51, 51, { "NORMAL" } + actor.stages.attack, actor.stages.defense = 1, -1 + end + end + local firstTurn = false + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + battle.moveIndex = firstTurn and 2 or 1 + require("src.core.Input").pressed = (i == 3 or firstTurn or not battle.localHashes[1]) and { a = true } + or {} + game.stack:update(1 / 60) + end + if + a.localHashes[1] + and b.localHashes[1] + and s.turnCount == 1 + and s.phase == "waitBoth" + and not firstTurn + then + firstTurn = true + for _, battle in ipairs({ a, b, s }) do + for _, actor in ipairs({ battle.player, battle.enemy }) do + T.eq(actor.choicedMove, 33, "completed native move binds the current learned move") + actor.mon.item = nil + end + end + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both menu-driven turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "every active/volatile/bench component agrees") + end + print("CHOICE_LINK_TRACES " .. require("src.link.Json").encode({ + version = version, + host = seen[a], + guest = seen[b], + spectator = seen[s], + })) + T.same(seen[a], seen[b], "both real link roles see the same complete ordered held reads") + T.same(seen[a], seen[s], "real spectator sees the same complete ordered held reads") + T.check(#(seen[a] or {}) > 8, "complete held traces include planning and move-end consumers") + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator retains the stateful host damage result") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator retains the stateful guest damage result") + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.player.mon.moves[1].pp, 9, "first native menu spends Tackle PP") + T.eq(battle.player.mon.moves[2].pp, 9, "lost physical item admits Scratch on the next native menu") + T.eq(battle.player.choicedMove, 33, "item loss retains the learned choice identity") + T.check(battle.player.mon.hp < 1000 and battle.enemy.mon.hp < 1000, "real attacks damage both battlers") + T.eq(battle.result, nil, "no hidden link or choice failure ends the mirror") + end + T.eq(#f.result.errors, 0, "all real callback chains remain error-free") + f.close() +end +T.finish("native Choice Band real link menus, item loss and command compatibility") diff --git a/tests/engine/gen3_choice_planning.lua b/tests/engine/gen3_choice_planning.lua new file mode 100644 index 000000000..b82eab294 --- /dev/null +++ b/tests/engine/gen3_choice_planning.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_planning.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band planning and actual PP ownership") diff --git a/tests/engine/gen3_choice_public.lua b/tests/engine/gen3_choice_public.lua new file mode 100644 index 000000000..312b944ed --- /dev/null +++ b/tests/engine/gen3_choice_public.lua @@ -0,0 +1,118 @@ +-- Real public Loader facade and native keyboard controller; no admission or +-- dispatch helper is replaced by the test. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local api = f.e.api.battle + local function fresh() + local b = f.field(false) + f.game.stack.states = { b } + b.introSlide, b.moveIndex = 0, 2 + f.hold(b.player, 186) + b.player.choicedMove = 33 + return b + end + local function submit(id, slot) + local snapshot = assert(api:snapshot()) + return api:submit({ id = id, revision = snapshot.revision, kind = "move", slot = slot }) + end + for _, kind in ipairs({ "physical", "metadata_cleared", "effect_only", "empty_override", "suppressed" }) do + local b, reads, draws = fresh(), 0, 0 + if kind == "effect_only" then + f.hold(b.player, 529) + end + if kind == "empty_override" then + f.hold(b.player, nil) + end + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads = reads + 1 + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo) + draws = draws + 1 + return lo + end + local snapshot = assert(api:snapshot()) + local blocked = kind == "physical" or kind == "metadata_cleared" or kind == "suppressed" + T.eq(snapshot.moves[1].disabled, false, "locked learned slot stays selectable") + T.eq(snapshot.moves[2].disabled, blocked, "snapshot follows physical native Choice Band") + if blocked then + local ok, reason = submit(1, 2) + T.eq(ok, nil, "public action refuses other move") + T.check(reason and reason:find("choice", 1, true) ~= nil, "public refusal identifies native choice") + T.eq(b.phase, "moveSelect", "public refusal preserves current prompt") + T.eq(#b.queue, 0, "public refusal adds no native messages") + end + T.eq(reads, 0, "public snapshot/admission never asks the held-effect hook") + T.eq(draws, 0, "public snapshot/admission consumes no RNG") + T.eq(b.player.curMoves[1].pp, 10, "query/refusal preserves locked slot PP") + T.eq(b.player.curMoves[2].pp, 10, "query/refusal preserves other slot PP") + off() + end + f.data.items.ITEM_186.holdEffect = "CHOICE_BAND" + for _, change in ipairs({ "choice", "item", "imprison", "same_restriction_choice" }) do + local b = fresh() + if change == "same_restriction_choice" then + f.hold(b.player, nil) + end + local snapshot = assert(api:snapshot()) + local old = { id = 1, revision = snapshot.revision, kind = "move", slot = 2 } + if change == "item" then + f.hold(b.player, nil) + elseif change == "imprison" then + b.enemy.imprison = true + else + b.player.choicedMove = 10 + end + local ok, reason = api:submit(old) + T.eq(ok, nil, "changed native admission invalidates offered intent") + T.eq(reason, "stale battle context", "staleness is reported before native dispatch") + T.eq(b.phase, "moveSelect", "stale intent preserves prompt") + T.eq(b.player.curMoves[2].pp, 10, "stale intent spends no PP") + end + do + local b, reads, draws = fresh(), 0, 0 + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads = reads + 1 + return nextRead(ctx) + end) + b.rng = function(lo) + draws = draws + 1 + return lo + end + f.game.input.wasPressed = function(_, key) + return key == "a" + end + b:update(1 / 60) + T.eq(b.phase, "messages", "actual A input presents native refusal") + T.eq(b.afterQueue, "moveSelect", "native refusal returns to attack selection") + for _ = 1, 240 do + if b.phase ~= "messages" then + break + end + b:update(1 / 60) + end + T.eq(b.phase, "moveSelect", "actual message queue returns to the move selector") + T.eq(b.moveIndex, 2, "native refusal keeps the selected cursor") + T.eq(reads, 0, "manual refusal never calls held-effect consumers") + T.eq(draws, 0, "manual refusal never advances RNG") + T.eq(b.player.curMoves[2].pp, 10, "manual refusal spends no PP") + off() + f.game.input.wasPressed = function() + return false + end + local ok, err = submit(1, 1) + T.eq(ok, true, "fresh public intent admits the locked learned move: " .. tostring(err)) + end + T.eq(#f.result.errors, 0, "public and native callbacks remain error-free") + f.close() +end +T.finish("native GBA Choice Band public admission and native refusal return") diff --git a/tests/engine/gen3_choice_selection.lua b/tests/engine/gen3_choice_selection.lua new file mode 100644 index 000000000..fac4e5b84 --- /dev/null +++ b/tests/engine/gen3_choice_selection.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_selection.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band AI and manual selection contracts") diff --git a/tests/engine/gen3_choice_wild.lua b/tests/engine/gen3_choice_wild.lua new file mode 100644 index 000000000..7a062498c --- /dev/null +++ b/tests/engine/gen3_choice_wild.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_wild.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band ordinary-wild selection") diff --git a/tests/engine/gen3_held_damage_link.lua b/tests/engine/gen3_held_damage_link.lua index ea732ba0e..7743b832e 100644 --- a/tests/engine/gen3_held_damage_link.lua +++ b/tests/engine/gen3_held_damage_link.lua @@ -6,7 +6,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local current = H.hello(f.game, "battle") local older = Copy(current) older.nativeBattleRevision = 9 - T.eq(current.nativeBattleRevision, 10, "native held damage advertises revised commands") + T.check(current.nativeBattleRevision >= 10, "native held damage advertises revised commands") T.eq(H.checkCompat(current, older), "subset", "old held commands cannot enter a full battle") T.eq(H.checkCompat(older, current), "subset", "revised held commands reject symmetrically") for _, host in ipairs({ false, true }) do diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 532e9dee6..7b24ea7ae 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -11,6 +11,43 @@ GEN1_REF = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" +MODES = [ + (2, 'choices'), + (3, 'choices'), + (3, 'arguments'), + (3, 'records'), + (3, 'targets'), + (3, 'follow_me'), + (3, 'helping_hand'), + (3, 'spit_up'), + (3, 'beat_up'), + (3, 'beat_up_content'), + (3, 'beat_up_adjustment'), + (3, 'beat_up_side_party'), + (3, 'critical_context'), + (3, 'constrained_party'), + (3, 'accuracy'), + (3, 'accuracy_execution'), + (3, 'secondary'), + (3, 'secondary_execution'), + (3, 'party_codec'), + (3, 'party_codec_hooks'), + (3, 'lock_on'), + (3, 'baton_pass'), + (3, 'encore'), + (3, 'disable'), + (3, 'multi_hit'), + (3, 'rage'), + (3, 'triple_kick'), + (3, 'sleep_talk'), + (3, 'sleep_talk_content'), + (3, 'choice_band_command'), + (3, 'choice_band_selection'), + (3, 'choice_band_planning'), + (3, 'choice_band_wild'), +] + + def git(root, *args): return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() @@ -21,6 +58,8 @@ def main(): 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", required=True, type=Path) + parser.add_argument("--mode", action="append", choices=sorted({name for _, name in MODES}), + help="Run selected modes; omission runs every contract") args = parser.parse_args() source, host = args.gen1.resolve(), args.gen2.resolve() if git(source, "rev-parse", "HEAD") != GEN1_REF or git(source, "status", "--porcelain"): @@ -33,7 +72,9 @@ def main(): for p in paths}, "runs": [], "comparisons": []} failed = False for version in ("firered", "emerald"): - for api, mode in ((2, "choices"), (3, "choices"), (3, "arguments"), (3, "records"), (3, "targets"), (3, "follow_me"), (3, "helping_hand"), (3, "spit_up"), (3, "beat_up"), (3, "beat_up_content"), (3, "beat_up_adjustment"), (3, "beat_up_side_party"), (3, "critical_context"), (3, "constrained_party"), (3, "accuracy"), (3, "accuracy_execution"), (3, "secondary"), (3, "secondary_execution"), (3, "party_codec"), (3, "party_codec_hooks"), (3, "lock_on"), (3, "baton_pass"), (3, "encore"), (3, "disable"), (3, "multi_hit"), (3, "rage"), (3, "triple_kick"), (3, "sleep_talk"), (3, "sleep_talk_content")): + for api, mode in MODES: + if args.mode and mode not in args.mode: + continue values = {} for backend, root in (("gen1", source), ("gen2", host)): cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index db72b840b..e0e4acb13 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,13 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "choice_band_command" then +if mode == "choice_band_wild" then + rows = dofile(root .. "/tests/enemy_actions/choice_wild.lua").run(root, backend, version) +elseif mode == "choice_band_planning" then + rows = dofile(root .. "/tests/enemy_actions/choice_planning.lua").run(root, backend, version) +elseif mode == "choice_band_selection" then + rows = dofile(root .. "/tests/enemy_actions/choice_selection.lua").run(root, backend, version) +elseif mode == "choice_band_command" then rows = dofile(root .. "/tests/enemy_actions/choice_band.lua").run(root, backend, version) elseif mode == "sleep_talk_content" then rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").content(root, backend, version) From 32e69c0b56f34deb0f47483ed8580497d8a9ae5d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:14:16 +0100 Subject: [PATCH 83/90] Preserve delegated native slot ownership for PP-bound wild actions --- docs/native-gen3-choice-band.md | 7 +++++++ src/battle/EnemyMoveChoice.lua | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index ffbc1402e..328a1d656 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -66,6 +66,13 @@ passing `selection-fixed`, `planning-struggle-fixed`, `wild-input-fixed`, fixture errors and genuine runtime mismatches are kept separately in those raw outputs. Initial full link traces failed even when turn hashes matched. +The first clean full contract matrix at `be88b1fd` retains a failed result: +eight host workers expose a delegated selected-slot PP regression in the new +ordinary-wild action records. The delegate now captures an already-bound +action's original slot too. All 16 focused workers / 220 paired rows for +the unchanged choices/arguments/records contracts pass after the correction; +the full matrix must be repeated before acceptance. + ## Required validation before a PR - Execute `tests/engine/gen3_choice_band.lua` and compare the same complete diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index 1f4d90278..7e53c555d 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -142,7 +142,9 @@ function M.delegate(battle, battler, action) return describe(action) end for i, slot in ipairs(battler.curMoves or {}) do - if action == slot then + -- Native wild selection already returns a separate PP-bound action. + -- A delegated edit must retain that selected slot, just like a raw slot. + if M.slot(action) == slot then local copy = shallow(action) copy.slot = i return describe(copy), { raw = action, before = shallow(copy), owner = battler } From 8eca1f98aa60c95eb04ac69d0a85d754402aa95e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:35:24 +0100 Subject: [PATCH 84/90] Retain legacy choice handles and validate actual authored and resumed calls --- docs/native-gen3-choice-band.md | 31 ++++- src/battle/BattleCommands.lua | 4 + src/battle/BattleState.lua | 8 +- src/battle/EffectRegistry.lua | 4 + src/battle/EnemyMoveChoice.lua | 35 +++++- src/battle/GbaItemSchedule.lua | 14 ++- src/battle/Gen3ChoiceBand.lua | 6 +- tests/enemy_actions/baton_pass.lua | 26 +++- tests/enemy_actions/choice_band.lua | 10 +- tests/enemy_actions/choice_content.lua | 119 ++++++++++++++++++ tests/enemy_actions/choice_planning.lua | 3 +- tests/enemy_actions/sleep_talk.lua | 22 +++- tests/engine/gen3_choice_baton.lua | 5 + tests/engine/gen3_choice_calls.lua | 5 + tests/engine/gen3_choice_content.lua | 5 + tests/engine/gen3_choice_legacy.lua | 75 +++++++++++ tools/enemy-action-contract-check/check.py | 3 + .../imported-sleep-talk-smoke.lua | 24 ++++ tools/enemy-action-contract-check/worker.lua | 8 +- 19 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 tests/enemy_actions/choice_content.lua create mode 100644 tests/engine/gen3_choice_baton.lua create mode 100644 tests/engine/gen3_choice_calls.lua create mode 100644 tests/engine/gen3_choice_content.lua create mode 100644 tests/engine/gen3_choice_legacy.lua diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index 328a1d656..dc073e240 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -19,7 +19,7 @@ planner applies the choice. Manual Torment also retains the Struggle exception. Planning binds the actual forced learned slot's PP and priority while keeping the declared turn event separate. A later replacement of the move table must rebind PP to the current slot. Native caller provenance is explicit: Sleep -Talk and the unchanged native Mirror Move/Metronome selectors differ from a +Talk and the unchanged default Mirror Move/Metronome selectors differ from a custom handler's `ctx.callMove`. Active Baton Pass caller frames follow the replacement battler slot. Generic custom callers retain their own move-end command rather than inheriting the native caller's early return. @@ -73,6 +73,30 @@ action's original slot too. All 16 focused workers / 220 paired rows for the unchanged choices/arguments/records contracts pass after the correction; the full matrix must be repeated before acceptance. +The repeated clean matrix at `32e69c0b` passes all 132 workers / 78,444 paired +rows. Its full engine run retains three additional compatibility failures: +existing public refusal wording and legacy native choice-handle identity. +Focused repetitions pass after retaining the existing reasons and keeping +selection-only fields private until planning consumes them. The real double +controller now plans each actor once, rather than planning its two left +actors twice; a new legacy/target/controller suite passes 84 assertions. + +Additional focused native comparisons pass for actual Sleep Talk dispatch +with a physical Choice Band, including custom callers and failure paths; +real synchronous/asynchronous Baton Pass with the item; and an unchanged +authored API-3 `ctx.callMove("TACKLE")` handler. The latter exposed a genuine +canonical-ID resolution gap and outer caller bookkeeping; the GBA helper now +resolves the same canonical references and preserves both move-end commands. +Forced status-move planning also retains the resulting target byte in single +battles. Raw failed probes are retained alongside their corrected reruns. + +Twelve actual imported FireRed/Emerald scenarios pass with `--choice-band`, +including three sleeping calls, item loss before waking/ordinary selection, +zero-PP children, empty/forbidden pools, early wake and awake failure. These +explicitly use GBA rules and constructed parties/public menus, without a +normal SAVE, ROM/data distribution, graphics or playthrough certification. +Final clean full/matrix repetitions remain pending. + ## Required validation before a PR - Execute `tests/engine/gen3_choice_band.lua` and compare the same complete @@ -95,3 +119,8 @@ the full matrix must be repeated before acceptance. Publish a regular English Gen2 PR only after acceptance; Gen1 stays read-only. No complete feature/combined acceptance or PR publication is claimed yet. + +Mirror Move/Metronome selector/catalog/RNG parity, the complete held-script +King's Rock/Shell Bell phase, other attack-canceller phases and full native +playthrough/graphics remain separate gaps. The new caller provenance alone +does not certify those selectors or full scripts. diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua index 614eba704..c766b522f 100644 --- a/src/battle/BattleCommands.lua +++ b/src/battle/BattleCommands.lua @@ -214,6 +214,10 @@ local function moveReason(b, actor, slot, move) end if require("src.core.GameVersion").isGen3() then local why = require("src.battle.Gen3MoveSelection").manualReason(b, actor, move, slot) + if why == "disabled" then return "move is disabled" end + if why == "pp" then return "move has no PP" end + if why == "taunt" then return "move is blocked by Taunt" end + if why == "torment" then return "move is blocked by Torment" end return why and ("move is blocked by native GBA rules: " .. why) or nil end if require("src.battle.Gen3Disable").blocked(actor, move, slot) then diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 712c3c5b1..31a5fe32f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5566,7 +5566,6 @@ function BattleState:resolveTurn(playerAction) local nativeActions if GameVersion.isGen3() then local Choice = require("src.battle.Gen3ChoiceBand") - playerAction, enemyAction = Choice.plan(self, self.player, playerAction), Choice.plan(self, self.enemy, enemyAction) local Rage = require("src.battle.Gen3Rage") if self:isDouble() then nativeActions = {} @@ -5581,7 +5580,10 @@ function BattleState:resolveTurn(playerAction) Rage.selected(self, b, action) end end + playerAction, enemyAction = nativeActions[0], nativeActions[1] else + playerAction = Choice.plan(self, self.player, playerAction) + enemyAction = Choice.plan(self, self.enemy, enemyAction) Rage.selected(self, self.player, playerAction) Rage.selected(self, self.enemy, enemyAction) end @@ -8433,6 +8435,10 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima if self._gbaActionFrame then self._gbaActionFrame.moved = true end performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) if not frame.finished and not frame.nativeCalled and not frame.suspended then Schedule.moveEnd(self) end + if frame.called and not frame.nativeCalled and not frame.suspended and frame.move + and tonumber(frame.move.gen3Effect) ~= 127 and frame.move.effect ~= "BATON_PASS_EFFECT" then + frame.user.lastMove = frame.moveId + end self._gbaMoveFrame = parent if not isCalled and not parent and not self._gbaActionFrame and not frame.suspended then Schedule.afterAction(self) end end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 593fb3eb9..68b491647 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -76,6 +76,10 @@ function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled) return battle:accuracyRoll(move, user, target) end, callMove = function(moveId) + if require("src.core.GameVersion").isGen3() then + moveId = require("src.battle.EnemyMoveChoice").resolveMove(battle.data, moveId) + if not moveId then return nil end + end return battle:performMove(user, target, { id = moveId, pp = 1 }, true) end, side = function(who) return battle:sideOf(who) end, diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index 7e53c555d..a95663556 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -6,6 +6,7 @@ local M = {} local nativeViews = setmetatable({}, { __mode = "k" }) local publicReferences = setmetatable({}, { __mode = "k" }) local slots = setmetatable({}, { __mode = "k" }) +local pendingSelections = setmetatable({}, { __mode = "k" }) local function shallow(row) local out = {} for k, v in pairs(row) do @@ -61,6 +62,7 @@ local function resolve(data, ref) end return found, row end +M.resolveMove = resolve local function bind(action, slot) slots[action] = slot action.pp = nil @@ -87,12 +89,27 @@ function M.withMove(action, id, forcedSlot, forcedIndex) copy.id = id if nativeViews[action] then nativeViews[copy] = shallow(nativeViews[action]) - if forcedIndex then nativeViews[copy].slot = forcedIndex end + if forcedIndex then + nativeViews[copy].slot = forcedIndex + end + end + if forcedIndex then + copy.slot = forcedIndex end - if forcedIndex then copy.slot = forcedIndex end publicReferences[copy] = publicReferences[action] return bind(copy, forcedSlot or M.slot(action)) end +function M.resolveSelection(action) + if type(action) ~= "table" then + return action + end + local selected = pendingSelections[action] + pendingSelections[action] = nil + if selected and selected.id == action.id then + return M.withMove(selected, selected.id, action, selected.slot) + end + return action +end function M.exhausted(action) return slots[action] ~= nil and action.pp <= 0 end @@ -147,7 +164,13 @@ function M.delegate(battle, battler, action) if M.slot(action) == slot then local copy = shallow(action) copy.slot = i - return describe(copy), { raw = action, before = shallow(copy), owner = battler } + return describe(copy), + { + raw = action, + before = shallow(copy), + owner = battler, + originalSlot = action ~= slot and slot or nil, + } end end return describe(action) @@ -155,6 +178,12 @@ end function M.normalize(data, battler, value, delegated, captured) if captured and value == delegated then if captured.owner == battler and same(value, captured.before) then + if captured.originalSlot then + -- Existing API-1/2 callbacks retain their native slot handle. Keep + -- selection-only fields private until planning/execution consumes it. + pendingSelections[captured.originalSlot] = shallow(captured.raw) + return captured.originalSlot + end return captured.raw end local id = resolve(data, value.move or value.id) diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index 3749b64c5..9ffd72d43 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -7,7 +7,7 @@ function Schedule.battlers(battle) for _, b in battle:activeBattlers() do list[#list + 1] = b end - if battle.linkRole == "guest" and not battle:isDouble() then + if battle.linkRole == "guest" and #list == 2 and not battle:isDouble() then list[1], list[2] = list[2], list[1] end return list @@ -15,13 +15,19 @@ end function Schedule.initialLinkEntry(battle, holder) local pending = battle._gbaInitialItemEntries - if not pending then return nil end + if not pending then + return nil + end pending[holder] = true - if not (pending[battle.player] and pending[battle.enemy]) then return false end + if not (pending[battle.player] and pending[battle.enemy]) then + return false + end battle._gbaInitialItemEntries = nil local activated = false for _, actor in ipairs(Schedule.battlers(battle)) do - if battle:holdItemTrigger(actor, "entry") then activated = true end + if battle:holdItemTrigger(actor, "entry") then + activated = true + end end return activated end diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index f490c845c..9777beb7e 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -81,6 +81,7 @@ function C.plan(battle, user, action) if not V.isGen3() then return action end + action = action and Choices.resolveSelection(action) if not (user and action) or action.special or action.kind and action.kind ~= "move" then return action end @@ -103,9 +104,7 @@ function C.plan(battle, user, action) local forced, index = learned(battle, user, user.choicedMove) if forced and (tonumber(forced.pp) or 1) > 0 then local out = Choices.withMove(action, forced.id, forced, index) - if battle:isDouble() then - out.targetType = tonumber(definition(battle, forced).target) or 0 - end + out.targetType = tonumber(definition(battle, forced).target) or 0 planned[out] = { index = index, id = forced.id } return out end @@ -113,6 +112,7 @@ function C.plan(battle, user, action) return action end function C.execution(user, action) + action = Choices.resolveSelection(action) local plan = planned[action] if plan then local slot = (user.curMoves or user.mon.moves or {})[plan.index] diff --git a/tests/enemy_actions/baton_pass.lua b/tests/enemy_actions/baton_pass.lua index 9b238866a..e3fbdcdc8 100644 --- a/tests/enemy_actions/baton_pass.lua +++ b/tests/enemy_actions/baton_pass.lua @@ -106,9 +106,19 @@ function M.fixture(root, backend, version) dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) return f end -function M.run(root, backend, version) +function M.run(root, backend, version, options) + options = options or {} local native = backend == "gen1" local f = M.fixture(root, backend, version) + if options.choiceBand and not native then + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} local events, draws = {}, {} local stop = f.e.api.events:on("battle.battler_switched", function(e) @@ -149,6 +159,12 @@ function M.run(root, backend, version) for id = 0, double and 3 or 1 do local a = at(b, id) a.mon.nickname = "P" .. id + if options.choiceBand then + a.mon.item = native and 186 or "ITEM_186" + if native then + a.item = 186 + end + end if native then a.mon.moves, a.mon.pp, a.mon.maxPp = { 226, 33 }, { 20, 10 }, { 20, 10 } a.type1, a.type2, a.ability, a.mon.ability = 0, nil, 0, 0 @@ -197,6 +213,7 @@ function M.run(root, backend, version) last = a.lastMove and (native and a.lastMove or f.data.moves[a.lastMove].index), disabled = a.disabledTurns or a.expDisabledTurns, taunt = a.tauntTurns or a.expTauntedTurns, + choice = options.choiceBand and a.choicedMove or nil, } end for _, double in ipairs({ false, true }) do @@ -322,6 +339,13 @@ function M.run(root, backend, version) T.eq(at(b, id).stages.attack, 2, "attack stage is inherited") T.eq(at(b, id).stages.defense, -1, "defense stage is inherited") end + if options.choiceBand then + T.eq( + at(b, id).choicedMove, + kind == "none" and 226 or nil, + "actual successful Baton Pass skips binding; failed Baton Pass binds its own move" + ) + end rows[#rows + 1] = { double = double, id = id, diff --git a/tests/enemy_actions/choice_band.lua b/tests/enemy_actions/choice_band.lua index e368043c5..dc28f2189 100644 --- a/tests/enemy_actions/choice_band.lua +++ b/tests/enemy_actions/choice_band.lua @@ -2,14 +2,15 @@ -- unchanged; all mounted item/move tables belong to this fixture. local M = {} local NUMBERS = { TACKLE = 33, SCRATCH = 10, FLY = 19, STRUGGLE = 165, SLEEP_TALK = 214, BATON_PASS = 226 } -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, options) + options = options or {} local native = backend == "gen1" local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( root, backend, version, - 3, - "unified", + options.api or 3, + options.format or "unified", function(data, _, ids, rom, names) for _, row in ipairs({ { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, @@ -81,7 +82,8 @@ function M.fixture(root, backend, version) data.constants.generation = 3 data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} end - end + end, + options.entry ) if native then local Items = require("src.core.game3.items_data") diff --git a/tests/enemy_actions/choice_content.lua b/tests/enemy_actions/choice_content.lua new file mode 100644 index 000000000..60b2b2356 --- /dev/null +++ b/tests/enemy_actions/choice_content.lua @@ -0,0 +1,119 @@ +-- Same authored API-3 caller on both engines. This observes actual dispatch, +-- events, RNG, HP and choice/PP ownership, not the complete held-script trace. +local M = {} +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PORTABLE_CHOOSER", {kind="primary",run=function(ctx) + ctx.callMove("TACKLE");return {} + end})) + assert(mod.content.moves:register("PORTABLE_CALLER", {id="PORTABLE_CALLER",name="PORTABLE_CALLER", + type="NORMAL",power=0,accuracy=100,pp=10,effect="PORTABLE_CHOOSER"})) + mod.exports.api=mod + end]=] + for _, learned in ipairs({ false, true }) do + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture( + root, + backend, + version, + { entry = entry } + ) + local number, registries, previousRegistryData + if native then + number = assert(require("src.mods.Gen3Compat").moveId("PORTABLE_CALLER")) + local moves = require("src.core.game3.battle.moves") + moves._rom[number] = assert(f.data.gen3Moves._rom[number]) + moves.BY_NUM[number], moves._numByName.PORTABLE_CALLER = "PORTABLE_CALLER", number + registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + end + local function name(value) + if not value then + return nil + end + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local def = f.data.moves[value] + if def then + return def.name + end + for _, row in pairs(f.data.moves) do + if type(row) == "table" and tonumber(row.index) == value then + return row.name + end + end + end + for id = 0, 1 do + local b = f.field(false) + local user, target = f.at(b, id), f.at(b, 1 - id) + f.hold(user, 186) + if native then + user.mon.moves = { number, learned and 33 or 10 } + user.mon.pp, user.mon.maxPp = { 10, 10 }, { 10, 10 } + else + user.mon.moves = + { { id = "PORTABLE_CALLER", pp = 10 }, { id = learned and f.ids.TACKLE or f.ids.SCRATCH, pp = 10 } } + user.curMoves = user.mon.moves + end + local draws, events = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + 1 - id, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + noCrit() + local pp = native and user.mon.pp or { user.curMoves[1].pp, user.curMoves[2].pp } + f.T.eq( + name(user.choicedMove), + learned and "TACKLE" or "PORTABLE_CALLER", + "custom ctx.callMove retains its own child/outer move-end boundaries" + ) + f.T.same(pp, { 9, 10 }, "actual authored caller spends only its learned PP") + f.T.eq(#events, 2, "actual authored caller and child both emit their public move event") + f.T.eq(#f.result.errors, 0, "authored callback completes without swallowed errors") + rows[#rows + 1] = { + learned = learned, + actor = id, + choice = name(user.choicedMove), + last = name(user[native and "lastMoveId" or "lastMove"]), + pp = pp, + userHp = user.mon.hp, + targetHp = target.mon.hp, + draws = draws, + events = events, + } + end + if registries then + registries.bind(previousRegistryData) + end + f.close() + end + return rows +end +return M diff --git a/tests/enemy_actions/choice_planning.lua b/tests/enemy_actions/choice_planning.lua index 628208622..3c1ef7f35 100644 --- a/tests/enemy_actions/choice_planning.lua +++ b/tests/enemy_actions/choice_planning.lua @@ -30,6 +30,7 @@ function M.run(root, backend, version) for id = 0, double and 3 or 1 do for _, kind in ipairs({ "bound", + "forced_status", "unbound", "same_move", "empty_item", @@ -53,7 +54,7 @@ function M.run(root, backend, version) local user, targetId = f.at(b, id), id % 2 == 0 and id + 1 or id - 1 local target = f.at(b, targetId) if kind ~= "unbound" then - user.choicedMove = 33 + user.choicedMove = kind == "forced_status" and 214 or 33 end local item = 186 if kind == "empty_item" or kind == "empty_override" then diff --git a/tests/enemy_actions/sleep_talk.lua b/tests/enemy_actions/sleep_talk.lua index cd0a970c6..6d5293032 100644 --- a/tests/enemy_actions/sleep_talk.lua +++ b/tests/enemy_actions/sleep_talk.lua @@ -78,9 +78,19 @@ function M.fixture(root, backend, version, entry) fixture.nativeRows, fixture.nativeNames = nativeRows, nativeNames return fixture end -function M.run(root, backend, version) +function M.run(root, backend, version, options) + options = options or {} local native = backend == "gen1" local f = M.fixture(root, backend, version) + if options.choiceBand and not native then + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end local T, Copy = f.T, require("src.mods.Merge").deepCopy local rows, draws, used, damage = {}, {}, {}, {} local offUsed = f.e.api.events:on("battle.move_used", function(e) @@ -128,6 +138,7 @@ function M.run(root, backend, version) sub = tonumber(a.substituteHP) or 0, attack = a.stages.attack or 0, rage = not not a.rage, + choice = options.choiceBand and a.choicedMove or nil, } end return all @@ -258,6 +269,12 @@ function M.run(root, backend, version) def(701).index = nil end local user, target = at(b, uid), at(b, tid) + if options.choiceBand then + user.mon.item = native and 186 or "ITEM_186" + if native then + user.item = 186 + end + end user.mon.status, user.sleepTurns = "SLP", 4 if kind == "awake" or kind == "called_awake" then user.mon.status, user.sleepTurns = nil, nil @@ -367,6 +384,9 @@ function M.run(root, backend, version) T.eq(#used, 2, "Sleep Talk and the chosen move each emit one public event") T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "only the selected caller spends PP") T.eq(user.sleepTurns, 3, "nested called execution does not decrement sleep again") + if options.choiceBand then + T.eq(user.choicedMove, 214, "actual called dispatch binds Sleep Talk rather than its child") + end end if banned diff --git a/tests/engine/gen3_choice_baton.lua b/tests/engine/gen3_choice_baton.lua new file mode 100644 index 000000000..00dcdf2e4 --- /dev/null +++ b/tests/engine/gen3_choice_baton.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/baton_pass.lua").run(".", "gen2", version, { choiceBand = true }) +end +require("tests.modkit").finish("native GBA Choice Band real Baton Pass continuations") diff --git a/tests/engine/gen3_choice_calls.lua b/tests/engine/gen3_choice_calls.lua new file mode 100644 index 000000000..76979d6d7 --- /dev/null +++ b/tests/engine/gen3_choice_calls.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/sleep_talk.lua").run(".", "gen2", version, { choiceBand = true }) +end +require("tests.modkit").finish("native GBA Choice Band actual called-move dispatch") diff --git a/tests/engine/gen3_choice_content.lua b/tests/engine/gen3_choice_content.lua new file mode 100644 index 000000000..13a4968d2 --- /dev/null +++ b/tests/engine/gen3_choice_content.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_content.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band authored callMove ownership") diff --git a/tests/engine/gen3_choice_legacy.lua b/tests/engine/gen3_choice_legacy.lua new file mode 100644 index 000000000..9dd12f409 --- /dev/null +++ b/tests/engine/gen3_choice_legacy.lua @@ -0,0 +1,75 @@ +-- Unchanged legacy callback handles and learned records survive a native +-- wild target selection; private planning fields still drive actual execution. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture( + ".", + "gen2", + version, + { api = mode[1], format = mode[2] } + ) + local b = f.field(true) + local user, right = b.enemy, b:battlerAt(2) + b.rng = function(lo, hi) + return hi == 65535 and 2 or lo + end + local before = require("src.mods.Merge").deepCopy(user.curMoves[1]) + f.e.choose(function(nextAction, ...) + return nextAction(...) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local action = b:enemyAction(user) + T.eq(action, user.curMoves[1], "forwarding retains the actual legacy learned-slot handle") + T.same(user.curMoves[1], before, "native target/scores never enter party move records") + local planned = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + T.eq(planned.target, 2, "private native target survives forwarding and planning") + T.eq(planned.slot, 1, "private native slot survives forwarding and planning") + T.same(planned.scores, { 0, 0, 0, 0 }, "native wild scores survive forwarding") + b:executeAction(user, b.player, planned) + T.check(right.mon.hp < 400, "actual execution hits the selected opposite right flank") + T.eq(b.player.mon.hp, 400, "actual execution preserves the unselected left flank") + T.eq(user.curMoves[1].pp, 9, "actual execution spends the original learned PP") + T.eq(user.curMoves[1].target, nil, "consuming the private target preserves party shape") + T.eq(#f.result.errors, 0, "every callback level forwards without errors") + f.close() + end + do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local b, checks, trace = f.field(true), {}, {} + for _, actor in b:activeBattlers() do + f.hold(actor, 186) + actor.choicedMove = 33 + end + b.pendingActions = { [2] = b:battlerAt(2).curMoves[1] } + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + trigger = ctx.trigger, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + } + if ctx.trigger == "check" then + checks[#checks + 1] = ctx.mon.nickname + end + return nextRead(ctx) + end) + b:resolveTurn(b.player.curMoves[1]) + -- AI masks for the two enemy actors precede one planner query per slot. + T.same( + checks, + { "P1", "P3", "P0", "P1", "P2", "P3" }, + "actual doubles plans each actor once in native slot order" + ) + print( + "CHOICE_DOUBLE_PLANNING_READS " .. require("src.link.Json").encode({ version = version, trace = trace }) + ) + T.eq(#f.result.errors, 0, "actual double planning callbacks remain error-free") + f.close() + end +end +T.finish("native GBA choice planning retains legacy handles and targets") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 7b24ea7ae..6e9368962 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -45,6 +45,9 @@ (3, 'choice_band_selection'), (3, 'choice_band_planning'), (3, 'choice_band_wild'), + (3, 'choice_band_calls'), + (3, 'choice_band_content'), + (3, 'choice_band_baton'), ] diff --git a/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua index 794e03222..def653df2 100644 --- a/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua +++ b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua @@ -1,6 +1,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local cacheRoot = assert(arg[1], "pass the local Gen2Recomp cache root") +local choiceBand = arg[2] == "--choice-band" local rows = {} for _, version in ipairs({ "firered", "emerald" }) do for _, mode in ipairs({ "repeat", "zero_pp", "empty", "forbidden", "early_wake", "awake" }) do @@ -17,7 +18,9 @@ for _, version in ipairs({ "firered", "emerald" }) do end local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure) T.link.prepare(f.data) + f.game.save.options.ruleset = "gen3_emerald" local b = f.fresh("TACKLE", { enemy = true }).battle + assert(b.ruleset.baseDamageStyle == "gba", "imported scenario requires the actual GBA ruleset") b:syncSides() local function id(n) return assert(f.data.constants.moveOrder[n]) @@ -39,6 +42,16 @@ for _, version in ipairs({ "firered", "emerald" }) do b.enemy.mon.moves = { { id = id(33), pp = f.data.moves[id(33)].pp } } b.enemy.curMoves = b.enemy.mon.moves b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + if choiceBand then + local item + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == 186 then + item = key + break + end + end + b.player.mon.item = assert(item, "imported physical Choice Band must exist") + end b.player.mon.status, b.player.sleepTurns = "SLP", 4 if mode == "awake" then b.player.mon.status, b.player.sleepTurns = nil, nil @@ -82,6 +95,9 @@ for _, version in ipairs({ "firered", "emerald" }) do end local turns = mode == "repeat" and 4 or 1 for turn = 1, turns do + if choiceBand and turn == 4 then + b.player.mon.item = nil + end if turn > 1 then submit({ kind = "menu", choice = "fight" }) end @@ -125,6 +141,12 @@ for _, version in ipairs({ "firered", "emerald" }) do assert(b.player.mon.status == nil and b.player.sleepTurns == nil) end assert(b._gbaMoveFrame == nil, "nested dispatcher restores its frame") + if choiceBand then + assert( + b.player.choicedMove == ((mode == "repeat" or mode == "zero_pp") and 214 or nil), + "actual imported caller binds only after its completed move-end command" + ) + end rows[#rows + 1] = { version = version, mode = mode, @@ -136,6 +158,8 @@ for _, version in ipairs({ "firered", "emerald" }) do childPP = moves[2].pp, status = b.player.mon.status, sleep = b.player.sleepTurns, + choiceBand = choiceBand or nil, + choice = b.player.choicedMove, } off() f.close() diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index e0e4acb13..ee2ae7726 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,13 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "choice_band_wild" then +if mode == "choice_band_baton" then + rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version, { choiceBand = true }) +elseif mode == "choice_band_content" then + rows = dofile(root .. "/tests/enemy_actions/choice_content.lua").run(root, backend, version) +elseif mode == "choice_band_calls" then + rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version, { choiceBand = true }) +elseif mode == "choice_band_wild" then rows = dofile(root .. "/tests/enemy_actions/choice_wild.lua").run(root, backend, version) elseif mode == "choice_band_planning" then rows = dofile(root .. "/tests/enemy_actions/choice_planning.lua").run(root, backend, version) From 9329838e00f59ed44639f38e8c0cd39b7a66a30d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:39:53 +0100 Subject: [PATCH 85/90] Retain API-3 positional actions alongside legacy native choice handles --- src/battle/EnemyMoveChoice.lua | 6 +++++- src/mods/CallbackRecords.lua | 1 + tests/engine/gen3_choice_legacy.lua | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua index a95663556..0e6660f5b 100644 --- a/src/battle/EnemyMoveChoice.lua +++ b/src/battle/EnemyMoveChoice.lua @@ -7,6 +7,7 @@ local nativeViews = setmetatable({}, { __mode = "k" }) local publicReferences = setmetatable({}, { __mode = "k" }) local slots = setmetatable({}, { __mode = "k" }) local pendingSelections = setmetatable({}, { __mode = "k" }) +local portableViews = setmetatable({}, { __mode = "k" }) local function shallow(row) local out = {} for k, v in pairs(row) do @@ -116,6 +117,9 @@ end function M.nativeView(action) return nativeViews[action] end +function M.markPortable(action) + portableViews[action] = true +end function M.rememberReference(action, key, public, native) local refs = publicReferences[action] if not refs then @@ -178,7 +182,7 @@ end function M.normalize(data, battler, value, delegated, captured) if captured and value == delegated then if captured.owner == battler and same(value, captured.before) then - if captured.originalSlot then + if captured.originalSlot and not portableViews[value] then -- Existing API-1/2 callbacks retain their native slot handle. Keep -- selection-only fields private until planning/execution consumes it. pendingSelections[captured.originalSlot] = shallow(captured.raw) diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index c268198b2..8416d27ae 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -309,6 +309,7 @@ function R.new(loader, name, generation, api3) local out = shallow(raw) local nativeAction = portableEnemyAction and kind == "action" and EnemyMoveChoice.nativeView(raw) if nativeAction then + EnemyMoveChoice.markPortable(raw) local def = moveDef(raw.id) local number = def and tonumber(def.index or def.numId) if not number and type(raw.id) == "number" then number = raw.id end diff --git a/tests/engine/gen3_choice_legacy.lua b/tests/engine/gen3_choice_legacy.lua index 9dd12f409..9c56c4112 100644 --- a/tests/engine/gen3_choice_legacy.lua +++ b/tests/engine/gen3_choice_legacy.lua @@ -23,7 +23,12 @@ for _, version in ipairs({ "firered", "emerald" }) do return false end) local action = b:enemyAction(user) - T.eq(action, user.curMoves[1], "forwarding retains the actual legacy learned-slot handle") + if mode[1] < 3 then + T.eq(action, user.curMoves[1], "forwarding retains the actual legacy learned-slot handle") + else + T.neq(action, user.curMoves[1], "API-3 retains its separate native action and positional target") + T.eq(action.target, 2, "API-3 exposes the positional target before execution") + end T.same(user.curMoves[1], before, "native target/scores never enter party move records") local planned = require("src.battle.Gen3ChoiceBand").plan(b, user, action) T.eq(planned.target, 2, "private native target survives forwarding and planning") From a7b14c7dcb7932f257c025cb63e3c258ec2bd8a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:51:13 +0100 Subject: [PATCH 86/90] Preserve explicit PP policy and native caller failure boundaries --- docs/native-gen3-choice-band.md | 17 +++ src/battle/BattleState.lua | 4 + src/battle/Gen3ChoiceBand.lua | 6 +- src/battle/Gen3MoveSelection.lua | 2 +- tests/enemy_actions/choice_call_failure.lua | 118 +++++++++++++++++++ tests/engine/gen3_choice_call_failure.lua | 5 + tests/engine/gen3_choice_legacy.lua | 25 ++++ tools/enemy-action-contract-check/check.py | 1 + tools/enemy-action-contract-check/worker.lua | 4 +- 9 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 tests/enemy_actions/choice_call_failure.lua create mode 100644 tests/engine/gen3_choice_call_failure.lua diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index dc073e240..d9cf22685 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -97,6 +97,23 @@ explicitly use GBA rules and constructed parties/public menus, without a normal SAVE, ROM/data distribution, graphics or playthrough certification. Final clean full/matrix repetitions remain pending. +Clean feature `9329838e` passes 142/151 engine suites, SDK 3/3 and B30, with +the exact same 25 inherited FAIL lines as unchanged #230. Its full native +matrix and both imported modes pass. The first combined candidate retains an +additional #162 explicit-unlimited-enemy-PP regression (260/261); native +finite-PP selection/planning now preserves that explicit non-link exception. +The API-3 positional action remains separate while API-1/2 native slot handles +retain their identity. Focused tests also verify the private target reaches +the actual right-hand opponent and learned move records remain unchanged. + +A new actual failed Mirror Move comparison records every held read and all +RNG/events/HP/PP for 24 rows over both profiles. The unmodified reference +returns before move-end; the initial host incorrectly bound Mirror Move and +ran extra item commands. The corrected default-caller failure retains only +its separate after-action sweep. The raw failing host observation and source +reference are retained. This does not certify successful Mirror Move target / +history selection or Metronome catalog/RNG parity. + ## Required validation before a PR - Execute `tests/engine/gen3_choice_band.lua` and compare the same complete diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 31a5fe32f..93620914a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8272,6 +8272,10 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni -- already said its failure text if record and record.callsMove then local pick = record.callsMove(ctx) + if not pick and record.gbaNativeChoiceCaller == record.callsMove then + local frame = self._gbaMoveFrame + if frame then frame.finished = true end + end -- Mirror Move never plays its own anim (MetronomePickMove does; -- MirrorMoveCopyMove only reloads the copied move or prints fail) if move.id == "MIRROR_MOVE" or not pick then diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index 9777beb7e..3260fb346 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -6,6 +6,10 @@ local Choices = require("src.battle.EnemyMoveChoice") local C = {} local planned = setmetatable({}, { __mode = "k" }) local builtinCache = setmetatable({}, { __mode = "k" }) +function C.enemyUnlimited(battle, user) + return not user.isPlayer and battle.kind ~= "link" and battle.ruleset and battle.ruleset.enemyUnlimitedPP + or false +end function C.signature(user) local value = user.choicedMove if value == false then @@ -102,7 +106,7 @@ function C.plan(battle, user, action) and C.identity(battle, action) ~= 165 then local forced, index = learned(battle, user, user.choicedMove) - if forced and (tonumber(forced.pp) or 1) > 0 then + if forced and ((tonumber(forced.pp) or 1) > 0 or C.enemyUnlimited(battle, user)) then local out = Choices.withMove(action, forced.id, forced, index) out.targetType = tonumber(definition(battle, forced).target) or 0 planned[out] = { index = index, id = forced.id } diff --git a/src/battle/Gen3MoveSelection.lua b/src/battle/Gen3MoveSelection.lua index f8a9ff643..bfa8aad7f 100644 --- a/src/battle/Gen3MoveSelection.lua +++ b/src/battle/Gen3MoveSelection.lua @@ -68,7 +68,7 @@ function Selection.aiMask(battle, user) for slot = 1, 4 do local move = moves(user)[slot] local bad = reason(battle, user, move, slot, effect == "CHOICE_BAND") ~= nil - if move and tonumber(move.pp) == 0 then + if move and tonumber(move.pp) == 0 and not Choice.enemyUnlimited(battle, user) then bad = true end if diff --git a/tests/enemy_actions/choice_call_failure.lua b/tests/enemy_actions/choice_call_failure.lua new file mode 100644 index 000000000..ecc67ad03 --- /dev/null +++ b/tests/enemy_actions/choice_call_failure.lua @@ -0,0 +1,118 @@ +-- A failed native Mirror Move returns before its move-end script. Observe +-- its complete held sequence, rather than accepting only unchanged HP. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + if native then + local row = { + effect = 9, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 0, + priority = 0, + secondaryChance = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[119], moves.BY_NUM[119], moves._numByName.MIRROR_MOVE = row, "MIRROR_MOVE", 119 + f.data.gen3Moves._rom[119], f.data.gen3Pokemon._moveNames[119] = row, "MIRROR_MOVE" + else + f.data.moves.MOVE_119 = { + id = "MOVE_119", + index = 119, + name = "MIRROR_MOVE", + gen3Effect = 9, + effect = "MIRROR_MOVE_EFFECT", + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 0, + mirrorMoveAffected = false, + } + end + local rows = {} + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, bound in ipairs({ false, true }) do + local b = f.field(double) + local user, target = f.at(b, id), f.at(b, id % 2 == 0 and id + 1 or id - 1) + f.hold(user, 186) + user.choicedMove = bound and 33 or nil + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 119, 33 }, { 10, 10 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_119", pp = 10 }, { id = f.ids.TACKLE, pp = 10 } } + user.curMoves = user.mon.moves + end + local trace, draws, events = {}, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + return nextRead(ctx) + end) + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id % 2 == 0 and id + 1 or id - 1, + 119, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + stop() + f.T.eq( + user.choicedMove, + bound and 33 or nil, + "failed native caller does not enter the choice command" + ) + f.T.eq(#trace, double and 4 or 2, "only the separate after-action item sweep remains") + f.T.eq(#draws, 0, "failed native caller consumes no selection or damage RNG") + f.T.eq(#f.result.errors, 0, "failed caller callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = id, + bound = bound, + choice = user.choicedMove, + pp = native and user.mon.pp[1] or user.curMoves[1].pp, + userHp = user.mon.hp, + targetHp = target.mon.hp, + trace = trace, + draws = draws, + events = events, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_choice_call_failure.lua b/tests/engine/gen3_choice_call_failure.lua new file mode 100644 index 000000000..311ff1241 --- /dev/null +++ b/tests/engine/gen3_choice_call_failure.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_call_failure.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band failed builtin caller boundary") diff --git a/tests/engine/gen3_choice_legacy.lua b/tests/engine/gen3_choice_legacy.lua index 9c56c4112..fd6bfa48e 100644 --- a/tests/engine/gen3_choice_legacy.lua +++ b/tests/engine/gen3_choice_legacy.lua @@ -42,6 +42,31 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(#f.result.errors, 0, "every callback level forwards without errors") f.close() end + do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local b = f.field(false) + b.ruleset = require("src.mods.Merge").deepCopy(b.ruleset) + b.ruleset.enemyUnlimitedPP = true + f.hold(b.enemy, 186) + b.enemy.choicedMove = 33 + for _, slot in ipairs(b.enemy.curMoves) do + slot.pp = 0 + end + b.rng = function(lo) + return lo + end + T.eq( + b:vanillaEnemyAction(b.enemy).id, + f.ids.TACKLE, + "explicit unlimited enemy policy admits dry learned moves" + ) + local planned = require("src.battle.Gen3ChoiceBand").plan(b, b.enemy, b.enemy.curMoves[2]) + T.eq(planned.id, f.ids.TACKLE, "explicit unlimited enemy policy retains the choice lock") + b:executeAction(b.enemy, b.player, planned) + T.check(b.player.mon.hp < 400, "explicit unlimited enemy policy executes the dry selected move") + T.eq(b.enemy.curMoves[1].pp, 0, "explicit unlimited enemy policy preserves dry PP") + f.close() + end do local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) local b, checks, trace = f.field(true), {}, {} diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 6e9368962..f0530a01d 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -48,6 +48,7 @@ (3, 'choice_band_calls'), (3, 'choice_band_content'), (3, 'choice_band_baton'), + (3, 'choice_band_call_failure'), ] diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index ee2ae7726..34bccbefc 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,7 +2,9 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local root, backend, version, api, mode = unpack(arg) local T = require("tests.modkit") local rows -if mode == "choice_band_baton" then +if mode == "choice_band_call_failure" then + rows = dofile(root .. "/tests/enemy_actions/choice_call_failure.lua").run(root, backend, version) +elseif mode == "choice_band_baton" then rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version, { choiceBand = true }) elseif mode == "choice_band_content" then rows = dofile(root .. "/tests/enemy_actions/choice_content.lua").run(root, backend, version) From 137d775b48864ab440276b8a7f4d5b32fbae15fa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:12:24 +0100 Subject: [PATCH 87/90] Keep forced move PP detached when the planned slot is removed --- docs/native-gen3-choice-band.md | 25 +++++++++++++ src/battle/Gen3ChoiceBand.lua | 8 +++-- tests/enemy_actions/choice_planning.lua | 47 ++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index d9cf22685..e2b3bac56 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -114,6 +114,31 @@ its separate after-action sweep. The raw failing host observation and source reference are retained. This does not certify successful Mirror Move target / history selection or Metronome catalog/RNG parity. +The later complete feature run at `a7b14c7d` passes 143/152 engine suites, +SDK 3/3 and B30, retaining the exact same 25 inherited FAIL lines as the +fresh #230 parent. The combined candidate at `fc41fcc5` passes 262/262 engine +suites, SDK 3/3 and B30. Both final cross-engine matrices were interrupted +for separately reserved graphics measurements. These completed full runs +do not certify the subsequently prepared missing-slot change. + +That pending regression replaces the learned move/PP arrays after forced +planning and removes the selected slot entirely. The unchanged native PP +command permits the planned move without spending retired or other current +slots. Prepared Pressure controls also distinguish this case from a present +replacement slot, which must spend two PP. The expanded comparison now passes +all 276 rows over both profiles, with all recorded planner payloads, RNG, +events, HP and current/retired PP equal. The exact saved old module fails 24 +rows, spending one or two PP from the retired slot. Full feature/combined +acceptance and publication remain pending. + +The planning fixture observes final PP and move-used events, but does not +observe PP inside the event callback. A separate actual-script diagnostic +retains 72 rows over both profiles, including 60 mismatches: ordinary and +Pressure callbacks observe PP before reduction in Gen1 and after reduction +in Gen2; zero-PP native failures still emit the event whereas Gen2 does not. +All raw rows are retained. This gap remains open; final-state equality does +not prove native event/PP-command timing. + ## Required validation before a PR - Execute `tests/engine/gen3_choice_band.lua` and compare the same complete diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index 3260fb346..13369eb2a 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -120,8 +120,12 @@ function C.execution(user, action) local plan = planned[action] if plan then local slot = (user.curMoves or user.mon.moves or {})[plan.index] - if slot and Choices.slot(action) ~= slot then - local rebound = Choices.withMove(action, plan.id, slot, plan.index) + if Choices.slot(action) ~= slot then + -- A removed native PP slot still permits the planned move, without + -- spending retired or other current learned records. Its temporary + -- action PP is consumed before the effect callback, as for an unowned call. + local owner = slot or { id = plan.id, pp = 1 } + local rebound = Choices.withMove(action, plan.id, owner, plan.index) planned[rebound] = plan return rebound end diff --git a/tests/enemy_actions/choice_planning.lua b/tests/enemy_actions/choice_planning.lua index 3c1ef7f35..1eb530345 100644 --- a/tests/enemy_actions/choice_planning.lua +++ b/tests/enemy_actions/choice_planning.lua @@ -15,8 +15,14 @@ function M.run(root, backend, version) local function view(user) local pp, learned = {}, {} for slot = 1, 4 do - pp[slot] = native and user.mon.pp[slot] or user.curMoves[slot].pp - learned[slot] = number(native and user.mon.moves[slot] or user.curMoves[slot].id) + if native then + pp[slot] = user.mon.pp[slot] + learned[slot] = number(user.mon.moves[slot]) + else + local move = user.curMoves[slot] + pp[slot] = move and move.pp + learned[slot] = move and number(move.id) + end end return { hp = user.mon.hp, @@ -49,13 +55,28 @@ function M.run(root, backend, version) "charging", "replace_moves", "replace_reordered_moves", + "replace_removed_slot", + "replace_moves_pressure", + "replace_removed_slot_pressure", }) do + local removedSlot = kind == "replace_removed_slot" or kind == "replace_removed_slot_pressure" + local pressure = kind == "replace_moves_pressure" or kind == "replace_removed_slot_pressure" local b = f.field(double) local user, targetId = f.at(b, id), id % 2 == 0 and id + 1 or id - 1 local target = f.at(b, targetId) if kind ~= "unbound" then user.choicedMove = kind == "forced_status" and 214 or 33 end + if removedSlot then + user.choicedMove = 10 + end + if pressure then + if native then + target.ability, target.mon.ability = 46, 46 + else + target.abilityOverride = "PRESSURE" + end + end local item = 186 if kind == "empty_item" or kind == "empty_override" then item = nil @@ -71,6 +92,9 @@ function M.run(root, backend, version) end local struggle = kind:match("^struggle") ~= nil local selected = kind == "same_move" and 1 or struggle and 4 or 2 + if removedSlot then + selected = 1 + end if kind == "struggle_ghost" or kind == "struggle_rock" then if native then target.type1 = kind == "struggle_ghost" and 7 or 5 @@ -165,12 +189,13 @@ function M.run(root, backend, version) ) local plannerTrace = Copy(trace) local oldPP, oldSlot - if kind == "replace_moves" or kind == "replace_reordered_moves" then + if kind == "replace_moves" or kind == "replace_reordered_moves" or removedSlot or pressure then oldSlot = native and user.mon.pp or user.curMoves oldPP = native and oldSlot[plannedSlot] or oldSlot[plannedSlot].pp f.moves( user, - kind == "replace_moves" and { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" } + removedSlot and { "TACKLE" } + or (kind == "replace_moves" or pressure) and { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" } or { "SCRATCH", "TACKLE", "SLEEP_TALK", "STRUGGLE" }, 7 ) @@ -206,6 +231,20 @@ function M.run(root, backend, version) if oldSlot then T.eq(retainedPP, oldPP, "replaced learned records do not spend old PP") end + if removedSlot then + T.check(target.mon.hp < 400, "removing the PP slot does not cancel the already planned move") + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 7, + "removing the planned slot does not spend another current move's PP" + ) + elseif pressure then + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 5, + "Pressure spends two PP from the replacement slot when it exists" + ) + end T.eq(#f.result.errors, 0, "planner and actual move callbacks stay error-free") rows[#rows + 1] = { case = kind, From 60d5a6fe06d2dcfd963d8b75f2c4b034ddefe458 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:13:49 +0100 Subject: [PATCH 88/90] Record actual move-used PP timing gap and corrected call probe --- docs/native-gen3-choice-band.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index e2b3bac56..4d13f66b8 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -133,9 +133,12 @@ acceptance and publication remain pending. The planning fixture observes final PP and move-used events, but does not observe PP inside the event callback. A separate actual-script diagnostic -retains 72 rows over both profiles, including 60 mismatches: ordinary and +retains 72 rows over both profiles, including 48 mismatches: ordinary and Pressure callbacks observe PP before reduction in Gen1 and after reduction in Gen2; zero-PP native failures still emit the event whereas Gen2 does not. +The first probe incorrectly supplied a selected PP slot for a called child; +native `run_called` supplies none. That first diagnostic is retained separately +from the corrected 72-row probe, with the same complete observation fields. All raw rows are retained. This gap remains open; final-state equality does not prove native event/PP-command timing. From e1d12f8e5deb486b9bba2102fdfb67bd881f27f9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:20:06 +0100 Subject: [PATCH 89/90] Run native contracts for Choice Band suite changes --- .github/workflows/enemy-action-contract.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 4e733ed30..de0644e3c 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -31,6 +31,7 @@ on: - 'tests/engine/gen3_rage*.lua' - 'tests/engine/gen3_triple_kick*.lua' - 'tests/engine/gen3_sleep_talk*.lua' + - 'tests/engine/gen3_choice*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] From 3ff39407073022beaf54628c3e0a0828ed2b10a8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:22:03 +0100 Subject: [PATCH 90/90] Document accepted Choice Band scope and remaining native hook gaps --- docs/native-gen3-choice-band.md | 276 +++++++++++++------------------- 1 file changed, 109 insertions(+), 167 deletions(-) diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md index 4d13f66b8..052710b58 100644 --- a/docs/native-gen3-choice-band.md +++ b/docs/native-gen3-choice-band.md @@ -1,171 +1,113 @@ # Native GBA Choice Band and move selection -Development branch; validation and publication are pending. Do not use this -branch as the accepted combined game-testing snapshot. Its parent is #230. - -The native move-end command always performs its Choice Band held-effect read -unless the action was disobedient. A new choice remembers the selected native -caller, excludes Struggle and successful Baton Pass, and is cleared when the -move disappears from the learned slots. Losing the item does not erase a -still-learned choice. Each later planner query obtains a fresh held answer. - -Manual admission is a separate consumer: it checks physical item 186, not -mutable held-effect metadata. The AI's four-slot limitations command uses the -held-effect hook and combines Choice Band with PP, Disable, Encore, Taunt, -Torment and Imprison. The ordinary wild controller only uses that mask for the -all-unusable shortcut; otherwise it draws nonempty native slots before the -planner applies the choice. Manual Torment also retains the Struggle exception. - -Planning binds the actual forced learned slot's PP and priority while keeping -the declared turn event separate. A later replacement of the move table must -rebind PP to the current slot. Native caller provenance is explicit: Sleep -Talk and the unchanged default Mirror Move/Metronome selectors differ from a -custom handler's `ctx.callMove`. Active Baton Pass caller frames follow the -replacement battler slot. Generic custom callers retain their own move-end -command rather than inheriting the native caller's early return. +This change is stacked on #230. It brings the native Choice Band command, +manual/AI selection consumers and forced-move PP ownership into the shared +Gen2 engine for FireRed and Emerald. + +## Behavior + +The move-end command performs its held-effect read unless the action was +disobedient. A new choice remembers the selected native caller, excludes +Struggle and successful Baton Pass, and clears when the move disappears from +learned slots. Losing the item retains a still-learned choice. Planner queries +read the current held effect again instead of caching a previous hook answer. + +Manual admission checks physical item 186. The AI's four-slot limitations +command uses the held-effect hook and combines Choice Band with PP, Disable, +Encore, Taunt, Torment and Imprison. Ordinary wild selection uses that mask +for the all-unusable shortcut; otherwise it draws nonempty native slots before +forced planning. Explicit non-link unlimited-enemy-PP rules remain supported. + +Forced planning binds the actual learned slot, priority and target byte while +keeping the declared turn event separate. Replacing the move table rebinds PP +to the current slot. Removing the selected slot still permits the planned move +without spending retired records or another current move's PP. Pressure spends +two PP when that replacement slot exists. + +Native caller provenance is explicit. Sleep Talk and unchanged default +Mirror Move/Metronome selectors differ from custom `ctx.callMove` handlers. +Active Baton Pass frames follow the replacement battler; failed Baton Pass +can bind a choice while successful Baton Pass does not. Failed native Mirror +Move returns before move-end. Custom callers retain both commands and resolve +canonical GBA move references such as `ctx.callMove("TACKLE")`. + +API-1/2 native choice handles retain their identity. Selection-only metadata +stays private until execution; API-3 positional actions remain separate from +learned records. Public snapshots, stale-intent checks and the actual keyboard +refusal/message-queue return path use the same native restrictions. + +GBA Struggle skips STAB and the type chart while retaining ordinary base, +critical and random-damage commands. Other-generation paths remain guarded. + +## Linked roles and protocol Choice identity participates in GBA link state and public-intent staleness. -The native command revision is 11; the party codec remains 1. The independent -revision-token audit reproduces `3e2986da83083f86` using the unchanged #230 -hasher and only revision 10 -> 11; older-generation digests remain unchanged. - -GBA Struggle skips STAB and the type chart while retaining its normal base, -critical and random-damage commands. The new planned-execution comparison -exposed this existing damage gap, including excess recoil. - -Real link simulations run held scans and planner/speed reads in canonical -host/guest order. The initial item-entry batch waits for both send-outs; -later individual switches keep their normal entry command. Host, guest and -the actual spectator compare complete ordered held traces, including entry, -priority, damage, move-end and residual calls. A stateful damage override -depends on all earlier reads. No payloads are filtered from this comparison. - -## Development evidence - -Before final fixture formatting, focused comparisons pass against the clean, -unchanged Gen1 reference: 80 move-end rows, 180 selection-mask/manual rows, -228 planned/executed rows and 288 ordinary-wild selection/planning rows. -The planner rows retain all recorded RNG calls, events, HP, learned moves, -current/replaced PP and complete planner held payloads. Their execution phase -does not record the full held-script trace; the separate command and link -probes have their explicitly different boundaries. - -Public Loader snapshots/submission, stale offers and the actual keyboard / -message queue refusal return pass 134 assertions. Actual host/guest/spectator -menus and two completed turns pass 86 assertions, with all captured held -reads and peer state components equal. Final formatted reruns, full suites, -negative controls, more caller/switch coverage and import acceptance are -still required. These are development results, not PR acceptance. - -Retained local evidence lives under `tmp/choice-band-qa/`: failed -`planning-first`, `planning-unbound-fixed`, `wild-first`, `public-first`, -`link-first`, `link-canonical-order` and `link-spectator-first` probes; -passing `selection-fixed`, `planning-struggle-fixed`, `wild-input-fixed`, -`public-helper-fixed`, `link-canonical-all-phases`, -`link-spectator-input-fixed` and `hash-audit` results. Early planner/wild/UI -fixture errors and genuine runtime mismatches are kept separately in those -raw outputs. Initial full link traces failed even when turn hashes matched. - -The first clean full contract matrix at `be88b1fd` retains a failed result: -eight host workers expose a delegated selected-slot PP regression in the new -ordinary-wild action records. The delegate now captures an already-bound -action's original slot too. All 16 focused workers / 220 paired rows for -the unchanged choices/arguments/records contracts pass after the correction; -the full matrix must be repeated before acceptance. - -The repeated clean matrix at `32e69c0b` passes all 132 workers / 78,444 paired -rows. Its full engine run retains three additional compatibility failures: -existing public refusal wording and legacy native choice-handle identity. -Focused repetitions pass after retaining the existing reasons and keeping -selection-only fields private until planning consumes them. The real double -controller now plans each actor once, rather than planning its two left -actors twice; a new legacy/target/controller suite passes 84 assertions. - -Additional focused native comparisons pass for actual Sleep Talk dispatch -with a physical Choice Band, including custom callers and failure paths; -real synchronous/asynchronous Baton Pass with the item; and an unchanged -authored API-3 `ctx.callMove("TACKLE")` handler. The latter exposed a genuine -canonical-ID resolution gap and outer caller bookkeeping; the GBA helper now -resolves the same canonical references and preserves both move-end commands. -Forced status-move planning also retains the resulting target byte in single -battles. Raw failed probes are retained alongside their corrected reruns. - -Twelve actual imported FireRed/Emerald scenarios pass with `--choice-band`, -including three sleeping calls, item loss before waking/ordinary selection, -zero-PP children, empty/forbidden pools, early wake and awake failure. These -explicitly use GBA rules and constructed parties/public menus, without a -normal SAVE, ROM/data distribution, graphics or playthrough certification. -Final clean full/matrix repetitions remain pending. - -Clean feature `9329838e` passes 142/151 engine suites, SDK 3/3 and B30, with -the exact same 25 inherited FAIL lines as unchanged #230. Its full native -matrix and both imported modes pass. The first combined candidate retains an -additional #162 explicit-unlimited-enemy-PP regression (260/261); native -finite-PP selection/planning now preserves that explicit non-link exception. -The API-3 positional action remains separate while API-1/2 native slot handles -retain their identity. Focused tests also verify the private target reaches -the actual right-hand opponent and learned move records remain unchanged. - -A new actual failed Mirror Move comparison records every held read and all -RNG/events/HP/PP for 24 rows over both profiles. The unmodified reference -returns before move-end; the initial host incorrectly bound Mirror Move and -ran extra item commands. The corrected default-caller failure retains only -its separate after-action sweep. The raw failing host observation and source -reference are retained. This does not certify successful Mirror Move target / -history selection or Metronome catalog/RNG parity. - -The later complete feature run at `a7b14c7d` passes 143/152 engine suites, -SDK 3/3 and B30, retaining the exact same 25 inherited FAIL lines as the -fresh #230 parent. The combined candidate at `fc41fcc5` passes 262/262 engine -suites, SDK 3/3 and B30. Both final cross-engine matrices were interrupted -for separately reserved graphics measurements. These completed full runs -do not certify the subsequently prepared missing-slot change. - -That pending regression replaces the learned move/PP arrays after forced -planning and removes the selected slot entirely. The unchanged native PP -command permits the planned move without spending retired or other current -slots. Prepared Pressure controls also distinguish this case from a present -replacement slot, which must spend two PP. The expanded comparison now passes -all 276 rows over both profiles, with all recorded planner payloads, RNG, -events, HP and current/retired PP equal. The exact saved old module fails 24 -rows, spending one or two PP from the retired slot. Full feature/combined -acceptance and publication remain pending. - -The planning fixture observes final PP and move-used events, but does not -observe PP inside the event callback. A separate actual-script diagnostic -retains 72 rows over both profiles, including 48 mismatches: ordinary and -Pressure callbacks observe PP before reduction in Gen1 and after reduction -in Gen2; zero-PP native failures still emit the event whereas Gen2 does not. -The first probe incorrectly supplied a selected PP slot for a called child; -native `run_called` supplies none. That first diagnostic is retained separately -from the corrected 72-row probe, with the same complete observation fields. -All raw rows are retained. This gap remains open; final-state equality does -not prove native event/PP-command timing. - -## Required validation before a PR - -- Execute `tests/engine/gen3_choice_band.lua` and compare the same complete - zero-damage move-end command inputs with unchanged, clean Gen1 - `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The fixture records every held - payload and RNG call; it does not filter the trace. These are command tests, - not proof of the complete damaging attack script. -- Extend actual planned-turn tests to physical/effect-only items, suppression, - loss/reacquisition, first matching slot/duplicates, empty PP, Struggle, - Encore/Disable/Taunt/Torment/Imprison, priority, targets and move replacement. -- Exercise public snapshots/submissions and real manual refusal/return paths, - including stale offers and preservation of other-generation behavior. -- Exercise native and custom called moves, failures, Transform and real - synchronous/asynchronous Baton Pass. Retain and report any remaining full - held-script trace mismatch; King's Rock/Shell Bell remains a separate gap. -- Run actual linked roles, old-peer refusal, independent hash audit, - maintained-LuaJIT cross-engine comparisons and complete feature/combined - suites. Compare the exact inherited FAIL Counter with the clean parent. -- Run imported-data scenarios, scoped AST-verified formatting and actionlint. - Publish a regular English Gen2 PR only after acceptance; Gen1 stays read-only. - -No complete feature/combined acceptance or PR publication is claimed yet. - -Mirror Move/Metronome selector/catalog/RNG parity, the complete held-script -King's Rock/Shell Bell phase, other attack-canceller phases and full native -playthrough/graphics remain separate gaps. The new caller provenance alone -does not certify those selectors or full scripts. +Host, guest and actual spectator use canonical actor order for held scans, +planning and speed reads. Initial item-entry waits for both send-outs; later +individual switches keep their normal entry command. The link test retains +complete ordered held payloads and a stateful damage override depending on +all earlier reads, rather than accepting equal hashes alone. + +Native command revision is **11**; party codec remains **1**. Older peers are +refused before party exchange. An independent audit applies only the revision +10-to-11 token change to the unchanged #230 hasher and reproduces native digest +`3e2986da83083f86`; the legacy digest remains `e33d251961f2a41d`. + +## Local acceptance + +Production was tested at feature `60d5a6fe` (runtime change `137d775b`) and +combined candidate `83346a19` (runtime change `bdd07012`). Later documentation +and workflow-path changes do not change those production sources. + +| Check | Isolated feature | Combined candidate | +| --- | --- | --- | +| Engine suites | 143/152 | 262/262 | +| SDK suites | 3/3 | 3/3 | +| Quick tiers including B30 | same inherited engine-tier failure | all pass | +| Enemy/action contracts | 148 workers, 80,132 paired rows | same counts, all equal | +| Ordinary GBA damage | 6,912 paired rows | same counts, all equal | +| Imported FireRed/Emerald scenarios | both modes pass | both modes pass | + +The isolated feature retains exactly the same Counter of 25 anchored FAIL +lines across nine inherited suites as a fresh unchanged #230 parent. No +failures are filtered. The combined candidate has zero FAIL lines. + +All four held-item CI formats also pass the strict native-schedule gate: +API-1 native, API-2 native, API-2 unified and API-3 unified; 40 workers / 1,914 +paired rows. The removed-slot/Pressure comparison passes 276 rows over both +profiles. The exact saved old module fails 24 rows by spending retired PP; +its output is retained as the negative control. + +The complete matrix executes unchanged Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; held-item CI uses its separately +pinned `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both checkouts stay clean +and read-only. The maintained LuaJIT runtime is pinned at +`a4c064539ea931274d407e1249b4bb575424a0f4`, matching contract CI. Tests run +with `nice -n 19`. New Lua sources/fixtures pass AST-verified formatting; +the workflow passes actionlint. Actual imported scenarios use explicit GBA +rules, constructed parties and public menus without a normal SAVE or +ROM/cache distribution. + +Raw local successes, failures, interrupted matrices, negative controls and +full-suite logs remain under `tmp/choice-band-qa/`. Interrupted matrices were +repeated completely on clean commits before these acceptance results. + +## Remaining native execution gaps + +The planner comparison retains all recorded RNG, events, HP, learned moves, +current/retired PP and complete planning held payloads. It does not observe +PP inside `battle.move_used` or certify the entire damaging script. + +A separate 72-row actual-script diagnostic retains 48 differences: ordinary +and Pressure callbacks observe PP before reduction in Gen1 and after reduction +in Gen2; native zero-PP failures still emit the event whereas Gen2 does not. +The initial probe incorrectly supplied a PP slot for an ordinary called child; +its raw result is retained separately from the corrected native-call probe. +No observation fields were dropped to obtain the corrected result. + +A separate complete held-read phase probe still observes 10 native reads +versus 11 host reads for successful Sleep Talk; awake, empty-pool and blocked +probes retain their matching 2/2/4 counts. King's Rock/Shell Bell command +ordering remains open. Successful Mirror Move target/history selection, +Metronome catalog/RNG parity, other attack-canceller phases and full native +playthrough/graphics are also not certified by this PR.