From 644aa8364b83438edc98e652cb1e5cf04e1bf1ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:07:49 +0200 Subject: [PATCH 001/122] Add version-aware capability queries to the mod API --- docs/modding.md | 50 +++++++ src/mods/Capabilities.lua | 108 +++++++++++++++ src/mods/Loader.lua | 6 + tests/engine/mod_supports.lua | 248 ++++++++++++++++++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 src/mods/Capabilities.lua create mode 100644 tests/engine/mod_supports.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..39892ef6a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,56 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +This is additive to Mod API 2: it neither normalizes content records, IDs or +callback payloads across generations nor provides Gen1Recomp's unified API 3. +Existing registration/subscription behavior remains unchanged. When adding a +native callback or changing screen routing, update `src/mods/Capabilities.lua` +and the capability tests in the same change. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua new file mode 100644 index 000000000..32da142d7 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,108 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..536e2de97 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -16,6 +16,7 @@ local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") +local Capabilities = require("src.mods.Capabilities") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") @@ -1119,6 +1120,11 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for -- the same reason: a headless load must not drag the world stack in, and -- the Game the facade acts on is still being wired when the entry chunk diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..7e3bf96f5 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,248 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') From 044ff9205ef332b9d54b3ce7dc2ebad7e5312bb8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:17 +0200 Subject: [PATCH 002/122] Add opt-in unified mod content records across generations --- docs/modding.md | 7 + docs/unified-mod-content.md | 164 ++++++++++ src/mods/Loader.lua | 14 + src/mods/Manifest.lua | 5 + src/mods/Registry.lua | 23 +- src/mods/Schemas.lua | 5 +- src/mods/UnifiedContent.lua | 518 +++++++++++++++++++++++++++++++ src/mods/UnifiedIds.lua | 85 +++++ src/mods/UnifiedSchemas.lua | 60 ++++ tests/engine/unified_content.lua | 470 ++++++++++++++++++++++++++++ 10 files changed, 1345 insertions(+), 6 deletions(-) create mode 100644 docs/unified-mod-content.md create mode 100644 src/mods/UnifiedContent.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/UnifiedSchemas.lua create mode 100644 tests/engine/unified_content.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2ea5c7f7e 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -277,3 +277,10 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..64d83da74 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,164 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Native effect selectors and item behavior remain opaque: changing a move's +effect on Gen 2–5, changing later item behavior, or changing a DS move type +without a native type adapter returns `nil, reason`. This avoids changing a +symbolic field while leaving an incompatible native selector behind. Power, +accuracy, PP, category, price and other represented fields can be edited. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..107f6041b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1020,6 +1020,10 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + api.contentFormat = mod.manifest.content_format or "native" + if api.contentFormat == "unified" then + api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen) + end -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- A RELATIVE PATH STAYS RELATIVE. @@ -1142,6 +1146,16 @@ function Loader:_api(mod) world = module.new(game, modId) return world end }) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..2fbc8602b 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,10 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -305,6 +309,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or "native", priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..cf200f844 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,16 @@ function Registry.new(name, spec) }, Registry) end +local function validId(self, id) + return type(id) == "string" and id ~= "" + or self.numericIds and type(id) == "number" and id >= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d9e994c1a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..dde1870a8 --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,518 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + if type(raw.effect) ~= "string" then out.effect = nil end + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + if value.effect and value.effect ~= DELETE then + reference(ctx, "move_effects", value.effect) + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..10d2f6bb2 --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") From 4822a59042c2b65ca2a8d9883d34b3e1f056004a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:00:39 +0200 Subject: [PATCH 003/122] Add opt-in unified hook and event record boundaries --- docs/modding.md | 8 + docs/unified-mod-callbacks.md | 177 +++++++ src/mods/CallbackRecords.lua | 670 +++++++++++++++++++++++++ src/mods/Loader.lua | 4 + src/mods/Manifest.lua | 5 + src/mods/UnifiedCallbacks.lua | 214 ++++++++ src/mods/UnifiedIds.lua | 85 ++++ tests/engine/gate_meta_coverage.lua | 2 - tests/engine/unified_mod_callbacks.lua | 634 +++++++++++++++++++++++ 9 files changed, 1797 insertions(+), 2 deletions(-) create mode 100644 docs/unified-mod-callbacks.md create mode 100644 src/mods/CallbackRecords.lua create mode 100644 src/mods/UnifiedCallbacks.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/unified_mod_callbacks.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..1f52f9979 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -192,6 +192,14 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Unified hook and event records (opt-in) + +API-2 mods can select `"callback_format": "unified"` independently of their +content format. Known Pokémon, battler, trainer-slot and encounter records +then use common fields and symbolic IDs. Existing mods keep native payloads. +See [the callback format and mutation contract](unified-mod-callbacks.md) for +adapted arguments/results, native handles and validation rules. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..6d702f4b1 --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,670 @@ +-- A call-local transaction. Only known record roles cross this boundary; +-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. +local Ids = require("src.mods.UnifiedIds") +local R = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do + out[k] = v + end + return out +end +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "function" then + return nil + end + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, x in pairs(v) do + out[k] = copy(x, seen) + end + return out +end +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function integer(v, lo, hi, label) + assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range") + return v +end +local status = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local short = {} +for k, v in pairs(status) do + short[v] = k +end +local stats = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} +local typeAliases = { + PSYCHIC = "PSYCHIC_TYPE", + PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", + FIGHT = "FIGHTING", + ELECTR = "ELECTRIC", +} +local function typeRead(id) + return typeAliases[id] or id +end +local monFields = { + species = true, + item = true, + nickname = true, + name = true, + level = true, + hp = true, + maxHp = true, + status = true, + isEgg = true, + stats = true, + moves = true, + native = true, +} +local idRoles = { + species = "pokemon", + fromSpecies = "pokemon", + toSpecies = "pokemon", + evolveTo = "pokemon", + moveId = "moves", + item = "items", + itemId = "items", + ball = "items", +} +local recordRoles = { + mon = "mon", + pokemon = "mon", + user = "battler", + target = "battler", + attacker = "battler", + defender = "battler", + battler = "battler", + incoming = "battler", + previous = "battler", + move = "move", + defeatedDef = "species", + ballDef = "item", + opts = "context", + alive = "party", + sent = "mon", + received = "mon", + playerAction = "action", + enemyAction = "action", +} +local function array(t, label, max) + assert(type(t) == "table", label .. " must be a list") + local n, count = #t, 0 + assert(not max or n <= max, label .. " too long") + for k in pairs(t) do + count = count + 1 + assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense") + end + assert(count == n, label .. " must be dense") +end + +function R.new(loader, name, generation) + local ids = Ids.new(loader) + local byRaw, byView, entries = {}, {}, {} + local view, restore, commit, refresh + local open = true + local function read(kind, id) + return ids:read(kind, id) + end + local function native(kind, id, old) + if id == read(kind, old) then + return old + end + if id == nil or id == false then + return nil + end + assert(type(id) == "string", kind .. " ID must be a string") + local entry, why = ids:resolve(kind, id) + assert(entry, why) + -- Keep each host's existing numeric/string representation. + if type(old) == "string" then + for _, key in ipairs(entry.keys) do + if type(key) == "string" then + return key + end + end + end + return entry.key + end + local function moveDef(id) + local group = ids:resolve("moves", read("moves", id)) + return group and loader.content.moves:get(group.key) or nil + end + local function moveViews(raw) + local out = {} + for i, m in ipairs(raw or {}) do + local id = type(m) == "table" and m.id or m + local def = moveDef(id) + local ups = type(m) == "table" and (m.ppUps or 0) or 0 + out[i] = { + id = read("moves", id), + pp = type(m) == "table" and m.pp or nil, + maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + return out + end + local function statView(raw) + local out = {} + for k, n in pairs(stats) do + out[k] = (raw or {})[n] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out + end + local function fields(raw, kind) + if kind == "mon" then + return { + species = read("pokemon", raw.species), + item = read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = status[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = statView(raw.stats), + moves = moveViews(raw.moves), + native = copy(raw), + } + elseif kind == "battler" then + return { + mon = view(raw.mon, "mon"), + id = raw.id, + side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil, + native = copy({ name = raw.name, species = raw.species, stages = raw.stages }), + } + elseif kind == "move" or kind == "species" or kind == "item" then + local out = { native = copy(raw), name = raw.name } + local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local key = raw.id or raw.index or raw.numId + if key == nil then + for id, row in loader.content[registryKind]:each() do + if row == raw then + key = id + break + end + end + end + out.id = read(registryKind, key) + if kind == "species" then + out.baseStats = statView(raw.baseStats) + out.types = {} + for i, t in ipairs(raw.types or {}) do + out.types[i] = typeRead(t) + end + out.baseExp = raw.baseExp or raw.expYield + out.catchRate = raw.catchRate + out.growthRate = raw.growthRate + elseif kind == "move" then + for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do + out[key] = raw[key] + end + out.type = typeRead(out.type) + out.category = raw.category or raw.class + out.effect = type(raw.effect) == "string" and raw.effect or nil + else + for _, key in ipairs({ "price", "description", "consumable" }) do + out[key] = raw[key] + end + end + return out + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(raw) do + out[i] = view(v, role) + end + return out + elseif kind == "trainerMon" then + local out = copy(raw) + out.species = read("pokemon", raw.species) + out.item = read("items", raw.item) + out.moves = {} + for i, id in ipairs(raw.moves or {}) do + out.moves[i] = read("moves", id) + end + out.native = copy(raw) + return out + end + local out = shallow(raw) + for k, v in pairs(raw) do + local role = recordRoles[k] + if name == "world.interacted" and k == "target" then + role = nil + end + if name == "trade.completed" and k == "evolution" then + role = "context" + end + if name == "pokemon.before_give" and k == "ctx" then + role = nil + end + if k == "status" then + out[k] = status[v] or v + elseif k == "move" and type(v) ~= "table" then + out[k] = read("moves", v) + elseif kind == "action" and k == "id" and not raw.special then + out[k] = read("moves", v) + elseif idRoles[k] and type(v) ~= "table" then + out[k] = read(idRoles[k], v) + elseif role and type(v) == "table" then + out[k] = view(v, role) + elseif k == "learnable" and name == "pokemon.level_up" then + out[k] = {} + for i, id in ipairs(v) do + out[k][i] = read("moves", id) + end + elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then + out[k] = function(mon, ...) + local bound = byView[mon] + assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon") + commit() + local result = pack(pcall(v, bound.raw, ...)) + if not result[1] then + error(result[2], 0) + end + refresh() + return unpack(result, 2, result.n) + end + end + end + return out + end + view = function(raw, kind) + if type(raw) ~= "table" then + return raw + end + byRaw[raw] = byRaw[raw] or {} + if byRaw[raw][kind] then + return byRaw[raw][kind].public + end + local e = { raw = raw, kind = kind, public = {} } + byRaw[raw][kind] = e + byView[e.public] = e + entries[#entries + 1] = e + for k, v in pairs(fields(raw, kind)) do + e.public[k] = v + end + e.before = ( + kind == "action" + or kind == "gift" + or kind == "context" + or kind == "encounter" + or kind == "party" + or kind == "trainerParty" + or kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + return e.public + end + local function statChanges(public, old, baseline, ops) + assert(type(old) == "table", "native stats are missing") + assert(type(public) == "table", "stats must be a record") + for k in pairs(public) do + assert(stats[k], "unknown stat " .. tostring(k)) + end + local specialValue + for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then + assert(not specialValue or specialValue == public[k], "conflicting special stats") + specialValue = public[k] + end + end + for k, n in pairs(stats) do + local value = public[k] + if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = specialValue or baseline[k] + n = "special" + elseif specialValue and (k == "special" or k == "specialAttack") then + value = specialValue + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + ops[#ops + 1] = function() + old[n] = value + end + end + end + end + local pending, visiting + local function stage(raw, key, value) + assert(type(raw) == "table", "native record is missing") + pending[#pending + 1] = function() + raw[key] = value + end + end + local function movesNative(public, old, baseline) + array(public, "moves", 4) + local out = {} + for i, m in ipairs(public) do + assert(type(m) == "table", "move must be a record") + for k in pairs(m) do + assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field") + end + local prev = (old or {})[i] + local prior = (baseline or {})[i] or {} + assert(m.maxPp == prior.maxPp, "maxPp is read-only") + local row = type(prev) == "table" and shallow(prev) or {} + row.id = native("moves", m.id, row.id) + assert(row.id, "move ID is required") + row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps") + local def = moveDef(row.id) + local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0 + row.pp = integer(m.pp, 0, max, "pp") + out[i] = row + end + return out + end + restore = function(value, kind) + if type(value) ~= "table" then + assert( + value == nil or (kind == "encounter" and value == false), + "expected " .. tostring(kind) .. " record" + ) + return value + end + local e = byView[value] + if not e then + assert( + kind == "action" + or kind == "encounter" + or kind == "trainerMon" + or kind == "candidates" + or kind == "trainerParty", + "replacement " .. tostring(kind) .. " must be a bound record" + ) + e = { public = value, kind = kind, raw = {}, before = {} } + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then + if kind == "battler" then + assert( + value.id == before.id and value.side == before.side and equal(value.native, before.native), + "battler metadata is read-only" + ) + assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only") + for k in pairs(value) do + assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field") + end + restore(value.mon, "mon") + else + assert(equal(value, before), kind .. " definition is read-only; use mod.content") + end + elseif kind == "mon" then + for k in pairs(value) do + assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + end + assert(equal(value.native, before.native), "native snapshot is read-only") + assert(value.name == before.name, "name is read-only; set nickname") + for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do + local v = value[key] + if not equal(v, before[key]) then + if key == "species" or key == "item" then + v = native(key == "species" and "pokemon" or "items", v, raw[key]) + assert(key ~= "species" or v, "species is required") + elseif key == "level" then + v = integer(v, 1, 100, key) + elseif key == "hp" then + v = integer(v, 0, 65535, key) + elseif key == "maxHp" then + v = integer(v, 1, 65535, key) + elseif key == "status" then + assert(v == nil or v == false or short[v], "unknown status") + v = short[v] + elseif key == "nickname" then + assert(v == nil or type(v) == "string", "nickname must be a string") + elseif key == "isEgg" then + assert(type(v) == "boolean", "isEgg must be boolean") + end + if key == "maxHp" then + stage(raw.stats, "hp", v) + else + stage(raw, key, v) + end + end + end + if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then + assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp") + end + local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp + if value.hp ~= before.hp or limit ~= before.maxHp then + assert( + type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit, + "hp exceeds maxHp" + ) + end + if not equal(value.stats, before.stats) then + statChanges(value.stats, raw.stats, before.stats, pending) + end + if not equal(value.moves, before.moves) then + local new = movesNative(value.moves, raw.moves, before.moves) + local target = raw.moves or {} + stage(raw, "moves", target) + pending[#pending + 1] = function() + local previous = shallow(target) + for i in pairs(target) do + target[i] = nil + end + for i, row in ipairs(new) do + local slot = type(previous[i]) == "table" and previous[i] or {} + for k in pairs(slot) do + slot[k] = nil + end + for k, v in pairs(row) do + slot[k] = v + end + target[i] = slot + end + end + end + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + array(value, kind, kind ~= "candidates" and 6 or nil) + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(value) do + out[i] = restore(v, role) + end + if kind == "party" then + assert(#value == #raw, "live party membership is read-only") + for i, v in ipairs(out) do + assert(v == raw[i], "live party order is read-only") + end + else + pending[#pending + 1] = function() + for k in pairs(raw) do + raw[k] = nil + end + for i, v in ipairs(out) do + raw[i] = v + end + end + end + elseif kind == "gift" then + assert(value.ctx == before.ctx, "gift context is read-only") + for k in pairs(value) do + assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field") + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "level", integer(value.level, 1, 100, "level")) + assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string") + stage(raw, "nickname", value.nickname) + elseif kind == "trainerMon" then + assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only") + local allowed = { species = true, item = true, level = true, moves = true, native = true } + for k, v in pairs(value) do + if not allowed[k] then + assert(equal(v, before[k]), "trainer metadata is read-only") + end + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "item", native("items", value.item, raw.item)) + stage(raw, "level", integer(value.level, 1, 100, "level")) + if not equal(value.moves, before.moves) then + array(value.moves, "trainer moves", 4) + local out = {} + for i, id in ipairs(value.moves) do + out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required") + end + stage(raw, "moves", out) + end + else + if kind == "encounter" then + assert(value.species ~= nil, "encounter species is required") + if value.level ~= nil then + integer(value.level, 1, 100, "encounter level") + end + for _, k in ipairs({ "min", "max" }) do + if value[k] ~= nil then + integer(value[k], 1, 100, k) + end + end + assert( + value.level ~= nil or (value.min and value.max and value.min <= value.max), + "encounter level is required" + ) + end + local keys = shallow(before) + for k in pairs(value) do + keys[k] = true + end + for k in pairs(keys) do + local v = value[k] + local old = raw[k] + if type(v) == "table" and byView[v] then + local restored = restore(v) + if v ~= before[k] then + stage(raw, k, restored) + end + elseif k == "move" and type(v) ~= "table" and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif k == "status" and v ~= before[k] then + assert(v == nil or v == false or short[v], "unknown status") + stage(raw, k, short[v]) + elseif idRoles[k] and v ~= before[k] then + stage(raw, k, native(idRoles[k], v, old)) + elseif k == "applyShare" and name == "battle.exp_award" then + assert(v == before[k], "applyShare is read-only") + elseif k == "learnable" and name == "pokemon.level_up" then + assert(equal(v, before[k]), "learnable is read-only") + elseif v ~= before[k] then + local role = recordRoles[k] + if kind == "encounter" and k == "level" then + integer(v, 1, 100, "encounter level") + end + if role and type(v) == "table" then + v = restore(v, role) + end + stage(raw, k, v) + end + end + end + return raw + end + commit = function(extra) + assert(open, "callback transaction is closed") + pending, visiting = {}, {} + for _, e in ipairs(entries) do + restore(e.public, e.kind) + end + local result + if extra then + result = extra() + end + for _, op in ipairs(pending) do + op() + end + return result + end + refresh = function() + -- Reuse public identities, but refresh after vanilla mutates native data. + for _, e in ipairs(entries) do + local nextValue = fields(e.raw, e.kind) + for k in pairs(e.public) do + e.public[k] = nil + end + for k, v in pairs(nextValue) do + e.public[k] = v + end + end + for _, e in ipairs(entries) do + e.before = ( + e.kind == "action" + or e.kind == "gift" + or e.kind == "context" + or e.kind == "encounter" + or e.kind == "party" + or e.kind == "trainerParty" + or e.kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + end + end + return { + close = function() + open = false + end, + view = view, + restore = function(v, kind) + return restore(v, kind) + end, + commit = commit, + refresh = refresh, + read = read, + native = native, + copy = copy, + } +end +return R diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..02b33db62 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -891,6 +891,7 @@ function Loader:_api(mod) }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -999,6 +1000,9 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..09f2a1abb 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -279,6 +279,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -316,6 +320,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or "native", github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua new file mode 100644 index 000000000..ce1659a02 --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,214 @@ +local Records = require("src.mods.CallbackRecords") +local Ids = require("src.mods.UnifiedIds") +local C = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +-- This is an audited boundary catalog, not a claim that every game executes +-- every seam. The source-catalog test requires a decision for each new seam. +C.hooks = names([[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation) + local roles = positions[name] or {} + local function translate(args) + local out = { n = args.n } + for i = 1, args.n do + local role = roles[i] + local v = args[i] + if role == "items" or role == "moves" then + v = boundary.read(role, v) + elseif role then + v = boundary.view(v, role) + end + out[i] = v + end + return out + end + local original = pack(...) + local function rawArgs(args) + local out = { n = args.n } + for i = 1, args.n do + local role, v = roles[i], args[i] + if role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + v = boundary.restore(v, role) + end + out[i] = v + end + return out + end + local public = translate(original) + local called, downstream, publicResult = false, nil, nil + local active = true + local function nextPublic(...) + assert(active, "next is only valid during its hook callback") + assert(not called or downstream, "next is already running") + if not called then + local args = select("#", ...) == 0 and public or pack(...) + local raw = boundary.commit(function() + return rawArgs(args) + end) + called = true + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..d3bbee83b 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -111,13 +111,11 @@ local DEBT = { ["hook:encounter.fishing"] = "M5", ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", ["hook:ui.pc.items"] = "M8", ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:player.warped"] = "M5", - ["event:pokemon.before_give"] = "M7", ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() From 886fca58204c5043f31a6d8f5e325c3cd0d3d4bf Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:17:55 +0200 Subject: [PATCH 004/122] Add detached read-only world and party queries --- docs/modding.md | 8 + docs/world-read-api.md | 151 ++++++++++++ src/mods/Loader.lua | 13 +- src/mods/UnifiedIds.lua | 85 +++++++ src/mods/WorldReads.lua | 227 +++++++++++++++++ src/world/WorldAPI.lua | 27 ++- tests/engine/world_read_api.lua | 415 ++++++++++++++++++++++++++++++++ 7 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 docs/world-read-api.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldReads.lua create mode 100644 tests/engine/world_read_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..8743187dd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -70,6 +70,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..8ea12fc6e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") +local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") @@ -1125,6 +1126,16 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1139,7 +1150,7 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..9d7a1cdea 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,14 +9,15 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local Reads = require("src.mods.WorldReads") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +59,28 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() From 9b7bc090401af8e80f89071e296bd215ae36db3e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:43:36 +0200 Subject: [PATCH 005/122] Add guarded public world actions and native flight --- docs/modding.md | 9 + docs/world-actions-api.md | 135 +++++++ src/mods/Loader.lua | 10 + src/world/WorldAPI.lua | 9 + src/world/WorldActions.lua | 469 +++++++++++++++++++++++ tests/engine/world_actions_api.lua | 582 +++++++++++++++++++++++++++++ 6 files changed, 1214 insertions(+) create mode 100644 docs/world-actions-api.md create mode 100644 src/world/WorldActions.lua create mode 100644 tests/engine/world_actions_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..33b9ee421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -255,6 +255,15 @@ without the engine knowing the layout. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Public world actions + +`mod.world` exposes guarded party swaps, verified contextual field actions +and native flight through `canReorderParty`, `reorderParty`, +`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`. +See [contracts, busy-state checks and generation coverage](world-actions-api.md). +Calls revalidate the live state and preserve native gameplay paths; the +field-action adapter explicitly refuses unsupported script-driven cases. + ## Support link (`support`) A mod can add a link where players can thank its creator: a Ko-fi, Patreon or diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..51fa8a5bf 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldActions = require("src.world.WorldActions") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1124,6 +1125,15 @@ function Loader:_api(mod) -- the Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end local world, storage setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..3b94e0c15 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,7 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") @@ -386,6 +387,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..d6172f82c --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,469 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() From 59348147b706bad091d095ef247c100b6438b2d9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:44:35 +0200 Subject: [PATCH 006/122] Allow capability tests to follow additive native flight support --- tests/engine/mod_supports.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 7e3bf96f5..5d4562cd8 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -5,6 +5,7 @@ local T = require('tests.harness') local GameVersion = require('src.core.GameVersion') local Loader = require('src.mods.Loader') local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') local Schemas = require('src.mods.Schemas') local Catalog = require('tests.modkit.catalog') local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -72,7 +73,9 @@ for _, version in ipairs(GameVersion.ORDER) do registries = registries and yes(api, 'content', name) end T.check(registries, version .. ': all API-2 registration surfaces available') - T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), version .. ': absent Gen1Recomp API-3 features are not advertised') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), From 52b0055ef9679e31e9cc0f24069c87e50c1f4401 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:05:23 +0200 Subject: [PATCH 007/122] Add detached read-only battle snapshots for mods --- docs/battle-snapshot-api.md | 119 ++++++ docs/modding.md | 9 + src/battle/BattleState.lua | 2 + src/mods/BattleSnapshots.lua | 472 +++++++++++++++++++++++ src/mods/Loader.lua | 20 +- src/mods/UnifiedIds.lua | 85 +++++ tests/engine/battle_snapshots.lua | 613 ++++++++++++++++++++++++++++++ 7 files changed, 1316 insertions(+), 4 deletions(-) create mode 100644 docs/battle-snapshot-api.md create mode 100644 src/mods/BattleSnapshots.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/battle_snapshots.lua diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md new file mode 100644 index 000000000..2e9266704 --- /dev/null +++ b/docs/battle-snapshot-api.md @@ -0,0 +1,119 @@ +# Battle snapshot API + +```lua +local battle = mod.battle -- safe in the mod entry chunk, before Game exists +local snapshot, reason = battle:snapshot() +if snapshot then + local actor = snapshot.player + if actor then print(actor.name, actor.hp, actor.maxHp) end +end +``` + +Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither +`content_format` nor `callback_format` is required. This reads the shared +BattleState in the current Game's stack, including beneath another screen. +A read before Game is loaded also returns `nil, "no battle"` without +bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a +`.battle` reference does not count as another battle. + +If the capability-query extension is installed, +`mod:supports("battle", "snapshot")` returns true without constructing Game. +`submit` and other battle capabilities return false with a reason. + +## Records + +All returned tables, including nested records and `native` diagnostics, are +copies. They carry no engine objects, functions, userdata, threads or +metatables. Unknown native Pokemon extension fields are copied as data; +cycles are preserved. These diagnostics are not a portable write format or +necessarily JSON-serializable. Changing a snapshot never changes battle/save +state or future reads. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive, monotonically increasing within this facade; see below | +| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome | +| `turn` | Native resolved-turn count; zero before the first turn | +| `double` | Whether this battle is configured as a double | +| `active` | Player position the menu currently selects for (0 or 2) | +| `player` | Occupied battler at `active`, or nil | +| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes | +| `battlers` | Array of occupied battler records in position order | +| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` | +| `moves` | Current actor's battle moves, including Transform/Mimic overrides | +| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs | +| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction | +| `prompt` | Description of the battle screen's input phase; see below | +| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable | +| `messageSource` | Optional current message source text, which can include unrevealed text | +| `safariBalls` | Optional native remaining Safari ball count | +| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors | +| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty | +| `targetIndex` | Optional one-based cursor into `targets` | +| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor | + +Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3 +opponent-right**. Array indices are not battler IDs. Empty positions are +omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the +second player chooser, including after the left flank leaves the field. + +Pokemon records expose `species`, optional `item`, `nickname`, `name`, +`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native` +diagnostics. Species/move/item IDs use canonical string names from this boot's +registries. Numeric DS IDs resolve through the imported names and aliases; +unresolved IDs use their string form. Pristine IDs remain stable across +presentation-name patches; definitions registered by later mods are visible. +Status codes SLP/PSN/BRN/FRZ/PAR/TOX become +sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained. + +Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, +`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes +from stored `stats.hp`. HP and status are logical state, not interpolated HUD +values. Battlers additionally expose `id`, `side` (player/enemy), `active`, +`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`, +`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and +stored stats stay separate from Transform's displayed species/current stats. +NPC allies are not added to the player's party. + +Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`, +`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus +clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base +values, not damage/accuracy predictions. Only the top-level actor `moves` +also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if +none applies on Gen 1–4. In BW this field is omitted unless a common +restriction is known, because additional native Choice/Imprison/Gravity/ +Heal Block controllers may refuse the move. Nil is unknown, not false. +Mimic options retain their original move `slot` even if options are compacted. + +Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional +`pocket`. This is an **inventory view**, not a list of currently usable items; +it can include badges/key items. Ball classification follows imported ball +metadata. `catchable` describes the target restriction, not permission to use +an item: noItems/frontier rules, party capacity and the active bag controller +still own item eligibility. No `needsTarget`, catchChance, hitChance, +displayPower, effectiveness or guessed preview is produced. + +## Prompts and revisions + +`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or +`locked`. Intro, automatic demos/Palace, conservative Arena menu handling, +known automatic battler locks and every covered battle are locked. Message +advance is reported only when native manual text waits have finished their +pre-advance delay. Party, bag and choice screens above battle stay locked; +this API does not inspect or drive those screens. Link controllers remain locked. +Visible messages decode the native revealed glyph buffer through the imported +charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, +not an action-permission token. There is no battle input/submission API. + +Identical successive snapshot records retain their revision. A change to +any exposed data advances it once at the next read; animation clocks and +interpolated HP alone do not. A different battle or Game also advances it, +even with identical records. Observing `no battle` invalidates the previous +record, so re-entry advances it. Unobserved transitions between reads cannot +be detected if the same Game and battle objects are reused. Each mod has its +own counter; revisions are not save data or globally synchronized IDs. + +Reading constructs no Pokemon or screen, loads no gameplay/graphics module, +executes no action, emits no event/hook and consumes no RNG. Only facade +bookkeeping and loader-local ID indexes change. The API provides an observer +boundary, not hidden-information filtering or a write-back transaction. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..dbbfadc75 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -152,6 +152,15 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5, +including singles and doubles, or `nil, "no battle"`. It can be captured in +an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) +for records, revisions, prompt semantics and intentionally omitted previews. +It reads state only; there is no `submit` method. When capability queries are +installed, `mod:supports("battle", "snapshot")` reports this interface. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..c72bad08e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,6 +53,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Data readers can identify the shared Gen 1-5 battle without loading this module. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua new file mode 100644 index 000000000..79a0aa417 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,472 @@ +-- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, +-- item-effect or preview helpers here: some of those consume RNG or write state. +local Ids = require("src.mods.UnifiedIds") +local Snapshots = {} + +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STATS = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "string" or kind == "boolean" or kind == "number" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, v in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(v, seen) + end + end + return out +end + +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function stats(raw) + local out = {} + for public, native in pairs(STATS) do + out[public] = (raw or {})[native] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out +end + +local function at(battle, pos) + -- Constructors fill these aliases before enter()/syncSides populates the + -- positional arrays. A cleared alias also truthfully represents a faint. + if pos == 0 then + return battle.player + end + if pos == 1 then + return battle.enemy + end + local side = battle.sides and battle.sides[pos % 2 + 1] + return side and side.battlers and side.battlers[math.floor(pos / 2) + 1] +end + +local function activeSlot(battle) + if not battle.double then + return 0 + end + local pos = battle.choosingSlot + if (pos == 0 or pos == 2) and at(battle, pos) then + return pos + end + for _, id in ipairs({ 0, 2 }) do + local b = at(battle, id) + if b and not b.ally then + return id + end + end + return 0 +end + +local function moveView(move, slot, data, ids) + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] or {} + local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0 + return { + slot = slot, + id = ids:read("moves", id), + name = def.name, + pp = type(move) == "table" and move.pp or nil, + maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil, + ppUps = ups, + type = def.type, + power = def.power, + accuracy = def.accuracy, + } +end + +local function monView(raw, data, ids) + if not raw then + return nil + end + local out = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats(raw.stats), + moves = {}, + native = copy(raw), + } + for slot, move in ipairs(raw.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +local function battlerView(b, pos, party, data, ids) + if not (b and b.mon) then + return nil + end + local out = monView(b.mon, data, ids) + out.name = b.name or out.name + out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false + out.displaySpecies = ids:read("pokemon", b.species or b.mon.species) + out.battleStats, out.types, out.stages = + stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {}) + out.ally = b.ally == true + for index, mon in ipairs(party) do + if mon == b.mon then + out.partyIndex = index + break + end + end + out.moves = {} + for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +-- Prompt describes the battle screen only. Covered screens and automatic +-- controllers stay locked; no attempt is made to inspect or drive their UI. +local function prompt(b, covered, actor) + if + covered + or b.result + or b.waitingUI + or b.demo + or b.pokedudeDemo + or b.palace + or b.kind == "link" + or (b.arena and b.phase == "menu") + then + return "locked" + end + if b.phase == "menu" then + if b.safari then + return (b.safari.balls or 0) > 0 and "safari" or "locked" + end + if + not (actor and actor.mon and (actor.mon.hp or 0) > 0) + or actor.ally + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + then + return "locked" + end + -- Sky Drop's controller in BW owns the menu; unknown native locks are + -- not advertised as actionable (this API never submits input). + if b.bw and actor.bwSkyDropped then + return "locked" + end + return "menu" + end + if b.phase == "moveSelect" then + return "moves" + end + if b.phase == "targetSelect" then + return "target" + end + if b.phase == "mimicSelect" then + return "mimic" + end + if + b.phase == "messages" + and b.current + and not b.current.choice + and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0)) + then + return "advance" + end + return "locked" +end + +-- Decode only glyphs already revealed by the native message clock. Unknown +-- glyphs leave the optional visible message unavailable, never reveal ahead. +local function visibleMessage(battle, covered, data) + if covered or battle.phase ~= "messages" or not battle.shown then + return nil + end + local reverse = {} + local function add(rows) + for _, row in ipairs(rows or {}) do + if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then + reverse[row.code] = row.seq + end + end + end + add(data.font and data.font.charmap) + local pages, names = data.font and data.font.pages or {}, {} + for name in pairs(pages) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + add(type(pages[name]) == "table" and pages[name].charmap) + end + local unicode = package.loaded["src.translation.UnicodeFont"] + local chars = type(unicode) == "table" and unicode.chars or {} + local scaffold = data.font + and type(data.font.source) == "string" + and data.font.source:find("Gen2 scaffold", 1, true) + local out = {} + for i, codes in ipairs(battle.shown) do + local line = {} + for _, code in ipairs(codes) do + local seq = chars[code] or reverse[code] + if scaffold and type(code) == "number" and code >= 0 and code <= 255 then + seq = string.char(code) + end + if not seq then + return nil + end + line[#line + 1] = seq + end + out[i] = table.concat(line) + end + return out +end + +local function kind(battle) + -- The same variants as BattleState:battleKind(), without a method call. + if battle.ghost then + return "ghost" + end + if battle.safari then + return "safari" + end + if battle.demo and not battle.dudeDemo then + return "oldman" + end + return battle.kind +end + +local function build(game, battle, covered, ids) + local data, save = battle.data or game.data or {}, game.save or {} + local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {} + local active = activeSlot(battle) + local actor = at(battle, active) + local out = { + kind = kind(battle), + phase = battle.phase, + double = battle.double == true, + active = active, + turn = battle.turnCount or 0, + prompt = prompt(battle, covered, actor), + message = visibleMessage(battle, covered, data), + messageSource = battle.current and battle.current.text, + safariBalls = battle.safari and battle.safari.balls, + menuIndex = battle.menuIndex, + moveIndex = battle.moveIndex, + moveSwapIndex = battle.moveSwapIndex, + result = battle.result, + party = {}, + battlers = {}, + moves = {}, + items = {}, + targets = {}, + } + local foes = 0 + local rows = {} + for pos = 0, (out.double and 3 or 1) do + local b = at(battle, pos) + local row = battlerView( + b, + pos, + pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty, + data, + ids + ) + rows[pos] = row + if row then + out.battlers[#out.battlers + 1] = row + if pos % 2 == 1 and (row.hp or 0) > 0 then + foes = foes + 1 + end + end + end + out.player, out.enemy = rows[active], rows[1] + out.catchable = battle.kind == "wild" + and not battle.ghost + and not battle.noCatch + and not battle.result + and foes == 1 + for slot, raw in ipairs(party) do + local row = monView(raw, data, ids) + row.slot, row.active = slot, false + for _, pos in ipairs({ 0, 2 }) do + local b = at(battle, pos) + if b and not b.ally and b.mon == raw then + row.active = true + end + end + out.party[slot] = row + end + for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do + local row = moveView(move, slot, data, ids) + local def = (data.moves or {})[move.id] or {} + -- The three explicit guards used by BattleState's move selector. This + -- is a local menu restriction, not a guarantee a move will execute. + row.disabled = actor.disabledSlot == slot + or (move.pp or 0) <= 0 + or (actor.tauntTurns ~= nil and (def.power or 0) == 0) + or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) + or false + -- BW adds Choice/Imprison/Gravity/Heal Block checks through native + -- controllers. Do not report an unverified false for those moves. + if battle.bw and not row.disabled then + row.disabled = nil + end + out.moves[slot] = row + end + -- Inventory quantities, not a use-eligibility result. Keep acquisition + -- order, then append positive unlisted IDs deterministically. + local inventory, seen, remaining = save.inventory or {}, {}, {} + local function item(id) + if seen[id] then + return + end + seen[id] = true + local count = inventory[id] + if type(count) ~= "number" or count <= 0 then + return + end + local def = (data.items or {})[id] or {} + local balls = data.balls or {} + local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil + out.items[#out.items + 1] = { + id = ids:read("items", id), + name = def.name, + count = count, + ball = ball, + catchable = ball and out.catchable, + pocket = def.pocket, + } + end + for _, id in ipairs(save.bagOrder or {}) do + item(id) + end + for id in pairs(inventory) do + if not seen[id] then + remaining[#remaining + 1] = id + end + end + table.sort(remaining, function(a, b) + return type(a) == type(b) and a < b or type(a) < type(b) + end) + for _, id in ipairs(remaining) do + item(id) + end + if battle.phase == "targetSelect" then + for _, target in ipairs(battle.targetChoices or {}) do + for pos = 0, 3 do + if at(battle, pos) == target then + out.targets[#out.targets + 1] = pos + break + end + end + end + out.targetIndex = battle.targetIndex + end + if battle.phase == "mimicSelect" then + out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex + for index, move in ipairs(battle.mimicMoves or {}) do + out.mimicMoves[index] = moveView(move, move.slot or index, data, ids) + end + end + return out +end + +function Snapshots.new(gameProvider, loader) + -- Ids keeps alias metadata on the loader, never on gameplay records. + local initial = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + initial:entries(kind) + end + local facade = {} + local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 + function facade:snapshot() + local game = gameProvider() + local states = game and game.stack and game.stack.states or {} + local battle, index + for i = #states, 1, -1 do + if states[i].isBattleState == true then + battle, index = states[i], i + break + end + end + if not battle then + lastGame, lastBattle, previous = nil, nil, nil + return nil, "no battle" + end + local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) + if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + revision = revision + 1 + end + lastGame, lastBattle, previous = game, battle, out + local result = copy(out) + result.revision = revision + return result + end + return facade +end +-- Run after construction so an optional capability provider and its other +-- surface wrappers are already installed. Standalone main has no provider. +function Snapshots.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "battle" then + if name == "snapshot" then + return true + end + return false, "unknown battle capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end + return api +end +return Snapshots diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..08e71b498 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -21,6 +21,7 @@ local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") +local BattleSnapshots = require("src.mods.BattleSnapshots") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -841,6 +842,8 @@ function Loader:_api(mod) if okN then hostInfo = info end end end + -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read. + local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do local okE, V = pcall(require, "src.core.Version") @@ -868,6 +871,7 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, + battle = battle, -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", @@ -1120,8 +1124,10 @@ function Loader:_api(mod) return data end) -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage @@ -1148,8 +1154,14 @@ end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..27c992c5f --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(api.battle.submit, nil, label .. "no mutation method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") From 904490919a64c3a4ba72914baecbb696c76cbd6f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:34:07 +0200 Subject: [PATCH 008/122] Add revision-bound public battle actions through native commands --- docs/battle-actions-api.md | 96 ++++ docs/battle-snapshot-api.md | 13 +- docs/modding.md | 4 +- src/battle/BattleCommands.lua | 355 ++++++++++++++ src/battle/BattleState.lua | 127 +---- src/mods/BattleIntents.lua | 178 +++++++ src/mods/BattleSnapshots.lua | 127 ++++- tests/engine/battle_actions.lua | 758 ++++++++++++++++++++++++++++++ tests/engine/battle_snapshots.lua | 4 +- 9 files changed, 1533 insertions(+), 129 deletions(-) create mode 100644 docs/battle-actions-api.md create mode 100644 src/battle/BattleCommands.lua create mode 100644 src/mods/BattleIntents.lua create mode 100644 tests/engine/battle_actions.lua diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md new file mode 100644 index 000000000..f0e232773 --- /dev/null +++ b/docs/battle-actions-api.md @@ -0,0 +1,96 @@ +# Battle action API + +The action extension builds on `mod.battle:snapshot()` and is available to +API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in +is required. If capability queries are installed, +`mod:supports("battle", "submit")` reports that this interface exists. It does +not promise the current battle accepts a particular command. + +```lua +local nextIntentId = 1 -- keep increasing for this captured facade +local snapshot = mod.battle:snapshot() +if snapshot and snapshot.prompt == "menu" then + local ok, reason = mod.battle:submit({ + id = nextIntentId, + revision = snapshot.revision, + kind = "menu", + choice = "fight", + }) + nextIntentId = nextIntentId + 1 + if not ok then mod.log:info("Battle command refused: %s", reason) end +end +``` + +Always fetch a fresh snapshot before the next command. Do not hold a revision +across a frame, other input, a native callback, another mod's command, a screen +change or a Game/battle replacement. The engine rechecks the live context; +returning to an apparently identical screen does not revive an old command. + +## Intent records + +Intents must be plain tables without a metatable. Every record contains +`id`, `revision`, `kind` and only the fields listed below. Integers must be +finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra +fields, unknown kinds, strings in numeric fields and missing arguments are +rejected. The facade copies accepted input fields before calling native code. + +| `kind` | Extra field | Meaning | +| --- | --- | --- | +| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu | +| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector | +| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID | +| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it | +| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button | +| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot | + +Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right. +These are not indices into `snapshot.battlers` or `snapshot.targets`. Target +choices are checked again against the native selector and live field. A +pending move must still be the same current move and still pass move checks. + +`move` uses the actual chooser, including the right flank and Transform's +current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/ +Gravity/Heal Block restrictions are checked before accepting the request. +Mimic uses its queued user, including a right-flank user during turn resolution, +and preserves the native copy/restoration bookkeeping. + +`menu` opens the existing bag/party flow. It does not select a party member, +use an inventory item or throw a normal bag ball directly. Those screens keep +their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer +RUN refusals and bug-contest item behavior are preserved. A valid menu choice +can therefore be accepted and produce a native refusal message. + +## Context, replay and errors + +`submit(intent)` returns `true` when admitted and dispatched; otherwise +`nil, reason`. Accepted means the native command path ran, not that a turn +succeeded, a move hit, a ball caught or a battle finished. + +An intent must refer to a snapshot actually returned by this facade and still +be current. Each facade owns its own monotonically increasing ID sequence; +IDs survive battle and Game changes. Once native dispatch starts, that ID is +consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed. +A rejection before dispatch does not consume its ID; correct the problem and +read a new snapshot before retrying. + +All public facades on one Game share an in-progress guard and an invalidation +epoch outside gameplay/save state. Another mod or a callback cannot submit +recursively during validation or execution. Snapshots observed during execution +are locked and cannot authorize a command after it finishes. Accepted calls +invalidate other mods' old snapshots even if exposed data happens to stay the +same. These counters are local process bookkeeping, not link/save data. + +Native errors are caught, reported and clear the guard. **An entered native +call is not retried or rolled back.** Its ID stays consumed even if native +code throws after a partial effect or returns a refusal. Fetch the current +snapshot and use a new ID for any deliberate next action. Validation errors +also clear the guard. Errors do not automatically drive subsequent frames or +drain the turn queue. + +No battle, covered menus, mismatched phases, finished battles, fainted/NPC +choosers, automatic locks, stale targets, demos/tutorials, link/spectator, +Palace and Arena controllers are refused. This API does not advance text, +drive a covered party/bag/choice screen, perform a forced replacement, or +bypass a script/controller. Ordinary keyboard/touch input continues through +the same extracted native dispatch methods and retains its existing messages, +queueing, RNG, targeting and per-version behavior. diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md index 2e9266704..e6e1cfa3c 100644 --- a/docs/battle-snapshot-api.md +++ b/docs/battle-snapshot-api.md @@ -18,7 +18,9 @@ bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen me If the capability-query extension is installed, `mod:supports("battle", "snapshot")` returns true without constructing Game. -`submit` and other battle capabilities return false with a reason. +When the [battle action extension](battle-actions-api.md) is installed, +`mod:supports("battle", "submit")` also returns true. Other battle capabilities +return false with a reason. ## Records @@ -103,9 +105,14 @@ pre-advance delay. Party, bag and choice screens above battle stay locked; this API does not inspect or drive those screens. Link controllers remain locked. Visible messages decode the native revealed glyph buffer through the imported charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, -not an action-permission token. There is no battle input/submission API. +not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated +submission; the snapshot's descriptive prompt is not its permission check. -Identical successive snapshot records retain their revision. A change to +Identical successive snapshot records and action contexts retain their revision. +The action extension also tracks native controller/selector identity and private +control flags, so replacement objects can invalidate an otherwise identical +record. Accepted public actions invalidate all facades on that Game, including +snapshots observed during native callbacks. A change to any exposed data advances it once at the next read; animation clocks and interpolated HP alone do not. A different battle or Game also advances it, even with identical records. Observing `no battle` invalidates the previous diff --git a/docs/modding.md b/docs/modding.md index dbbfadc75..023f89f5d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -158,7 +158,9 @@ frame"; the engine draws the vanilla world instead. including singles and doubles, or `nil, "no battle"`. It can be captured in an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) for records, revisions, prompt semantics and intentionally omitted previews. -It reads state only; there is no `submit` method. When capability queries are +The snapshot reads state only. The [Battle action API](battle-actions-api.md) +adds validated `mod.battle:submit(intent)` for the native menu, move, target, +back, Safari and Mimic paths. When capability queries are installed, `mod:supports("battle", "snapshot")` reports this interface. ## Battle sprite scaling diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..9c0778a86 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,355 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c72bad08e..c2f51594a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local BattleCommands = require("src.battle.BattleCommands") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -55,6 +56,8 @@ BattleState.__index = BattleState BattleState.isOpaque = true -- Data readers can identify the shared Gen 1-5 battle without loading this module. BattleState.isBattleState = true +-- Input and public intents share the exact same native command dispatch. +for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -4408,7 +4411,7 @@ function BattleState:update(dt) Gen4Battle.actionMove(self.menuIndex, dir, self.gen4MenuColumn) end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4434,7 +4437,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4514,60 +4517,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4593,58 +4546,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4662,16 +4571,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4692,12 +4594,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..5daa69311 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,178 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "bwSkyDropped", + "bwChoiceLock", + "bwImprison", + "bwHealBlock", + "bwEmbargo", +} +-- Reference identity matters as well as record values: an identical replacement +-- Pokemon, move slot, selector or covering screen must invalidate old commands. +-- refs are compared shallowly; values are copied by the snapshot owner. +function Intents.context(game, b, top, at) + local refs = { + game = game, + battle = b, + top = top, + stack = game.stack, + queue = b.queue, + current = b.current, + targets = b.targetChoices, + mimic = b.mimicCtx, + mimicMoves = b.mimicMoves, + pendingMove = b.pendingMove, + pending = b.pendingActions, + chosen = b.chosenTargets, + safari = b.safari, + } + local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} } + for _, key in ipairs(BATTLE_FLAGS) do + values.flags[key] = type(b[key]) == "table" and true or b[key] + end + values.bw = type(b.bw) == "table" + and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom } + or b.bw + if b.mimicCtx then + refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst + end + for i, move in ipairs(b.mimicMoves or {}) do + refs["mimicOption" .. i] = move + end + for pos = 0, 3 do + local actor = at(b, pos) + refs["b" .. pos] = actor + if actor then + refs["mon" .. pos] = actor.mon + refs["moves" .. pos] = actor.curMoves + local flags = {} + for _, key in ipairs(BATTLER_FLAGS) do + flags[key] = type(actor[key]) == "table" and true or actor[key] + end + values.battlers[pos] = flags + for slot, mv in ipairs(actor.curMoves or {}) do + refs["move" .. pos .. ":" .. slot] = mv + end + end + refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos] + refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos] + end + for i, target in ipairs(b.targetChoices or {}) do + refs["target" .. i] = target + end + return refs, values +end +function Intents.sameRefs(a, b) + if not a or not b then + return a == b + end + for key, value in pairs(a) do + if b[key] ~= value then + return false + end + end + for key, value in pairs(b) do + if a[key] ~= value then + return false + end + end + return true +end +return Intents diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua index 79a0aa417..1fce0a816 100644 --- a/src/mods/BattleSnapshots.lua +++ b/src/mods/BattleSnapshots.lua @@ -1,6 +1,19 @@ --- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, --- item-effect or preview helpers here: some of those consume RNG or write state. +-- Detached Gen 1-5 snapshots and revision-bound native command admission. +-- Snapshot building stays data-only; submit validates before native dispatch. local Ids = require("src.mods.UnifiedIds") +local Intents = require("src.mods.BattleIntents") +local Commands = require("src.battle.BattleCommands") +local Runtime = require("src.mods.Runtime") +-- Native work requested through a facade remains engine work. The buses keep +-- their existing callback/error attribution; lazy requires must not be blamed +-- on the requesting mod by the dev-mode permission shim. +local function enginePcall(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b +end local Snapshots = {} local STATUS = @@ -86,6 +99,13 @@ local function at(battle, pos) end local function activeSlot(battle) + if battle.phase == "mimicSelect" and battle.mimicCtx then + for _, pos in ipairs({ 0, 2 }) do + if at(battle, pos) == battle.mimicCtx.user then + return pos + end + end + end if not battle.double then return 0 end @@ -427,7 +447,8 @@ function Snapshots.new(gameProvider, loader) end local facade = {} local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 - function facade:snapshot() + local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0 + local function refresh() local game = gameProvider() local states = game and game.stack and game.stack.states or {} local battle, index @@ -438,18 +459,108 @@ function Snapshots.new(gameProvider, loader) end end if not battle then - lastGame, lastBattle, previous = nil, nil, nil - return nil, "no battle" + lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil + return nil, nil, nil, "no battle" end + local state = Intents.state(game) local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) - if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + local refs, control = Intents.context(game, battle, states[#states], at) + control = copy(control) + if + game ~= lastGame + or battle ~= lastBattle + or not equal(out, previous) + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + or state.epoch ~= lastEpoch + then revision = revision + 1 end lastGame, lastBattle, previous = game, battle, out - local result = copy(out) + lastRefs, lastControl, lastEpoch = refs, control, state.epoch + return game, battle, index, state + end + function facade:snapshot() + local game, battle, _, state = refresh() + if not battle then + return nil, "no battle" + end + local result = copy(previous) result.revision = revision + if state.busy then + result.prompt = "locked" + end + offeredRevision = revision return result end + function facade:submit(raw) + local intent, reason = Intents.normalize(raw) + if not intent then + return nil, reason + end + if intent.id <= lastIntentId then + return nil, "replayed intent" + end + local game, battle, index, state = refresh() + if not battle then + return nil, "no battle" + end + if state.busy then + return nil, "battle action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale battle context" + end + if offeredRevision ~= revision then + return nil, "fresh battle snapshot required" + end + if battle.game ~= game then + return nil, "battle does not belong to current Game" + end + if index ~= #game.stack.states then + return nil, "battle menu is covered" + end + -- The native admission helpers are read-only. Lock during validation too, + -- so a mod callback/overridden helper cannot recursively enter submission. + local refs, control = lastRefs, lastControl + state.busy = true + local checked, refusal = enginePcall(Commands.reason, battle, intent) + if not checked or refusal then + state.busy = false + return nil, checked and refusal or "battle validation failed" + end + local fresh, currentGame, currentBattle = pcall(refresh) + if not fresh then + state.busy = false + return nil, "battle validation failed" + end + if + game ~= currentGame + or battle ~= currentBattle + or revision ~= intent.revision + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + then + state.busy = false + return nil, "stale battle context" + end + -- Consume BEFORE native code: a callback or partial native failure cannot + -- replay this ID. Both boundaries invalidate snapshots held by other mods, + -- including snapshots observed halfway through an accepted action. + lastIntentId, offeredRevision = intent.id, nil + state.epoch = state.epoch + 1 + local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent) + state.epoch = state.epoch + 1 + state.busy = false + if not ok then + local printable, message = pcall(tostring, accepted) + return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "") + end + if not accepted then + return nil, err or "battle action did not complete; intent consumed" + end + return true + end return facade end -- Run after construction so an optional capability provider and its other @@ -459,7 +570,7 @@ function Snapshots.withCapabilities(api) local supports = api.supports function api:supports(surface, name) if surface == "battle" then - if name == "snapshot" then + if name == "snapshot" or name == "submit" then return true end return false, "unknown battle capability: " .. tostring(name) diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua index 27c992c5f..acf55273d 100644 --- a/tests/engine/battle_snapshots.lua +++ b/tests/engine/battle_snapshots.lua @@ -185,11 +185,11 @@ for _, version in ipairs(GameVersion.ORDER) do T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") if api.supports then T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") - T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") T.eq(resolves(), 0, label .. "probe does not resolve Game") end T.eq(api.battle, api.battle, label .. "stable facade") - T.eq(api.battle.submit, nil, label .. "no mutation method") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") local no, reason = api.battle:snapshot() T.eq(no, nil, label .. "no battle") T.eq(reason, "no battle", label .. "no battle reason") From 418b55ff7359643dcb2e06c2fecfd4f2d620756b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:00:08 +0200 Subject: [PATCH 009/122] Add revision-bound public party, bag and dialogue UI actions --- docs/modding.md | 8 + docs/ui-actions-api.md | 128 ++++++ src/mods/Loader.lua | 8 +- src/mods/UIActions.lua | 241 +++++++++++ src/render/TextBox.lua | 62 ++- src/ui/BagMenu.lua | 7 +- src/ui/ChoiceBox.lua | 28 +- src/ui/Gen3PartyMenu.lua | 47 ++- src/ui/Gen4PartyMenu.lua | 40 +- src/ui/ListMenu.lua | 29 +- src/ui/Menu.lua | 39 +- src/ui/PartyMenu.lua | 778 ++++++++++++++++++----------------- src/ui/UICommands.lua | 346 ++++++++++++++++ tests/engine/ui_actions.lua | 792 ++++++++++++++++++++++++++++++++++++ 14 files changed, 2084 insertions(+), 469 deletions(-) create mode 100644 docs/ui-actions-api.md create mode 100644 src/mods/UIActions.lua create mode 100644 src/ui/UICommands.lua create mode 100644 tests/engine/ui_actions.lua diff --git a/docs/modding.md b/docs/modding.md index 023f89f5d..1e6175c38 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -230,6 +230,14 @@ The console understands these verbs (anything else is evaluated as Lua): - `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `help` — list the verbs. +## Public party, bag and dialogue actions + +`mod.ui:snapshot()` returns detached options for the current native screen; +`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances +ready text through its native flow. See [Public UI actions](ui-actions-api.md) +for revision/ID requirements, per-generation menu behavior and supported screens. +The existing `mod.ui` list-building helpers retain their original signatures. + ## Tool input and title-menu hooks Tool mods that need to act once per game logic tick can wrap `input.step`. diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 08e71b498..7a2d130fc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -22,6 +22,7 @@ local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -918,9 +919,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -1127,6 +1128,7 @@ function Loader:_api(mod) -- the same reason: a headless load must not drag the world stack in. -- Battle capability queries can also be installed before Game exists. BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..533730961 --- /dev/null +++ b/src/mods/UIActions.lua @@ -0,0 +1,241 @@ +-- Per-mod, detached UI snapshots and revision-bound native actions. +local Commands = require("src.ui.UICommands") +local Intents = require("src.mods.BattleIntents") +local Ids = require("src.mods.UnifiedIds") +local Runtime = require("src.mods.Runtime") +local Actions = {} +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then + return v + end + return nil + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, value in pairs(v) do + if type(k) == "string" or type(k) == "number" then + out[k] = copy(value, seen) + end + end + return out +end +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b, c = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b, c +end +local function integer(n) + return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0 +end +local function normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id) or not integer(raw.revision) then + return nil, "invalid UI id or revision" + end + local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind] + if not fields then + return nil, "unknown UI intent" + end + for k in pairs(raw) do + if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then + return nil, "unknown UI intent field" + end + end + if raw.kind == "select" and not integer(raw.index) then + return nil, "invalid UI row" + end + if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then + return nil, "invalid pocket direction" + end + return { + id = raw.id, + revision = raw.revision, + kind = raw.kind, + index = raw.index, + direction = raw.direction, + } +end +function Actions.new(provider, loader, builders) + local ids = Ids.new(loader) + ids:entries("pokemon") + ids:entries("items") + local facade = setmetatable({}, { __index = builders }) + local revision, consumed = 0, 0 + local previous, oldRefs, oldControls, oldEpoch, offered + local function refresh() + local game = provider() + local states = game and game.stack and game.stack.states + local top = states and states[#states] + if not top then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, "no UI screen" + end + local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader)) + if not ok or not view then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, ok and refs or "UI snapshot failed" + end + refs.game = game + -- Capture live eligibility, target and inventory changes even when the + -- native screen has not rebuilt its labels yet. Never return this data. + local controls = copy({ + party = type(top.party) == "table" and top.party or game.save and game.save.party, + inventory = game.save and game.save.inventory, + bagOrder = game.save and game.save.bagOrder, + select = top.select, + menu = top.menu, + submenu = top.submenu, + swapFrom = top.swapFrom, + switchFrom = top.switchFrom, + softboiledFrom = top.softboiledFrom, + hpTransfer = top.hpTransfer, + hpTransferFrom = top.hpTransferFrom, + forceSwitch = top.forceSwitch, + pickOnly = top.pickOnly, + keepOpen = top.keepOpen, + pick = top.pick, + store = top.store, + sell = top.sell, + registeredItem = game.save and game.save.registeredItem, + cur = top.cur, + mem = top.mem, + page = top.page, + top = top.top, + done = top.done, + waiting = top.waiting, + shown = top.shown, + contAdvance = top.contAdvance, + lineIndex = top.lineIndex, + pageIndex = top.pageIndex, + pending = top.pending, + trash = top.trash, + moving = top.moving, + lockPocket = top.lockPocket, + resultMessage = top.resultMessage, + message = top.message, + selectNotice = top.selectNotice, + }) + local state = Intents.state(game) + if + not equal(view, previous) + or not Intents.sameRefs(refs, oldRefs) + or not equal(controls, oldControls) + or state.epoch ~= oldEpoch + then + revision = revision + 1 + end + previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch + return { game = game, top = top, view = view, kind = kind, state = state } + end + function facade:snapshot() + local ctx, err = refresh() + if not ctx then + return nil, err + end + local out = copy(ctx.view) + out.revision = revision + if ctx.state.busy then + out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false + end + offered = revision + return out + end + function facade:submit(raw) + local intent, err = normalize(raw) + if not intent then + return nil, err + end + if intent.id <= consumed then + return nil, "replayed UI intent" + end + local ctx + ctx, err = refresh() + if not ctx then + return nil, err + end + local state, view = ctx.state, ctx.view + if state.busy then + return nil, "UI action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale UI context" + end + if offered ~= revision then + return nil, "fresh UI snapshot required" + end + if view.locked then + return nil, "UI is locked" + end + if intent.kind == "select" then + if not view.rows[intent.index] or not view.rows[intent.index].enabled then + return nil, "unavailable UI row" + end + elseif intent.kind == "back" and not view.canBack then + return nil, "UI cannot go back" + elseif intent.kind == "advance" and not view.canAdvance then + return nil, "UI cannot advance" + elseif intent.kind == "pocket" and not view.canPocket then + return nil, "UI cannot change pocket" + end + -- Native work can raise after partial effects. Consume before dispatch; + -- the shared epoch also expires snapshots held by battle/other UI facades. + consumed, offered = intent.id, nil + state.busy, state.epoch = true, state.epoch + 1 + local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent) + state.busy, state.epoch = false, state.epoch + 1 + if not ok then + local printable, message = pcall(tostring, failure) + return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "") + end + return true + end + return facade +end +function Actions.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "ui" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown UI capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end +end +return Actions diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d229471d..4020375b6 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1234,11 +1234,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..855f2c805 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -1015,6 +1015,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1073,20 +1105,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index ce9163880..851b8ec29 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -876,6 +876,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -908,23 +930,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..4b487d623 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -472,6 +472,399 @@ function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end +-- Native confirmation shared by input and the public UI facade. +function PartyMenu:confirm() + local party = self.party or self.game.save.party + if self.submenu then + local mon = party[self.index] + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) + Screens.push(self.game, "SummaryMenu", mon, { + mons = party, + index = self.index, + onMonChange = function(index) self.index = index end, + }) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "item" then + self:openItemMenu(mon) + elseif action == "quit" then + self.submenu = nil + elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld + self.game.stack:pop() -- close the party menu + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + -- The other half of CheckUseFlash: using it in the Aerodactyl chamber + -- opens the wall. The chamber's scene script plays the opening the + -- next time the map loads, exactly as the escape-rope one does. + require("src.script.RuinsOfAlph").aerodactylChamber(self.game) + self.game.save.flashLit = true + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._FlashLightsAreaText + or Strings("A blinding FLASH\nlights the area!"), function() + self:close() + -- setDark, not a bare field write: ADVANCED carries the darkness + -- in a baked atlas, so lighting the cave drops every resident map + -- and rebakes this one (#383). It runs HERE, before the blink, + -- because start_sub_menus.asm .flash clears wMapPalOffset before + -- PrintText and blinks last of all: the cave is already lit by the + -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the + -- blink's completion instead left that rebuild's whole cost -- + -- seconds of per-pixel atlas baking on a phone -- on screen as a + -- solid white frame with nothing under it, which reads as a + -- lockup (#610). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy, function() self:close() end) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press + -- steps the player forward onto land (or across a connection + -- strip when the shore is the next map's edge) + self.game.stack:pop() + ow.player.surfing = false + require("src.core.Music").setSurfing(self.game.data, false) + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + ow:stepForwardOrCrossEdge(ow.player.facing) + end)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) + self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UseStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _MoveBoulderText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + local def = self.game.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or mon.species + ow.strengthActive = true + -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; + -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. + local t1raw = self.game.data.text._UseStrengthText + or self.game.data.text._UsedStrengthText + local t2raw = self.game.data.text._MoveBoulderText + or self.game.data.text._CanMoveBouldersText + local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) + local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) + -- like surf (#320, #385): both texts print with the party menu + -- still on screen, and the blink IS the menu closing afterwards, + -- not a flashbang on the empty map + self.game.stack:push(TextBox.new(self.game, t1, function() + self.game.stack:push(TextBox.new(self.game, t2, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(self.game.data, mon.species) + end } })) + return + elseif action == "gen2_field" then + -- GSC runs the same handler from the menu as from the overworld + -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the + -- tile check lives in one place. + local ow = self.game.overworld + local fx, fy = ow.player:facingCell() + self:close() + if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then + local TextBox = require("src.render.TextBox") + -- _CantSurfText is "You can't SURF here", so using it for every + -- field move told a player holding HEADBUTT that the game thought + -- they had picked SURF. Only SURF gets the surf line. + local text = (entry.move == "SURF") + and self.game.data.text._CantSurfText or nil + self.game.stack:push(TextBox.new(self.game, + text or Strings("You can't use that\nhere."))) + end + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "dig" then + -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as + -- a DOOR warp so the player steps out of the opening rather than + -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares + -- TELEPORT's Pokemon Center warp, so it falls through to the same + -- departure with no target. + local ow = self.game.overworld + self.game.stack:pop() + if ow then + local escape = require("src.core.GameVersion").isGen2() + and ow.escapePoint and ow:escapePoint() and true or nil + ow:beginTeleportOut(nil, { escape = escape }) + end + return + elseif action == "escape" then + -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction + -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup + -- / wLastSpawnMapNumber, so this destination is right for both + -- generations. Only DIG diverged; it has its own branch above. + -- pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. + local ow = self.game.overworld + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + return + end + self.submenu = nil + return + end + if #party == 0 then return end + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) + else + self.submenu = true + self.subIndex = 1 + local items + local ow = self.game.overworld + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + -- The badge gate per field move comes from constants.hmBadges and + -- the badge itself through Badges.has: Gen2 gates the same moves on + -- the Johto badges and stores them as engine flags rather than bag + -- items, so the hard-coded R/B item names listed nothing at all. + local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} + local function badged(moveId) + local gate = gates[moveId] + if not (gate and gate.badge) then return true end + return Badges.has(self.game.save, { id = gate.badge }) + end + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside and badged("FLY") then + table.insert(items, { label = Strings("FLY"), action = "fly" }) + -- LIGHT is carved on the Aerodactyl chamber wall, and the + -- chamber is not a dark cave -- so CheckUseFlash asks + -- SpecialAerodactylChamber FIRST and lets FLASH be used on its + -- carry, before it ever looks at the palset + -- (engine/events/overworld.asm:285). Without this the move is + -- never even offered there and the wall can never open. + elseif mv.id == "FLASH" and badged("FLASH") + and (ow.dark or (ow.map and ow.map.id + == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then + table.insert(items, { label = Strings("FLASH"), action = "flash" }) + elseif mv.id == "CUT" and badged("CUT") then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = Strings("CUT"), action = "cut" }) + elseif mv.id == "SURF" and badged("SURF") then + table.insert(items, { label = Strings("SURF"), action = "surf" }) + elseif mv.id == "STRENGTH" and badged("STRENGTH") then + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) + elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) + elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" + and (DIG_TILESETS[ow.map.def.tileset] + or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] + and ow.escapePoint and ow:escapePoint())) then + -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel). + -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a + -- recorded entrance to come back out of. Its own action, + -- because Gen 2's DIG and TELEPORT no longer share a + -- destination the way Gen 1's did. + table.insert(items, { label = Strings("DIG"), action = "dig" }) + elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then + -- GSC's own field moves; the tile check happens on selection, + -- exactly like CUT's (start_sub_menus.asm never gates the list + -- on where the player is standing) + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "gen2_field", move = mv.id }) + end + end + end + if require("src.core.GameVersion").isGen2() then + -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, + -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. + local stats, switch = table.remove(items, 1), table.remove(items, 1) + items[#items + 1] = stats + items[#items + 1] = switch + items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings("QUIT"), action = "quit" } + end + end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items + end +end + +function PartyMenu:cancel() + if self.submenu then self.submenu = nil; return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -505,245 +898,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land (or across a connection - -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or (def and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +910,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") From 4bfa6683fc334697ec2622895a3678e4e4b8d525 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:15:11 +0200 Subject: [PATCH 010/122] Add opt-in prevalidated portable world dialogue scripts --- docs/modding.md | 7 + docs/portable-world-scripts.md | 164 +++++ src/mods/Loader.lua | 8 +- src/mods/Manifest.lua | 5 + src/mods/PortableScripts.lua | 543 +++++++++++++++ src/mods/ScriptProgram.lua | 160 +++++ tests/engine/portable_world_scripts.lua | 856 ++++++++++++++++++++++++ 7 files changed, 1741 insertions(+), 2 deletions(-) create mode 100644 docs/portable-world-scripts.md create mode 100644 src/mods/PortableScripts.lua create mode 100644 src/mods/ScriptProgram.lua create mode 100644 tests/engine/portable_world_scripts.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..57f514c97 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -87,6 +87,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..d1d9d5e2b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,6 +11,7 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local PortableScripts = require("src.mods.PortableScripts") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1125,7 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage - setmetatable(api, { __index = function(_, key) + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1141,7 +1142,7 @@ function Loader:_api(mod) if not module then return nil end world = module.new(game, modId) return world - end }) + end }, api, loader, mod.manifest)) return api end @@ -1196,6 +1197,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1310,6 +1312,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1656,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..61ae1c60e 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -240,6 +240,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and "native" or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -325,6 +329,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function owned(state, step) + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..4bfc0a813 --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,160 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +function Program.supports(verb) + return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") +end +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil + end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, max) then + return nil + end + n = n + 1 + end + for i = 1, n do + if rawget(t, i) == nil then + return nil + end + end + return n +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil +end +local function text(value, max) + if type(value) ~= "string" or #value > max or value:find("%z") then + return false + end + local i = 1 + while i <= #value do + local b = value:byte(i) + local n = b < 128 and 1 + or b >= 194 and b <= 223 and 2 + or b >= 224 and b <= 239 and 3 + or b >= 240 and b <= 244 and 4 + if not n or i + n - 1 > #value then + return false + end + for j = i + 1, i + n - 1 do + local c = value:byte(j) + if c < 128 or c > 191 then + return false + end + end + if n > 1 then + local c = value:byte(i + 1) + if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then + return false + end + end + i = i + n + end + return true +end +function Program.compile(raw) + local n = array(raw, Program.MAX_ROWS) + if not n then + return nil, "script must be a dense plain array of at most 1024 rows" + end + local rows, labels, bytes = {}, {}, 0 + local function fail(i, message) + return nil, "row " .. i .. ": " .. message + end + for i = 1, n do + local source, count = raw[i], array(raw[i], 3) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = source[1] == "text" and "show_text" or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if verb == "show_text" or verb == "ask" then + if not text(source[2], 4096) then + return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") + end + bytes = bytes + #source[2] + elseif verb == "choice" then + local size = array(source[2], 16) + if not size or size == 0 then + return fail(i, "choice needs 1-16 labels") + end + row[2] = {} + for j = 1, size do + local value = source[2][j] + if not text(value, 128) or #value == 0 then + return fail(i, "invalid choice label") + end + row[2][j], bytes = value, bytes + #value + end + local opts = source[3] + row[3] = { default = 1, cancel = size } + if opts ~= nil then + if type(opts) ~= "table" or getmetatable(opts) ~= nil then + return fail(i, "choice options must be a plain table") + end + for key, value in pairs(opts) do + if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then + return fail(i, "invalid choice option") + end + row[3][key] = value + end + end + elseif verb == "wait" then + if not integer(source[2], 0, 36000) then + return fail(i, "wait must be 0-36000 native logic frames") + end + elseif verb == "label" then + if not label(source[2]) or source[2] == "end" or labels[source[2]] then + return fail(i, "invalid or duplicate label") + end + labels[source[2]] = i + elseif verb ~= "end" then + if not label(source[2]) and not integer(source[2], 1, n) then + return fail(i, "invalid jump target") + end + end + if bytes > 131072 then + return fail(i, "script text exceeds 128 KiB") + end + rows[i] = row + end + for i, row in ipairs(rows) do + if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then + local target = row[2] + if type(target) == "string" then + if target == "end" then + row[2] = n + 1 + elseif labels[target] then + row[2] = labels[target] + else + return fail(i, "jump to missing label") + end + end + end + end + return rows +end +return Program diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ccef037d9 --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,856 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local WorldAPI = require("src.world.WorldAPI") +local GameVersion = require("src.core.GameVersion") +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Json = require("src.link.Json") +local originalVersion = GameVersion.get() +local function boot(version) + GameVersion.set(version) + local data, files = T.fixtures.fresh(), {} + for _, id in ipairs({ "probe", "other", "legacy" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 2, + entry = "main.lua", + script_format = id ~= "legacy" and "unified" or nil, + content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "", + }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + if mod.id ~= "legacy" then + assert(mod.scriptFormat == "unified") + if mod.manifest.description == "all-formats" then + assert(mod.contentFormat == "unified") + assert(mod.callbackFormat == "unified") + end + local ok, why = mod.world:queueScript({ { "wait", 1 } }) + assert(ok == nil and type(why) == "string") + else assert(mod.scriptFormat == "native") end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other", "legacy" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game") + Font.load(data) + local game = { + data = data, + save = SaveData.newGame(), + stack = setmetatable({ states = {} }, { __index = StateStack }), + } + local ow = { + isOverworld = true, + map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN }, + player = { cellX = 4, cellY = 5, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + parallelRunners = {}, + } + game.overworld, ow.game = ow, game + ow.runner = Runner.new(game, ow) + function ow:update() + self.runner:update() + end + game.stack.states = { ow } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + loader.game = game + return loader.apis.probe.world, game, ow, loader, files +end +local function tick(game, key) + game.input.wasPressed = function(_, k) + return k == key + end + Runtime.call("input.step", function() end, game, 1 / 60) + game.stack:update(1 / 60) + game.input.wasPressed = function() + return false + end +end +local function untilState(game, predicate) + for _ = 1, 2000 do + if predicate() then + return + end + tick(game) + end + error("native UI did not reach requested state") +end +local function finishText(game) + local box = game.stack:top() + untilState(game, function() + return box.done or box.waiting and (box.preWait or 0) <= 0 + end) + tick(game, "a") +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local before, calls, result = ow.runner, 0, nil + T.check( + world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + version .. " program admitted" + ) + T.neq(ow.runner, before, "isolated native runner installed") + T.eq(ow.runner:isRunning(), true, "runner locks foreground input") + tick(game) + T.eq(game.stack:top(), ow, "wait does not end early") + tick(game) + T.neq(game.stack:top(), ow, "native text appears after wait") + T.eq(calls, 0, "completion awaits text") + finishText(game) + T.same(result, { true }, "success completion") + T.eq(calls, 1, "completion once") + T.eq(ow.runner, before, "idle native runner restored") + T.eq(game.stack:top(), ow, "script text closed") + + local Choice = require("src.ui.ChoiceBox") + for _, yes in ipairs({ true, false }) do + local lines = {} + T.check( + world:queueScript({ + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "text", "YES" }, + { "jump", "end" }, + { "label", "no" }, + { "text", "NO" }, + }, { + onDone = function(ok) + lines[#lines + 1] = ok + end, + }), + "question script admitted" + ) + untilState(game, function() + return getmetatable(game.stack:top()) == Choice + end) + local choice = game.stack:top() + tick(game, yes and "a" or "b") + T.eq(choice.pending, yes, "native answer pending") + T.eq(#lines, 0, "answer hold preserved") + untilState(game, function() + return game.stack:top() ~= choice + end) + T.same( + game.stack:top().pages, + require("src.render.TextBox").paginate( + yes and "YES" or "NO", + game.stack:top().maxCols, + game.stack:top().letterSpacing, + game.stack:top().maxPixels, + true + ), + "correct branch text" + ) + finishText(game) + T.same(lines, { true }, "question branch completes") + end + local trace = {} + T.check( + world:queueScript({ + { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } }, + { "jump_if_false", 5 }, + { "text", "WRONG" }, + { "end" }, + { "text", "RIGHT" }, + }, { + onDone = function(ok) + trace[#trace + 1] = ok + end, + }), + "multiple choice admitted" + ) + T.eq(game.stack:top().index, 2, "native default selection") + tick(game, "b") + finishText(game) + T.same(trace, { true }, "native B choice follows false branch") + T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged") +end + +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(world, game, rows, options, label) + local unchanged = freeze(game) + local ok, why = world:queueScript(rows, options) + T.eq(ok, nil, label) + T.eq(type(why), "string", label .. " has reason") + unchanged(label .. " is observational") +end +local badPrograms = { + false, + 1, + "text", + function() end, + { [1] = { "wait", 1 }, [3] = { "end" } }, + { [1] = { "wait", 1 }, extra = true }, + setmetatable({ { "end" } }, {}), + { false }, + { {} }, + { { "wait" } }, + { { "wait", 1, 2 } }, + { { "wait", -1 } }, + { { "wait", 1.5 } }, + { { "wait", 0 / 0 } }, + { { "wait", math.huge } }, + { { "wait", 36001 } }, + { { "end", false } }, + { { "native_mutate", {} } }, + { { "show_text", {} } }, + { { "show_text", string.rep("x", 4097) } }, + { { "text", "Valid" }, { "give_item", "POTION" } }, + { { "text", "Valid" }, { "jump", "missing" } }, + { { "label", "same" }, { "label", "same" } }, + { { "label", "end" } }, + { { "label", "bad\nname" } }, + { { "jump", 0 } }, + { { "jump", 99 } }, + { { "jump", false } }, + { { "choice", {} } }, + { { "choice", { "A", "B" }, { cancel = 3 } } }, + { { "choice", { "A" }, { default = 0 } } }, + { { "choice", { "A" }, { custom = true } } }, + { { "choice", { "A", function() end } } }, + { { "choice", { "" } } }, + { { "choice", { "A" }, setmetatable({}, {}) } }, + { setmetatable({ "wait", 1 }, {}) }, +} +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local callbacks = 0 + for _, rows in ipairs(badPrograms) do + refusal(world, game, rows, { + onDone = function() + callbacks = callbacks + 1 + end, + }, "invalid whole program") + end + for _, options in ipairs({ + false, + 1, + "options", + { onDone = true }, + { onDone = function() end, runner = {} }, + setmetatable({}, {}), + }) do + refusal(world, game, { { "wait", 1 } }, options, "invalid options") + end + T.eq(callbacks, 0, "rejected lists never complete or execute a prefix") + for _, key in ipairs({ + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + }) do + ow[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world") + ow[key] = nil + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player") + ow.player[key] = nil + end + for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do + ow[key] = { {} } + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling") + ow[key] = {} + end + game.linkSession = {} + refusal(world, game, { { "wait", 1 } }, nil, "link session busy") + game.linkSession = nil + game.linkNet = { closed = false } + refusal(world, game, { { "wait", 1 } }, nil, "link transport busy") + game.linkNet = nil + game.stack:push({}) + refusal(world, game, { { "wait", 1 } }, nil, "covered world busy") + game.stack:pop() + ow.runner:run({ { "wait", 9 } }) + refusal(world, game, { { "wait", 1 } }, nil, "native script busy") + for _ = 1, 9 do + tick(game) + end + T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion") + tick(game) + T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program") +end +-- Limits, data copying and compiler immutability, including a cyclic payload. +do + local world, game, ow = boot("crystal") + local over = {} + for i = 1, 1025 do + over[i] = { "end" } + end + refusal(world, game, over, nil, "row bound") + over = {} + for i = 1, 33 do + over[i] = { "text", string.rep("x", 4096) } + end + refusal(world, game, over, nil, "total text bound") + local cycle = {} + cycle[1] = cycle + refusal(world, game, cycle, nil, "cyclic command denied") + local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } } + local unchanged = freeze(rows) + local compiled = Program.compile(rows) + unchanged("compiler leaves caller rows intact") + T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels") + T.check(world:queueScript(rows), "copied program accepted") + rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1 + tick(game) + tick(game) + T.eq(game.stack:top().index, 2, "choice options captured before caller edit") + T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution") + world:cancelScript() +end +-- Session changes cancel even while an old Game is no longer being stepped. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do + for _, change in ipairs({ + "save", + "data", + "stack", + "states", + "map", + "mapId", + "player", + "position", + "runner", + "game", + "version", + "removed", + "transition", + }) do + local world, game, ow, loader = boot(version) + local originalRunner, calls, result = ow.runner, 0 + T.check( + world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + "session test admitted" + ) + local stale = ow.runner + local replacement + if change == "save" then + game.save = SaveData.newGame() + elseif change == "data" then + game.data = T.fixtures.fresh() + elseif change == "stack" then + game.stack = setmetatable({ states = { ow } }, { __index = StateStack }) + elseif change == "states" then + game.stack.states = { ow } + elseif change == "map" then + ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE } + elseif change == "mapId" then + ow.map.id = "FIX_ROUTE" + elseif change == "player" then + ow.player = { cellX = 4, cellY = 5 } + elseif change == "position" then + ow.player.cellX = 7 + elseif change == "runner" then + replacement = Runner.new(game, ow) + ow.runner = replacement + elseif change == "game" then + replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input } + loader.game = replacement + elseif change == "version" then + GameVersion.set(version == "red" and "crystal" or "red") + elseif change == "removed" then + game.stack.states = {} + elseif change == "transition" then + ow.transitioning = true + end + Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60) + T.eq(calls, 1, change .. " cancellation delivered") + T.eq(result[1], false, "session cancellation is not success") + T.eq(type(result[2]), "string", "cancellation gives reason") + T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner") + stale:resume() + stale:update() + T.eq(calls, 1, "old runner callbacks cannot replay cancellation") + T.eq(stale:isRunning(), false, "abandoned coroutine released") + GameVersion.set(version) + end +end +-- Cancel removes only owned windows, including the choice above its text. +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local originalRunner, calls = ow.runner, 0 + T.check( + world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, { + onDone = function(ok) + T.eq(ok, false, "explicit cancel completion") + calls = calls + 1 + end, + }), + "cancellable question" + ) + local box = game.stack:top() + untilState(game, function() + return game.stack:top() ~= box + end) + local choice = game.stack:top() + local foreign = { update = function() end } + game.stack:push(foreign) + T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority") + T.check(world:cancelScript(), "owner cancels") + T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation") + T.eq(ow.runner, originalRunner, "cancel restores idle runner") + T.eq(calls, 1, "cancel callback once") + choice.onChoose(true) + box.onDone() + choice:update(1 / 60) + box:update(1 / 60) + T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen") + T.eq(calls, 1, "stale native callbacks do not complete twice") +end +-- Replaying an old callback during a later row must not skip that row's wait. +do + local world, game, ow = boot("red") + T.check( + world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }), + "callback replay test admitted" + ) + local old = game.stack:top() + finishText(game) + T.eq(ow.runner.waitingFrames, 10, "later wait begins") + old.onDone() + old:update(1 / 60) + T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait") + for _ = 1, 10 do + tick(game) + end + T.neq(game.stack:top(), ow, "new text after full wait") + world:cancelScript() +end + +-- Native error cleanup, completion exceptions and event reentry are bounded. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do + local world, game, ow, loader = boot(version) + local TextBox = require("src.render.TextBox") + local original, make = ow.runner, TextBox.new + local completions, result = 0 + TextBox.new = function() + error("injected native constructor failure") + end + Runtime.currentMod = "probe" + T.check( + world:queueScript({ { "text", "Fail" } }, { + onDone = function(ok, why) + completions = completions + 1 + result = { ok, why } + end, + }), + "entered native failure is admitted" + ) + T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure") + Runtime.currentMod = nil + TextBox.new = make + T.eq(result[1], false, "native error completes as failure") + T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained") + T.eq(completions, 1, "native error completion once") + T.eq(ow.runner, original, "native error releases runner") + T.check( + world:queueScript({}, { + onDone = function() + error("completion failure") + end, + }), + "callback error isolated" + ) + T.eq(ow.runner, original, "callback error cannot strand runner") + local events = {} + local stop1 = loader.events:on("script.started", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = "started" + refusal( + loader.apis.other.world, + game, + { { "wait", 1 } }, + nil, + "event callback cannot reenter owned run" + ) + end + end) + local stop2 = loader.events:on("script.ended", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = ev.completed and "success" or "failure" + end + end) + T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted") + tick(game) + T.same(events, { "started", "success" }, "one lifecycle event pair") + T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted") + world:cancelScript() + T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once") + stop1() + stop2() +end +-- An infinite authored jump yields slices to the engine and eventually +-- reports failure rather than blocking a frame or permanently owning input. +do + local world, game, ow = boot("red") + local original, result, ticks = ow.runner, nil, 0 + T.check( + world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, { + onDone = function(ok, why) + result = { ok, why } + end, + }), + "loop admitted" + ) + T.eq(result, nil, "loop yields back from initial call") + untilState(game, function() + ticks = ticks + 1 + return result ~= nil + end) + T.check(ticks > 1, "loop spans engine updates") + T.eq(result[1], false, "instruction limit fails explicitly") + T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason") + T.eq(ow.runner, original, "budget exhaustion releases world") +end +-- Teardown/reload and failed entry chunks retire captured facades. Neither +-- completion nor a script-ended listener may start fresh work during teardown. +for _, mode in ipairs({ "teardown", "reload", "rollback" }) do + local world, game, ow, loader, files = boot("emerald") + local original, calls, retry = ow.runner, 0 + T.check( + world:queueScript({ { "text", "Pending" } }, { + onDone = function(ok) + T.eq(ok, false, "lifecycle cancellation") + calls = calls + 1 + retry = world:queueScript({ { "wait", 1 } }) + end, + }), + "lifecycle program accepted" + ) + if mode == "teardown" then + Loader.teardown() + elseif mode == "reload" then + boot("emerald") + else + loader:_rollback("probe") + end + T.eq(calls, 1, "lifecycle completion once") + T.eq(retry, nil, "no restart while retiring") + T.eq(ow.runner, original, "lifecycle restores runner") + T.same(game.stack.states, { ow }, "lifecycle removes owned text") + refusal(world, game, { { "wait", 1 } }, nil, "retained API retired") +end +do + local _, game, ow, loader, files = boot("crystal") + local original = ow.runner + files["mods/probe/main.lua"] = [[return function(mod) + assert(mod.world:queueScript({ { "wait", 30 } })) + error("entry failure after queue") + end]] + files["mods/other/main.lua"] = "return function() end" + local failed = Loader.new({ fs = loader.fs, dev = false }) + failed.game = game + failed:load(T.fixtures.fresh()) + T.eq(ow.runner, original, "failed entry cancels its accepted run") + T.eq(original:isRunning(), false, "failed entry cannot strand foreground") +end +-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed +-- or API-1 unified declarations must fail instead of silently changing mode. +do + local function manifest(format, level) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = level or 2, + script_format = format, + } + end + for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do + T.raises(function() + Manifest.validate(manifest(bad), "mods/probe") + end, "script_format", "invalid format") + end + T.raises(function() + Manifest.validate(manifest("unified", 1), "mods/probe") + end, "API 2", "unified requires API 2") + T.eq( + Manifest.validate(manifest(nil, 1), "mods/probe").script_format, + "native", + "API 1 default remains native" + ) + T.eq( + Manifest.validate(manifest("native", 1), "mods/probe").script_format, + "native", + "explicit API 1 native accepted" + ) +end +-- Per-frame traces against the real native runner/commands, rather than +-- against another implementation of the portable adapter. +local function stateRecord(game, ow) + local top = game.stack:top() + local record = + { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames } + if top ~= ow then + record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames + record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex + record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait + if top.shown then + record.shown = Json.decode(Json.encode(top.shown)) + end + if top.items then + record.labels = {} + for _, item in ipairs(top.items) do + record.labels[#record.labels + 1] = item.label + end + end + end + return record +end +for _, version in ipairs(GameVersion.ORDER) do + for _, yes in ipairs({ true, false }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local world, game, ow = boot(version) + local rows = { + { "wait", 2 }, + { "show_text", "First\fSecond" }, + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } }, + { "jump", "end" }, + { "label", "no" }, + { "show_text", "NO" }, + } + local completed, trace = false, {} + local saveUnchanged = freeze(game.save) + if native then + ow.runner:run(rows, { + onDone = function() + completed = true + end, + }) + else + T.check( + world:queueScript(rows, { + onDone = function(ok) + T.eq(ok, true, "parity program succeeds") + completed = true + end, + }), + "parity program admitted" + ) + end + for frame = 1, 2000 do + trace[#trace + 1] = stateRecord(game, ow) + if completed then + break + end + local top = game.stack:top() + local key + if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then + key = yes and "a" or "b" + elseif top.items then + key = "b" + elseif top.done or top.waiting and (top.preWait or 0) <= 0 then + key = "a" + end + tick(game, key) + end + T.eq(completed, true, "native parity trace completed") + saveUnchanged("dialogue program leaves save unchanged") + traces[#traces + 1] = trace + end + T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity") + end +end + +do + local world, game, ow, loader = boot("black") + for _, invalid in ipairs({ + "bad\0text", + "\128", + "\192\128", + "\224\128\128", + "\237\160\128", + "\244\144\128\128", + "\240\159", + }) do + refusal( + world, + game, + { { "text", "Valid first" }, { "ask", invalid } }, + nil, + "invalid UTF-8 refused before prefix" + ) + end + T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted") + world:cancelScript() + local calls = 0 + local stop = loader.hooks:wrap("input.step", function(nextFn, ...) + calls = calls + 1 + return nextFn(...) + end, -1, "other") + for _ = 1, 3 do + T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission") + world:cancelScript() + end + local before = calls + tick(game) + T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks") + stop() + local random = math.random + local rolls = 0 + math.random = function() + rolls = rolls + 1 + error("portable scripts must not roll RNG") + end + T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue") + finishText(game) + math.random = random + T.eq(rolls, 0, "no RNG consumed") + local events = {} + local done = loader.events:on("script.ended", function(ev) + events[#events + 1] = ev.completed + end) + T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission") + local box = game.stack:top() + -- The guarded method captures the native update when the box is created. + -- Fail native work reached on a later frame instead of changing the guard. + box.beginLine = function() + error("native text update failure") + end + box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true + tick(game, "a") + T.same(events, { false }, "native UI update error completes once") + T.eq(game.stack:top(), ow, "native update error releases owned UI") + done() +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local api = loader.apis.probe + if api.supports then + T.eq(api:supports("world", "queueScript"), true, "portable queue capability") + T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability") + for _, verb in ipairs({ + "text", + "show_text", + "ask", + "choice", + "wait", + "label", + "jump", + "jump_if_true", + "jump_if_false", + "end", + }) do + T.eq(api:supports("scripts", verb), true, "bounded verb capability") + end + T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") From b46e1289f0038b1538190b956ce07117e5fda066 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:07:37 +0200 Subject: [PATCH 011/122] Add read-only map overview and encounter table queries --- docs/modding.md | 2 + docs/world-overview.md | 105 +++++ src/mods/Loader.lua | 3 + src/mods/UnifiedIds.lua | 85 ++++ src/mods/WorldOverview.lua | 153 +++++++ src/world/MapOverview.lua | 176 ++++++++ src/world/WorldAPI.lua | 15 + tests/engine/world_overview_api.lua | 660 ++++++++++++++++++++++++++++ 8 files changed, 1199 insertions(+) create mode 100644 docs/world-overview.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldOverview.lua create mode 100644 src/world/MapOverview.lua create mode 100644 tests/engine/world_overview_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2e08c6136 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,8 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ba7509711 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldOverview = require("src.mods.WorldOverview") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1125,6 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local overviewContext = WorldOverview.install(api, loader) setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1140,6 +1142,7 @@ function Loader:_api(mod) local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end world = module.new(game, modId) + world.overviewContext = overviewContext return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..ea1a6e542 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,6 +9,8 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI @@ -58,6 +60,19 @@ function WorldAPI:current() elevation) } end +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") From 69c3474958ec09b48b91b55ff8cfd0ed15501d46 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:55:26 +0200 Subject: [PATCH 012/122] 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 013/122] 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 014/122] 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 015/122] 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 016/122] 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 017/122] 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 018/122] 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 019/122] 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 020/122] 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 021/122] 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 022/122] 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 023/122] 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 024/122] 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 025/122] 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 026/122] 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 027/122] 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 028/122] 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 029/122] 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 030/122] 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 031/122] 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 032/122] 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 033/122] 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 034/122] 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 035/122] 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 036/122] 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 037/122] 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 038/122] 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 039/122] 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 040/122] 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 041/122] 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 042/122] 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 043/122] 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 044/122] 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 045/122] 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 046/122] 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 047/122] 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 048/122] 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 049/122] 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 050/122] 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 051/122] 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 052/122] 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 053/122] 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 054/122] 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 055/122] 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 056/122] 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 057/122] 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 058/122] 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 059/122] 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 060/122] 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 061/122] 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 062/122] 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 063/122] 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 064/122] 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 065/122] 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 066/122] 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 067/122] 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 068/122] 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 069/122] 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 070/122] 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 071/122] 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 072/122] 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 073/122] 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 074/122] 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 075/122] 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 076/122] 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 077/122] 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 078/122] 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 079/122] 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 080/122] 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 081/122] 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 082/122] 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 083/122] 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 084/122] 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 085/122] 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 086/122] 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 087/122] 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 088/122] 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 089/122] 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 090/122] 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. From 167f6c91944f4a72e77d0459615212593bd282ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:10:42 +0200 Subject: [PATCH 091/122] Add native public charge-turn decisions and shared battle contracts --- .../workflows/charge-required-contract.yml | 44 +++++ docs/api3-portability.md | 3 + docs/modding.md | 3 + docs/portable-charge-required.md | 79 +++++++++ examples/portable_charge_required/main.lua | 32 ++++ .../portable_charge_required/manifest.json | 1 + src/battle/BattleState.lua | 11 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 2 + tests/charge_required/cases.lua | 152 ++++++++++++++++++ tests/charge_required/worker.lua | 31 ++++ tests/engine/charge_required_contract.lua | 72 +++++++++ tools/charge-required-contract-check/check.py | 61 +++++++ 13 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/charge-required-contract.yml create mode 100644 docs/portable-charge-required.md create mode 100644 examples/portable_charge_required/main.lua create mode 100644 examples/portable_charge_required/manifest.json create mode 100644 tests/charge_required/cases.lua create mode 100644 tests/charge_required/worker.lua create mode 100644 tests/engine/charge_required_contract.lua create mode 100644 tools/charge-required-contract-check/check.py diff --git a/.github/workflows/charge-required-contract.yml b/.github/workflows/charge-required-contract.yml new file mode 100644 index 000000000..948479389 --- /dev/null +++ b/.github/workflows/charge-required-contract.yml @@ -0,0 +1,44 @@ +name: portable charge-required contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: charge-required-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native battle charge decisions and compatibility + run: | + luajit tests/engine/charge_required_contract.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/charge-required-contract + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/charge-required-contract/api1-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/charge-required-contract/api2-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/charge-required-contract/api2-unified.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/charge-required-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: charge-required-contract + path: /tmp/charge-required-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index c61cc6019..65d3f2479 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -152,3 +152,6 @@ 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 and GBA scheduling phases. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. diff --git a/docs/modding.md b/docs/modding.md index a48460651..065f6b47c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -394,3 +394,6 @@ API-2 mods can select `"content_format": "unified"` to read and edit common Pokémon, move, item, trainer and encounter definition shapes. Existing mods keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) for the manifest guard, generation coverage, symbolic IDs and failure behavior. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. diff --git a/docs/portable-charge-required.md b/docs/portable-charge-required.md new file mode 100644 index 000000000..a1c70ae95 --- /dev/null +++ b/docs/portable-charge-required.md @@ -0,0 +1,79 @@ +# Portable charge-turn decisions + +`battle.charge_required(next, ctx)` lets a mod skip the initial charge turn +of a native two-turn move. Gen1–3 hosts expose it; DS reports unavailable and +retains its current native move flow. + +```lua +local mod = ... +if mod:supports("hooks", "battle.charge_required") then + mod.hooks:wrap("battle.charge_required", function(next, ctx) + if myOptions.instantChargeMoves then return false end + return next(ctx) + end) +end +``` + +The context has `battle`, `user`, `target`, `move`, `charge = true` and +`isCalled`. `next(ctx)` returns `ctx.charge`. Only an explicit false skips the +charge; nil, zero and other values retain it, as in the reference engines. +Setting `ctx.charge = false` before `next(ctx)` is another way to bypass. + +The native pipeline invokes the hook only when it would otherwise enter a +first charge turn. Regular moves, releases and native sunny SolarBeam skips do +not invoke it. It cannot force a charge turn for an ordinary move or override +a native weather skip. Called moves set `isCalled = true` and keep their +existing caller-owned PP semantics. + +A bypass continues ordinary native damage/effects on the same turn. It stores +no charge continuation or semi-invulnerable state. GBA's queued move animation +uses the release branch (`moveTurn = 1`). A delegated charge retains native +continuation, invulnerability, charge text and animation; release clears that +state and does not spend a second player PP. No subscriber means the engine +does not call Runtime for this seam. Unsubscribe and failed-entry rollback +restore this path. Wrapper errors retain normal hook-bus recovery; repeated +API-3 `next` calls reuse the downstream verdict. + +API 3 and Gen2Recomped's API-2 unified mode expose common move/actor records. +Legacy native IDs and object layouts remain engine-specific; forward opaque +battle handles and use API 3 for portable field access. The producer does not +translate the whole battle implementation into the other engine's layout. + +## Validation and remaining native differences + +`examples/portable_charge_required` has no engine imports. The same entry is +loaded through both SDKs. The shared harness drives the actual GB +`BattleState:performMove`, Gold `Battle:useMove` and GBA `Engine.resolveMove` +paths in Gen1Recomp, and Gen2Recomped's native `BattleState:performMove`. +Fixtures supply native data/cache records; the hook producer, verdict, +charge state and damage pipeline are not mocked. + +Tests cover SolarBeam, Fly, Dig, Razor Wind, Skull Bash and Sky Attack, player +and enemy actions, release, caller PP, native sunlight, context/ID formats, +false versus nil/zero, errors, API-3 replay, unsubscribe, genuine no-mod cases +and failed-entry rollback. Native tests cover every registered host, SDK mode, +DS exclusion and the GBA release animation branch. + +This proves the tested public decision contract, not identical damage amounts, +text, all move side effects or every underlying battle rule. In particular, +Gen2Recomped currently leaves Gold's default battles on its Gen-1 ruleset: +enemy moves spend zero PP, while the reference Gold engine spends one. The +checker records each engine's `native_policy` costs separately and compares +whether charge/bypass preserve that cost. Enemy PP numbers are not counted +as equal observations. That existing rule difference needs a separate fix; +the new hook must not silently change native PP policy. Native secondary move +effects also remain host-owned, including Skull Bash's defense increase. + +```sh +luajit tests/engine/charge_required_contract.lua +python3 tools/charge-required-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/charge-required.json +``` + +The reference is pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Red/Blue/Yellow, +Gold/Silver/Crystal and FireRed/Emerald are compared in four SDK modes. +Gen1 API 2 has no unified opt-in; that run compares Gen2's adapter against +Gen1's native callback. Imported-data native smoke checks remain bounded; +they do not establish full-playthrough, whole-battle or pixel parity. diff --git a/examples/portable_charge_required/main.lua b/examples/portable_charge_required/main.lua new file mode 100644 index 000000000..ac7ea76c2 --- /dev/null +++ b/examples/portable_charge_required/main.lua @@ -0,0 +1,32 @@ +local mod = ... +local state = { calls = 0, mode = "native" } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "battle.charge_required") then + stop = mod.hooks:wrap("battle.charge_required", function(next, ctx) + state.calls = state.calls + 1 + state.charge, state.isCalled = ctx.charge, ctx.isCalled + state.move = ctx.move and ctx.move.id + state.battle, state.user, state.target = ctx.battle, ctx.user, ctx.target + if state.mode == "bypass" then + return false + end + if state.mode == "nil" then + return nil + end + if state.mode == "truthy" then + return 0 + end + if state.mode == "edit" then + ctx.charge = false + end + local required = next(ctx) + state.nextResult = required + return required + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_charge_required/manifest.json b/examples/portable_charge_required/manifest.json new file mode 100644 index 000000000..5ec52692b --- /dev/null +++ b/examples/portable_charge_required/manifest.json @@ -0,0 +1 @@ +{"id":"portable_charge_required","name":"Portable charge-turn control","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","affects_link":true} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 93620914a..390e31e23 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8309,7 +8309,16 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni -- BattleCommand_SkipSunCharge (effect_commands.asm:6535): Solar Beam jumps -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. - if record and record.charge and not releasing and not skipCharge then + local chargeRequired = record and record.charge ~= nil and not releasing and not skipCharge + if chargeRequired and GameVersion.generation() <= 3 and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) return c.charge end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) + chargeRequired = required ~= false + if not chargeRequired then animContext.moveTurn = 1 end + end + if chargeRequired then local branchAware = GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index c49a78eac..90ccf0acf 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -34,6 +34,7 @@ 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", "123", "battle.charge_required") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 46f485fd6..bb0aeb660 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([[ + battle.charge_required held_item.trigger shiny.roll gender.roll battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award @@ -42,6 +43,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.charge_required"] = { "context" }, ["shiny.roll"] = { "context" }, ["gender.roll"] = { "context" }, ["held_item.trigger"] = { "context" }, diff --git a/tests/charge_required/cases.lua b/tests/charge_required/cases.lua new file mode 100644 index 000000000..b73d4cbff --- /dev/null +++ b/tests/charge_required/cases.lua @@ -0,0 +1,152 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, b, beforeHP, beforePP, calls) + local out = { + label = label, + damaged = b.hp() < beforeHP, + spent = beforePP - b.pp(), + charging = b.charging(), + hidden = b.hidden(), + calls = e.state.calls - calls, + } + rows[#rows + 1] = out + return out + end + for _, name in ipairs({ "SOLARBEAM", "FLY", "DIG", "RAZOR_WIND", "SKULL_BASH", "SKY_ATTACK" }) do + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh(name) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(name .. ":" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "initial charge-capable move invokes callback once") + T.eq(out.charging, not bypass, "public verdict controls native continuation") + T.eq(out.damaged, bypass, "bypass resolves native damage immediately") + T.eq(out.spent, 1, "initial move spends one PP") + T.eq(e.state.charge, true, "default charge context") + T.eq(e.state.isCalled, false, "ordinary initial use is not called") + T.check( + e.state.battle ~= nil and e.state.user ~= nil and e.state.target ~= nil, + "context has native battle and actors" + ) + local expected = f.backend == "gen2" + and f.gen == 3 + and f.api < 3 + and f.format == "native" + and f.ids[name] + or name + T.eq(e.state.move, expected, "native/canonical move ID follows selected callback format") + if not bypass then + local releaseHP, releasePP, releaseCalls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe(name .. ":release", b, releaseHP, releasePP, releaseCalls) + T.eq(out.charging, false, "release clears native continuation") + T.eq(out.hidden, false, "release clears native semi-invulnerability") + T.eq(out.damaged, true, "release resolves native damage") + T.eq(out.spent, 0, "release cannot spend a second PP") + T.eq(out.calls, 0, "release cannot invoke initial-charge callback") + else + T.eq(out.hidden, false, "bypass creates no semi-invulnerable state") + end + end + end + f.nativePolicy = {} + local ordinary = f.fresh("TACKLE", { enemy = true }) + local ordinaryPP = ordinary.pp() + ordinary.use() + f.nativePolicy.enemyNormalPPCost = ordinaryPP - ordinary.pp() + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh("FLY", { enemy = true }) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe("enemy:" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "enemy initial charge invokes shared seam") + T.eq(out.charging, not bypass, "enemy verdict controls continuation") + T.eq(out.damaged, bypass, "enemy bypass resolves native damage") + f.nativePolicy[bypass and "enemyBypassPPCost" or "enemyChargePPCost"] = out.spent + T.eq(out.spent, f.nativePolicy.enemyNormalPPCost, "charge decision retains native enemy PP policy") + out.nativePPPreserved, out.spent = out.spent == f.nativePolicy.enemyNormalPPCost, nil + end + for _, mode in ipairs({ "edit", "nil", "truthy" }) do + e.state.mode = mode + local b = f.fresh("SOLARBEAM") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(mode, b, hp, pp, calls) + T.eq(out.charging, mode ~= "edit", "only false bypasses charge") + T.eq(out.damaged, mode == "edit", "edited next verdict reaches native damage") + end + e.state.mode = "native" + local b = f.fresh("TACKLE") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + T.eq(observe("ordinary move", b, hp, pp, calls).calls, 0, "ordinary move bypasses charge seam") + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + local out = observe("called", b, hp, pp, calls) + T.eq(e.state.isCalled, true, "called-move context") + T.eq(out.spent, 0, "called-move PP semantics preserved") + T.eq(out.charging, true, "called charge move retains native continuation") + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + out = observe("called bypass", b, hp, pp, calls) + T.eq(out.spent, 0, "called bypass retains caller-owned PP") + T.eq(out.charging, false, "called bypass stores no continuation") + T.eq(out.damaged, true, "called bypass resolves ordinary damage") + e.state.mode = "native" + if f.gen > 1 then + b = f.fresh("SOLARBEAM", { sun = true }) + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("native sun", b, hp, pp, calls) + T.eq(out.calls, 0, "native sun bypass precedes public seam") + T.eq(out.charging, false, "native sun retains instant SolarBeam") + T.eq(out.damaged, true, "native sun damages immediately") + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.charge_required", function(next, ...) + if after then + next(...) + end + error("intentional charge wrapper failure") + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("failure:" .. tostring(after), b, hp, pp, calls) + T.eq(out.calls, 1, "wrapper failure executes downstream once") + T.eq(out.spent, 1, "wrapper failure spends exactly one PP") + T.eq(out.charging, true, "wrapper failure retains native verdict") + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("battle.charge_required", function(next, ctx) + local a, b = next(ctx), next(ctx) + T.eq(a, b, "replayed next retains charge decision") + return a + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("replay", b, hp, pp, calls) + T.eq(out.calls, 1, "API-3 replay invokes downstream once") + T.eq(out.spent, 1, "API-3 replay retains one native PP spend") + off() + end + e.stop() + e.stop() + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("unsubscribed", b, hp, pp, calls) + T.eq(out.calls, 0, "unsubscribe removes charge callback") + T.eq(out.charging, true, "unsubscribe restores native charge") + return rows +end +return M diff --git a/tests/charge_required/worker.lua b/tests/charge_required/worker.lua new file mode 100644 index 000000000..f98af650c --- /dev/null +++ b/tests/charge_required/worker.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/charge_required/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/charge_required/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local native = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + native.T.check(#native.result.errors > 0, "failed entry recorded") + else + native.T.eq(native.result.loader.exports.portable_charge_required, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.charge_required"] or {}) do + native.T.check(link.owner ~= "portable_charge_required", "no-mod/rollback removes owner") + end + local b = native.fresh("FLY") + local pp, hp = b.pp(), b.hp() + b.use() + native.T.eq(b.charging(), true, "no-mod/rollback native charge") + rows[#rows + 1] = { + label = failing and "rollback" or "no mod", + charging = b.charging(), + spent = pp - b.pp(), + damaged = b.hp() < hp, + } + native.close() +end +print("CHARGE_NATIVE_POLICY " .. require("src.link.Json").encode(f.nativePolicy)) +print("CHARGE_REQUIRED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native charge decisions") diff --git a/tests/engine/charge_required_contract.lua b/tests/engine/charge_required_contract.lua new file mode 100644 index 000000000..ce3509cec --- /dev/null +++ b/tests/engine/charge_required_contract.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/charge_required/fixture.lua"), dofile("tests/charge_required/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq( + f.e.api:supports("hooks", "battle.charge_required"), + f.gen <= 3, + "native charge capability is honest" + ) + if f.gen <= 3 then + Cases.run(f) + local off = f.e.api.hooks:wrap("battle.charge_required", function() + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(b.charging(), false, "fresh public bypass works after consumer unsubscribe") + if f.gen == 3 then + local seen = false + for _, row in ipairs(b.battle.queue) do + if row.anim == f.ids.FLY then + seen = true + T.eq(row.animContext.moveTurn, 1, "GBA bypass uses native release animation branch") + end + end + T.eq(seen, true, "GBA bypass retains ordinary move animation") + end + off() + else + local calls = 0 + local off = f.e.api.hooks:wrap("battle.charge_required", function() + calls = calls + 1 + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(calls, 0, "DS does not dispatch unavailable seam") + T.eq(b.charging(), true, "DS native charge flow is preserved") + off() + end + f.close() + for _, failing in ipairs({ false, true }) do + local baseline = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#baseline.result.errors > 0, "entry failure is recorded") + else + T.eq(baseline.result.loader.exports.portable_charge_required, nil, "no-mod consumer is absent") + end + for _, link in ipairs(Runtime.hooks.chains["battle.charge_required"] or {}) do + T.check(link.owner ~= "portable_charge_required", "entry rollback removes owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.charge_required", "unsubscribed hot path invoked charge producer") + return call(name, ...) + end + local b = baseline.fresh("FLY") + local pp = b.pp() + b.use() + Runtime.call = call + T.eq(b.charging(), true, "no-mod/rollback restores native continuation") + T.eq(pp - b.pp(), 1, "no-mod/rollback spends native player PP once") + baseline.close() + end + end +end +Version.set(old) +T.finish("native charge-required decisions and public contract") diff --git a/tools/charge-required-contract-check/check.py b/tools/charge-required-contract-check/check.py new file mode 100644 index 000000000..b23c0f304 --- /dev/null +++ b/tools/charge-required-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare one public charge-turn consumer through both native battle engines.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/charge_required/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("CHARGE_REQUIRED_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_REQUIRED_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native battle charge decision", + "observations": rows, "native_policy": json.loads(next( + line[len("CHARGE_NATIVE_POLICY "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_NATIVE_POLICY "))), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"charge decision mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native charge observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_charge_required/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 31a26ad62e77eb24158d818ac24b79e0288d2591 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:31 +0100 Subject: [PATCH 092/122] Align native GBA move-used events and current-slot PP commands --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-move-used-pp.md | 48 +++ src/battle/BattleState.lua | 53 ++- src/battle/Gen3PPCommand.lua | 47 +++ src/link/Fingerprint.lua | 2 +- tests/enemy_actions/move_used_pp.lua | 342 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_choice_link.lua | 2 +- tests/engine/gen3_move_used_link.lua | 137 ++++++++ tests/engine/gen3_move_used_pp.lua | 5 + tools/enemy-action-contract-check/check.py | 1 + tools/enemy-action-contract-check/worker.lua | 4 +- 12 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen3-move-used-pp.md create mode 100644 src/battle/Gen3PPCommand.lua create mode 100644 tests/enemy_actions/move_used_pp.lua create mode 100644 tests/engine/gen3_move_used_link.lua create mode 100644 tests/engine/gen3_move_used_pp.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index de0644e3c..ea8e131ab 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -32,6 +32,7 @@ on: - 'tests/engine/gen3_triple_kick*.lua' - 'tests/engine/gen3_sleep_talk*.lua' - 'tests/engine/gen3_choice*.lua' + - 'tests/engine/gen3_move_used*.lua' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-move-used-pp.md b/docs/native-gen3-move-used-pp.md new file mode 100644 index 000000000..68b362ef5 --- /dev/null +++ b/docs/native-gen3-move-used-pp.md @@ -0,0 +1,48 @@ +# Native GBA move-used and PP command ordering + +Development branch, stacked on #241 and the existing #161 charge-decision +producer. Full acceptance and PR publication are pending. + +Ordinary GBA attack strings emit `battle.move_used` before the selected PP +command. The command reads the current selected slot and Pressure abilities +after callbacks, rather than spending a retired learned record. Removing a +slot does not cancel an already admitted attack or spend a different slot. + +Zero-PP admission is decided before the event. Such a failure still emits its +attack-string event, then refuses the move without animation, damage or a +move-end command. A callback refill cannot retroactively admit it. A failed +spread action must not continue attacking its remaining targets. + +Charge commands are different: the charge-decision hook precedes PP reduction. +A retained first charge consumes PP without an attack-string event. Hook +bypass and native sunlight consume PP before the subsequent event. Release +does not consume a second PP. Non-GBA ordering is preserved. + +The new shared fixture executes unchanged native scripts for FireRed and +Emerald. Its 384 rows retain event-visible PP, charge-hook PP, all RNG, +current/retired PP and all active HP. They cover ordinary, Pressure, one/zero +PP, callback refill/edit/replacement/removal, changed Pressure, forced slots, +actual Sleep Talk, awake/asleep failures, authored `ctx.callMove`, Struggle, +charge/release/bypass/sunlight and spread targets. Callback mutations are +controlled native fixture inputs; this does not introduce a new public PP +writer or promise writeback of event snapshots. + +All 384 rows currently agree. The exact composed prerequisite fails 336 rows +with that same fixture. Raw initial failures and corrected results remain in +`tmp/move-used-pp-qa/`. The actual host/guest/spectator test passes 84 checks +over two menu-driven turns per profile, with a stateful public held-damage +consumer depending on event-visible PP. The first linked probe attempted to +write an event snapshot and lacked a two-turn input barrier; its failed raw +result is retained separately from the corrected read-only public consumer. + +Native command revision is 12; party codec remains 1. An independent audit +applies only token 11 -> 12 to the unchanged prerequisite hasher and reproduces +`6ce58ced7ffad4d9`; older-generation digest `e33d251961f2a41d` stays unchanged. + +Pending: final clean full feature/combined suites, complete default contract +matrices, existing charge-decision regressions in all formats, actual imports, +format/workflow checks and publication. The unchanged composed parent passes +144/153 engine suites, SDK 3/3 and B30, retaining the same 25 inherited FAIL +lines as #241. Other native command boundaries, special callers such as Nature +Power, complete held-script orchestration, catalogs and graphical playthroughs +are not certified by this bounded fixture. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 390e31e23..806a1ecbe 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7849,6 +7849,7 @@ function BattleState:executeAction(user, target, action) -- only the first announces; the rest are the same move landing again self:performMove(user, one, action, i > 1, spreadAnimation and animationPlayed, GameVersion.generation() == 3 and i > 1) + if self._gbaActionFrame and self._gbaActionFrame.ppBlocked then break end -- 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 @@ -8217,12 +8218,14 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni 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 + local nativePP = GameVersion.isGen3() + and require("src.battle.Gen3PPCommand").prepare(self, user, target, moveInst, isContinuation, isCalled) + if not nativePP and 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 + if not nativePP and 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 -- holder. Two per use, not double the total -- which is why it is @@ -8232,16 +8235,37 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni moveInst.pp = math.max(0, moveInst.pp - cost) end + local function resolveCharge() + local required = record and record.charge ~= nil and not releasing and not skipCharge + if required and GameVersion.generation() <= 3 and Runtime.wantsHook("battle.charge_required") then + required = Runtime.call("battle.charge_required", function(c) return c.charge end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) ~= false + if not required then animContext.moveTurn = 1 end + end + return required + end + local chargeRequired + if nativePP and not nativePP.blocked then + chargeRequired = resolveCharge() + -- Native charge setup/bypass/sunlight consumes PP before any attack + -- string. A retained first charge has no move-used event at all. + if record and record.charge and not releasing then moveInst = nativePP.reduce() end + 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)) + if not chargeRequired then + self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + end -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the -- exception (announceAnim = false): PlayCurrentMoveAnimation runs -- only after a successful copy, never on a miss -- applyMimic queues it - if not suppressAnimation and not (record and record.announceAnim == false) then + if not (nativePP and nativePP.blocked) and not suppressAnimation and not (record and record.announceAnim == false) then self.nextInsert = (self.nextInsert or 0) + 1 self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer, attackerPosition = user.position, @@ -8250,12 +8274,21 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end - if not spreadContinuation then + if not spreadContinuation and not chargeRequired then Runtime.emit("battle.move_used", { battle = self, user = user, target = target, move = move, isCalled = isCalled or false, }) end + if nativePP then + if nativePP.blocked then + if self._gbaMoveFrame then self._gbaMoveFrame.finished = true end + if self._gbaActionFrame then self._gbaActionFrame.ppBlocked = true end + self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + return + end + moveInst = nativePP.reduce() + 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. @@ -8309,15 +8342,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni -- BattleCommand_SkipSunCharge (effect_commands.asm:6535): Solar Beam jumps -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. - local chargeRequired = record and record.charge ~= nil and not releasing and not skipCharge - if chargeRequired and GameVersion.generation() <= 3 and Runtime.wantsHook("battle.charge_required") then - local required = Runtime.call("battle.charge_required", function(c) return c.charge end, { - battle = self, user = user, target = target, move = move, - charge = true, isCalled = isCalled or false, - }) - chargeRequired = required ~= false - if not chargeRequired then animContext.moveTurn = 1 end - end + if not nativePP then chargeRequired = resolveCharge() end if chargeRequired then local branchAware = GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) diff --git a/src/battle/Gen3PPCommand.lua b/src/battle/Gen3PPCommand.lua new file mode 100644 index 000000000..412dd9e9b --- /dev/null +++ b/src/battle/Gen3PPCommand.lua @@ -0,0 +1,47 @@ +-- PP admission precedes the script; the PP command itself reads the current +-- selected slot after public callbacks. Called children have no selected PP. +local Choices = require("src.battle.EnemyMoveChoice") +local P = {} +function P.prepare(battle, user, target, action, continuation, called) + local index = tonumber(action.slot) + if not index then + local owner = Choices.slot(action) + for i = 1, 4 do + if (user.curMoves or user.mon.moves or {})[i] == owner then + index = i + break + end + end + end + local skip = continuation + or called + or action.struggle + or require("src.battle.Gen3ChoiceBand").enemyUnlimited(battle, user) + local move = battle:moveDef(action) + local number = move and tonumber(move.index or move.numId) + local current = index and (user.curMoves or user.mon.moves or {})[index] + local command = { + blocked = not skip and number ~= 165 and current and tonumber(current.pp) == 0 or false, + done = false, + } + function command.reduce() + if command.done then + return action + end + command.done = true + if skip or not index then + return action + end + local slot = (user.curMoves or user.mon.moves or {})[index] + if Choices.slot(action) ~= slot then + action = Choices.withMove(action, action.id, slot or { id = action.id, pp = 1 }, index) + end + if slot and tonumber(slot.pp) then + local cost = require("src.battle.Targeting").gen3PPCost(battle, user, target, move) + slot.pp = math.max(0, tonumber(slot.pp) - cost) + end + return action + end + return command +end +return P diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 1eabaf1e9..e52f58b96 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 11 or nil + return require("src.core.GameVersion").isGen3() and 12 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/tests/enemy_actions/move_used_pp.lua b/tests/enemy_actions/move_used_pp.lua new file mode 100644 index 000000000..221a8c803 --- /dev/null +++ b/tests/enemy_actions/move_used_pp.lua @@ -0,0 +1,342 @@ +-- Execute actual scripts and public event dispatch on unchanged native code. +-- Observe event-visible PP, mutations, command order, final state and all RNG. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PP_CALL_EFFECT", {kind="primary",run=function(ctx) + ctx.callMove("TACKLE"); return {} + end})) + assert(mod.content.moves:register("PP_CALLER", {id="PP_CALLER",name="PP_CALLER",type="NORMAL", + power=0,accuracy=100,pp=10,effect="PP_CALL_EFFECT"})) + mod.exports.api=mod + end]=] + local f = + dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version, { entry = entry }) + local caller, registries, previousRegistryData + if native then + caller = assert(require("src.mods.Gen3Compat").moveId("PP_CALLER")) + local moves = require("src.core.game3.battle.moves") + moves._rom[caller] = assert(f.data.gen3Moves._rom[caller]) + moves.BY_NUM[caller], moves._numByName.PP_CALLER = "PP_CALLER", caller + registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + else + caller = -1 + end + local ids = { + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [19] = f.ids.FLY, + [214] = f.ids.SLEEP_TALK, + [165] = f.ids.STRUGGLE, + } + ids[caller] = "PP_CALLER" + for _, row in ipairs({ + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 11, "WATER", 8 }, + { 76, "SOLARBEAM", 151, "SOLARBEAM_EFFECT", 12, "GRASS", 0 }, + }) do + local number, name, effect, binding, kind, typeName, target = unpack(row) + ids[number] = ("MOVE_%03d"):format(number) + if native then + local def = { + effect = effect, + power = 40, + type = kind, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + priority = 0, + secondaryChance = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[number], moves.BY_NUM[number], moves._numByName[name] = def, name, number + f.data.gen3Moves._rom[number], f.data.gen3Pokemon._moveNames[number] = def, name + else + f.data.moves[ids[number]] = { + id = ids[number], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = 40, + type = typeName, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + local function setMoves(user, numbers, value) + user.mon.moves = {} + if native then + user.mon.pp, user.mon.maxPp = {}, {} + end + for i, number in ipairs(numbers) do + if native then + user.mon.moves[i], user.mon.pp[i], user.mon.maxPp[i] = number, value or 10, 10 + else + user.mon.moves[i] = { id = ids[number], pp = value or 10 } + end + end + if not native then + user.curMoves = user.mon.moves + end + end + local function pp(user) + local out = {} + for i = 1, 4 do + local value + if native then + value = user.mon.pp[i] + else + value = user.curMoves[i] and user.curMoves[i].pp + end + out[i] = value == nil and "absent" or value + end + return out + end + local function setPP(user, slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + local function pressure(target, enabled) + if native then + target.ability, target.mon.ability = enabled and 46 or 0, enabled and 46 or 0 + else + target.abilityOverride = enabled and "PRESSURE" or false + end + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, kind in ipairs({ + "ordinary", + "pressure", + "one_pp_pressure", + "zero_pp", + "refill_zero", + "callback_zero", + "callback_set", + "callback_replace", + "callback_remove", + "callback_pressure_on", + "callback_pressure_off", + "planned_replace", + "planned_removed", + "sleep_talk", + "awake_sleep_talk", + "sleeping_block", + "charge_start", + "charge_release", + "charge_bypass", + "charge_hook_set", + "charge_hook_remove", + "charge_refill_zero", + "sun_skip", + "spread", + "spread_pressure", + "spread_callback_set", + "spread_zero", + "spread_refill_zero", + "spread_callback_remove", + "spread_one_pressure", + "authored_call", + "struggle_zero", + }) do + local b = f.field(double) + local user, targetId = f.at(b, actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.at(b, targetId) + local move = kind == "authored_call" and caller + or kind == "struggle_zero" and 165 + or kind:match("^charge") and 19 + or kind == "sun_skip" and 76 + or kind:match("^spread") and 57 + or kind:find("sleep_talk", 1, true) and 214 + or 33 + setMoves(user, { move, move == 214 and 33 or 10, 165 }, 10) + if + kind == "zero_pp" + or kind == "refill_zero" + or kind == "charge_refill_zero" + or kind == "spread_zero" + or kind == "spread_refill_zero" + or kind == "struggle_zero" + then + setPP(user, 1, 0) + end + if kind == "one_pp_pressure" or kind == "spread_one_pressure" then + setPP(user, 1, 1) + end + if kind == "pressure" or kind == "one_pp_pressure" or kind == "callback_pressure_off" then + pressure(target, true) + end + if kind == "spread_pressure" or kind == "spread_one_pressure" then + for id = 0, double and 3 or 1 do + if id % 2 ~= actor % 2 then + pressure(f.at(b, id), true) + end + end + end + if kind == "sleep_talk" or kind == "sleeping_block" then + user.mon.status, user.sleepTurns = "SLP", 4 + end + if kind == "sun_skip" then + if native then + b.weather = "SUN" + else + b.field.weather = "SUN" + end + end + local planned = kind == "planned_replace" or kind == "planned_removed" + if planned then + user.choicedMove = 10 + f.hold(user, 186) + end + local action, number, slot = nil, move, 1 + if native then + local plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [actor] = { kind = "move", move = move, slot = 1, target = targetId } } + )[1] + number, slot = plan.move, plan.slot + else + action = + require("src.battle.EnemyMoveChoice").withMove(user.curMoves[1], ids[move], user.curMoves[1], 1) + action.kind, action.target = "move", targetId + action.targetType = f.data.moves[ids[move]].target or 0 + action = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + local retired = native and user.mon.pp or user.curMoves + if planned then + setMoves(user, kind == "planned_removed" and { 33 } or { 33, 10 }, 7) + end + local function execute() + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + number, + slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, action) + end + end + if kind == "charge_release" then + execute() + if not native then + action = user.charging + end + b.queue, b.current = {}, nil + end + local events, chargeCalls, draws = {}, {}, {} + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offCharge = f.e.api.hooks:wrap("battle.charge_required", function(nextRead, ctx) + chargeCalls[#chargeCalls + 1] = + { pp = pp(user), called = ctx.isCalled or false, charge = ctx.charge } + if kind == "charge_hook_set" then + setPP(user, 1, 4) + end + if kind == "charge_hook_remove" then + setMoves(user, {}) + end + if kind == "charge_refill_zero" then + setPP(user, 1, 9) + end + if kind == "charge_bypass" then + return false + end + return nextRead(ctx) + end) + local off = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + pp = pp(user), + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + if #events == 1 then + if kind == "refill_zero" or kind == "spread_refill_zero" then + setPP(user, 1, 9) + end + if kind == "callback_zero" then + setPP(user, 1, 0) + end + if kind == "callback_set" or kind == "spread_callback_set" then + setPP(user, 1, 4) + end + if kind == "callback_replace" then + setMoves(user, { 33, 10 }, 7) + end + if kind == "callback_remove" or kind == "spread_callback_remove" then + setMoves(user, {}) + end + if kind == "callback_pressure_on" then + pressure(target, true) + end + if kind == "callback_pressure_off" then + pressure(target, false) + end + end + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local before = pp(user) + execute() + off() + offCharge() + offCrit() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.at(b, id) + state[#state + 1] = { pp = pp(a), hp = a.mon.hp } + end + local oldPP = {} + for i = 1, 4 do + local value + if native then + value = retired[i] + else + value = retired[i] and retired[i].pp + end + oldPP[i] = value == nil and "absent" or value + end + f.T.eq(#f.result.errors, 0, "actual move-used and charge callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = actor, + before = before, + events = events, + chargeCalls = chargeCalls, + draws = draws, + state = state, + retiredPP = oldPP, + } + end + end + end + if registries then + registries.bind(previousRegistryData) + 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 e0989e786..88cc007e6 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 10 -> 11 only. +-- Legacy hash retained; native hash independently audits revision 11 -> 12 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "3e2986da83083f86" +local NATIVE = "6ce58ced7ffad4d9" 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 11 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 12 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 index 2faf2e39d..f1ab30724 100644 --- a/tests/engine/gen3_choice_link.lua +++ b/tests/engine/gen3_choice_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 = 10 - T.eq(current.nativeBattleRevision, 11, "native Choice Band advertises revised commands") + T.check(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 diff --git a/tests/engine/gen3_move_used_link.lua b/tests/engine/gen3_move_used_link.lua new file mode 100644 index 000000000..aea661e1d --- /dev/null +++ b/tests/engine/gen3_move_used_link.lua @@ -0,0 +1,137 @@ +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 old = Copy(current) + old.nativeBattleRevision = 11 + T.eq(current.nativeBattleRevision, 12, "PP command ordering advertises revision twelve") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + 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 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = f.ids.TACKLE, pp = 10 } } + game.save.party[1].item = nil + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 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 got = inbox + inbox = {} + return got + 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 role uses native GBA rules") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 4, "two actual turns emit four move events") + for i, event in ipairs(seen[battle] or {}) do + T.eq(event.pp, i <= 2 and 10 or 9, "event sees current PP before the command") + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native move-used PP events in actual host guest and spectator") diff --git a/tests/engine/gen3_move_used_pp.lua b/tests/engine/gen3_move_used_pp.lua new file mode 100644 index 000000000..6ca7e55f0 --- /dev/null +++ b/tests/engine/gen3_move_used_pp.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/move_used_pp.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA move-used event and PP command order") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index f0530a01d..fc7615d58 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,7 @@ MODES = [ + (3, 'move_used_pp'), (2, 'choices'), (3, 'choices'), (3, 'arguments'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 34bccbefc..a1bcc4c60 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_call_failure" then +if mode == "move_used_pp" then + rows = dofile(root .. "/tests/enemy_actions/move_used_pp.lua").run(root, backend, version) +elseif 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 }) From 28c51eacaf0f50f7a9318ce18c98a976f9a51743 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:57:58 +0100 Subject: [PATCH 093/122] Observe public PP records across consecutive event subscribers --- docs/native-gen3-move-used-pp.md | 3 ++- tests/enemy_actions/move_used_pp.lua | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/native-gen3-move-used-pp.md b/docs/native-gen3-move-used-pp.md index 68b362ef5..b24125c5c 100644 --- a/docs/native-gen3-move-used-pp.md +++ b/docs/native-gen3-move-used-pp.md @@ -19,7 +19,8 @@ bypass and native sunlight consume PP before the subsequent event. Release does not consume a second PP. Non-GBA ordering is preserved. The new shared fixture executes unchanged native scripts for FireRed and -Emerald. Its 384 rows retain event-visible PP, charge-hook PP, all RNG, +Emerald. Its 384 rows retain event-visible PP, the public API-3 move records, +the next subscriber's view after native callback changes, charge-hook PP, all RNG, current/retired PP and all active HP. They cover ordinary, Pressure, one/zero PP, callback refill/edit/replacement/removal, changed Pressure, forced slots, actual Sleep Talk, awake/asleep failures, authored `ctx.callMove`, Struggle, diff --git a/tests/enemy_actions/move_used_pp.lua b/tests/enemy_actions/move_used_pp.lua index 221a8c803..aaa8c0141 100644 --- a/tests/enemy_actions/move_used_pp.lua +++ b/tests/enemy_actions/move_used_pp.lua @@ -107,6 +107,19 @@ function M.run(root, backend, version) user.curMoves[slot].pp = value end end + local function eventPP(event) + local out = {} + for i = 1, 4 do + local row = event.user.mon.moves[i] + f.T.check(row == nil or type(row) == "table", "public API-3 moves expose PP records") + local value + if row then + value = row.pp + end + out[i] = value == nil and "absent" or value + end + return out + end local function pressure(target, enabled) if native then target.ability, target.mon.ability = enabled and 46 or 0, enabled and 46 or 0 @@ -241,7 +254,7 @@ function M.run(root, backend, version) end b.queue, b.current = {}, nil end - local events, chargeCalls, draws = {}, {}, {} + local events, eventsAfter, chargeCalls, draws = {}, {}, {}, {} local offCrit = f.e.api.hooks:wrap("battle.crit", function() return false end) @@ -265,6 +278,7 @@ function M.run(root, backend, version) local off = f.e.api.events:on("battle.move_used", function(e) events[#events + 1] = { pp = pp(user), + publicPP = eventPP(e), user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id, @@ -294,6 +308,9 @@ function M.run(root, backend, version) end end end) + local offAfter = f.e.api.events:on("battle.move_used", function(e) + eventsAfter[#eventsAfter + 1] = { pp = pp(user), publicPP = eventPP(e), move = e.move.id } + end) b.rng = function(lo, hi) draws[#draws + 1] = { lo, hi, hi } return hi @@ -301,6 +318,7 @@ function M.run(root, backend, version) local before = pp(user) execute() off() + offAfter() offCharge() offCrit() local state = {} @@ -325,6 +343,7 @@ function M.run(root, backend, version) actor = actor, before = before, events = events, + eventsAfter = eventsAfter, chargeCalls = chargeCalls, draws = draws, state = state, From 05927b85d01d9b230abadb9d309541da2d6d0df3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:06:55 +0100 Subject: [PATCH 094/122] Release native charge moves after action planning, including zero PP --- docs/native-gen3-move-used-pp.md | 10 ++++++++-- src/battle/BattleState.lua | 5 +++++ tests/enemy_actions/move_used_pp.lua | 29 +++++++++++++++++++++++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/native-gen3-move-used-pp.md b/docs/native-gen3-move-used-pp.md index b24125c5c..d3013f734 100644 --- a/docs/native-gen3-move-used-pp.md +++ b/docs/native-gen3-move-used-pp.md @@ -19,7 +19,7 @@ bypass and native sunlight consume PP before the subsequent event. Release does not consume a second PP. Non-GBA ordering is preserved. The new shared fixture executes unchanged native scripts for FireRed and -Emerald. Its 384 rows retain event-visible PP, the public API-3 move records, +Emerald. Its 408 rows retain event-visible PP, the public API-3 move records, the next subscriber's view after native callback changes, charge-hook PP, all RNG, current/retired PP and all active HP. They cover ordinary, Pressure, one/zero PP, callback refill/edit/replacement/removal, changed Pressure, forced slots, @@ -28,7 +28,7 @@ charge/release/bypass/sunlight and spread targets. Callback mutations are controlled native fixture inputs; this does not introduce a new public PP writer or promise writeback of event snapshots. -All 384 rows currently agree. The exact composed prerequisite fails 336 rows +All 408 rows currently agree. The exact composed prerequisite fails 360 rows with that same fixture. Raw initial failures and corrected results remain in `tmp/move-used-pp-qa/`. The actual host/guest/spectator test passes 84 checks over two menu-driven turns per profile, with a stateful public held-damage @@ -36,6 +36,12 @@ consumer depending on event-visible PP. The first linked probe attempted to write an event snapshot and lacked a two-turn input barrier; its failed raw result is retained separately from the corrected read-only public consumer. +The fixture also repeats actual planning before charge release, including +release after the last PP was spent. A wrapped action must release the stored +move by identity, rather than charge again or refuse for zero PP. The exact +previous module fails 24 rows for this case; the fixed path clears charge and +invulnerability, deals native damage and spends no second PP. + Native command revision is 12; party codec remains 1. An independent audit applies only token 11 -> 12 to the unchanged prerequisite hasher and reproduces `6ce58ced7ffad4d9`; older-generation digest `e33d251961f2a41d` stays unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 806a1ecbe..101378079 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8163,6 +8163,11 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni -- charge release? local releasing = user.charging == moveInst and user.chargeReady + if not releasing and GameVersion.isGen3() and user.charging and user.chargeReady then + -- Native planning may wrap the learned action; release follows the + -- locked move identity rather than the action table's identity. + releasing = user.charging.id == moveInst.id + end if releasing then user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.hiddenAs = nil diff --git a/tests/enemy_actions/move_used_pp.lua b/tests/enemy_actions/move_used_pp.lua index aaa8c0141..3966a5c20 100644 --- a/tests/enemy_actions/move_used_pp.lua +++ b/tests/enemy_actions/move_used_pp.lua @@ -149,6 +149,8 @@ function M.run(root, backend, version) "sleeping_block", "charge_start", "charge_release", + "charge_planned_release", + "charge_planned_release_zero", "charge_bypass", "charge_hook_set", "charge_hook_remove", @@ -185,7 +187,11 @@ function M.run(root, backend, version) then setPP(user, 1, 0) end - if kind == "one_pp_pressure" or kind == "spread_one_pressure" then + if + kind == "one_pp_pressure" + or kind == "spread_one_pressure" + or kind == "charge_planned_release_zero" + then setPP(user, 1, 1) end if kind == "pressure" or kind == "one_pp_pressure" or kind == "callback_pressure_off" then @@ -247,10 +253,21 @@ function M.run(root, backend, version) b:executeAction(user, target, action) end end - if kind == "charge_release" then + local plannedRelease = kind == "charge_planned_release" or kind == "charge_planned_release_zero" + if kind == "charge_release" or plannedRelease then execute() if not native then action = user.charging + if plannedRelease then + action = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + elseif plannedRelease then + local plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [actor] = { kind = "move", move = number, slot = slot, target = targetId } } + )[1] + number, slot = plan.move, plan.slot end b.queue, b.current = {}, nil end @@ -324,7 +341,13 @@ function M.run(root, backend, version) local state = {} for id = 0, double and 3 or 1 do local a = f.at(b, id) - state[#state + 1] = { pp = pp(a), hp = a.mon.hp } + local charging, hidden + if native then + charging, hidden = a.twoTurnMove ~= nil, a.semiInvulnerable ~= nil + else + charging, hidden = a.charging ~= nil, a.invulnerable == true + end + state[#state + 1] = { pp = pp(a), hp = a.mon.hp, charging = charging, hidden = hidden } end local oldPP = {} for i = 1, 4 do From f7c18701a1241e168c404f7db82bb54ae1fe6f5b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:15:07 +0100 Subject: [PATCH 095/122] Document accepted move-used PP boundaries and remaining native gaps --- docs/native-gen3-move-used-pp.md | 60 +++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/native-gen3-move-used-pp.md b/docs/native-gen3-move-used-pp.md index d3013f734..502f77500 100644 --- a/docs/native-gen3-move-used-pp.md +++ b/docs/native-gen3-move-used-pp.md @@ -1,7 +1,7 @@ # Native GBA move-used and PP command ordering -Development branch, stacked on #241 and the existing #161 charge-decision -producer. Full acceptance and PR publication are pending. +Stacked on #241 and the existing #161 charge-decision producer. This aligns +the tested native GBA command boundaries without adding a new mod API. Ordinary GBA attack strings emit `battle.move_used` before the selected PP command. The command reads the current selected slot and Pressure abilities @@ -46,10 +46,52 @@ Native command revision is 12; party codec remains 1. An independent audit applies only token 11 -> 12 to the unchanged prerequisite hasher and reproduces `6ce58ced7ffad4d9`; older-generation digest `e33d251961f2a41d` stays unchanged. -Pending: final clean full feature/combined suites, complete default contract -matrices, existing charge-decision regressions in all formats, actual imports, -format/workflow checks and publication. The unchanged composed parent passes -144/153 engine suites, SDK 3/3 and B30, retaining the same 25 inherited FAIL -lines as #241. Other native command boundaries, special callers such as Nature -Power, complete held-script orchestration, catalogs and graphical playthroughs -are not certified by this bounded fixture. +## Local acceptance + +Production and final fixtures were tested on clean feature +`05927b85d01d9b230abadb9d309541da2d6d0df3` and combined candidate +`b70f28ef071b019c88829b30488dcd7f6eba086e`. + +| Check | Feature | Combined | +| --- | --- | --- | +| Engine suites | 146/155 | 264/264 | +| SDK suites | 3/3 | 3/3 | +| Quick tiers / B30 | inherited engine-tier failure / B30 passes | all pass | +| Enemy/action contracts | 152 workers / 80,540 equal paired rows | same, all equal | +| Held-damage command | 2,040 equal paired rows | same, all equal | +| Ordinary GBA damage | 6,912 equal paired rows | same, all equal | +| Strict held-item scheduling, four API formats | 40 workers / 1,914 equal paired rows | same, all equal | +| Charge decision, four API formats | 64 workers / 1,020 equal paired rows | same, strict native-policy parity | +| Actual FireRed/Emerald imports | both existing modes pass | both existing modes pass | + +The feature retains the exact Counter of 25 inherited anchored FAIL lines +across nine suites from the fresh unchanged composed prerequisite `167f6c91` +(144/153 engine suites, SDK 3/3 and B30). The combined candidate has no FAIL +lines. Feature charge-decision checks retain the prerequisite's documented +legacy enemy-PP policy; the combined branch already includes #162's native +AUTO defaults and passes the stricter raw-policy comparison. + +The default contract matrix includes the new fixture; no selected modes or +observation fields are omitted from final acceptance. Source checkouts remain +clean and unchanged at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` (native +execution) and `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` (older shared +contracts). The maintained LuaJIT runtime is pinned at +`a4c064539ea931274d407e1249b4bb575424a0f4`. CPU checks use `nice -n 19`. +AST-verified formatting, whitespace checks and actionlint pass. + +The imported checks use actual local FireRed/Emerald data with explicit GBA +rules, constructed parties and public menus. They distribute no ROM/cache or +normal SAVE and do not establish graphical playthrough acceptance. + +## Remaining scope + +A repeated complete held-phase probe still has 10 native versus 11 host reads +for successful Sleep Talk; awake/empty/blocked calls retain matching 2/2/4 +counts. King's Rock/Shell Bell orchestration remains a separate native gap. +Other special command boundaries and callers such as Nature Power, complete +catalogs/controllers and graphical playthroughs are not certified here. + +Raw initial failures, negative controls, earlier completed runs and final +acceptance reports remain under `tmp/move-used-pp-qa/`. Earlier runs did not +cover the copied planned-release path; the final full suites and complete +matrices were repeated after that correction. From e7f1756c865867d9cc090c8b36f0982e4483789c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:22:40 +0100 Subject: [PATCH 096/122] Align native GBA shared post-hit item commands and spectator turn boundaries --- .github/workflows/enemy-action-contract.yml | 2 + docs/native-gen3-post-hit-items.md | 66 ++ src/battle/BattleState.lua | 14 +- src/battle/EffectRegistry.lua | 14 +- src/battle/GbaItemSchedule.lua | 1 + src/battle/Gen3PostHitItems.lua | 86 ++ src/battle/HoldItems.lua | 14 +- src/link/Fingerprint.lua | 2 +- src/link/LinkBattle.lua | 17 +- tests/enemy_actions/post_hit_items.lua | 482 +++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_move_used_link.lua | 2 +- tests/engine/gen3_post_hit_execution.lua | 5 + tests/engine/gen3_post_hit_items.lua | 5 + tests/engine/gen3_post_hit_link.lua | 176 ++++ tests/post_hit_items/command-cases.json | 793 +++++++++++++++++++ tools/enemy-action-contract-check/check.py | 3 + tools/enemy-action-contract-check/worker.lua | 6 +- 18 files changed, 1670 insertions(+), 24 deletions(-) create mode 100644 docs/native-gen3-post-hit-items.md create mode 100644 src/battle/Gen3PostHitItems.lua create mode 100644 tests/enemy_actions/post_hit_items.lua create mode 100644 tests/engine/gen3_post_hit_execution.lua create mode 100644 tests/engine/gen3_post_hit_items.lua create mode 100644 tests/engine/gen3_post_hit_link.lua create mode 100644 tests/post_hit_items/command-cases.json diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index ea8e131ab..a1ad858f5 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -33,6 +33,8 @@ on: - 'tests/engine/gen3_sleep_talk*.lua' - 'tests/engine/gen3_choice*.lua' - 'tests/engine/gen3_move_used*.lua' + - 'tests/engine/gen3_post_hit*.lua' + - 'tests/post_hit_items/**' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-post-hit-items.md b/docs/native-gen3-post-hit-items.md new file mode 100644 index 000000000..799844b36 --- /dev/null +++ b/docs/native-gen3-post-hit-items.md @@ -0,0 +1,66 @@ +# Native GBA post-hit item command + +This change is stacked on #244. It aligns the shared King's Rock / Shell Bell +move-end command with the pinned, unchanged Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Gen1 is read-only. + +## Contract + +The native damaging command retains its first damage, including its native +substitute convention, rather than summing every hit. The shared held read runs +after Choice Band and the ordinary move-end cure sweep. An empty hand still +produces the same read. Public hook substitutions remain authoritative. + +King's Rock performs the reference's extra canonical player-slot read, including +when the attacker belongs to the other side. A changed first answer selects its +own parameter; otherwise the second read selects the chance. RNG runs before the +move flag and living-target checks, and secondary refusal checks preserve native +Shield Dust, substitute, Inner Focus and already-moved behavior. The player-slot +parameter rule intentionally matches the pinned reference; this is not a claim +that it reproduces every cartridge edition's intended behavior. + +Shell Bell uses the first damage and current divisor, with minimum one HP and +maximum-HP clamping. Its native held-item animation identifies the holder in both +positions. Physical item identities 186, 187 and 219 retain intrinsic GBA effects +even if mutable descriptive metadata clears them; public hooks can replace them. + +Spread scripts retain their chosen target order and count occupied defending +slots until queued removal, including a just-fainted defender. An attacker that +faints before a later ordinary spread body does not run its damage commands. +Non-GBA damage and item handling retain their existing paths. + +Already-moved state belongs to the current logical turn. Spectator action pairs +are buffered FIFO until the current animation/command queue finishes, preventing +an early next-turn packet from changing the turn under a running command. +Native battle protocol revision is 13; party codec remains 1. The independent +unchanged-hasher token audit changes only revision 12 to 13, yielding +`beacb71804a6af34`; legacy digest `e33d251961f2a41d` is retained. + +## Validation status + +Development checkpoint, before the final complete regression gate: + +- Shared command: 7,488 paired rows, retaining all held reads, public parameters, + RNG, activation, first-damage consumption, HP, flinch and animation metadata. +- Initial actual-script matrix: 13,200 paired rows match for Tackle, Double Slap, + Triple Kick, Surf and Sleep Talk. A subsequent matrix also includes Earthquake + and must pass before publication. +- Actual host, guest and spectator: 172 checks pass across FireRed and Emerald, + including physical items, a stateful second held read, already-moved targets, + skipped PP, complete held/event/RNG traces and completed state hashes. +- The previously mismatched complete successful Sleep Talk probe now has the same + ten ordered held reads in both engines; awake/empty/blocked cases retain 2/2/4. + +Failed and superseded fixture observations are retained separately in local QA. +The first link probe omitted the owned move's flinch flag; after supplying the +native-shaped flag, it exposed the real spectator turn-overlap bug. No rows are +filtered to establish equality. Final baseline, full engine regression, wider +contract matrix and combined-fork acceptance are still pending at this checkpoint. + +## Scope + +The executable reference comparison covers registered FireRed and Emerald paths. +Ruby/Sapphire's separate per-hit item scheduling is not certified. Other special +script boundaries, full catalogs and controllers, graphical playthroughs and +unchanged full-mod acceptance remain open. No ROM data, imported caches, normal +saves or third-party mod assets are committed. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 101378079..6bfb17014 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5298,7 +5298,9 @@ function BattleState:computeDamage(user, target, move, opts) local n = 0 for flank = 1, 2 do local b = self.sides[isPlayer and 1 or 2].battlers[flank] - if b and b.mon and (b.mon.hp or 0) > 0 then n = n + 1 end + -- Native GBA counts occupied slots. A fainted target stays present + -- until its queued removal, including the rest of a spread script. + if b and b.mon and (GameVersion.isGen3() or (b.mon.hp or 0) > 0) then n = n + 1 end end return n end @@ -7660,6 +7662,9 @@ function BattleState:executeAction(user, target, action) end if user.mon.hp <= 0 then return end if not action then return end + if GameVersion.isGen3() and action.id and not action.special then + require("src.battle.Gen3PostHitItems").markMoved(self, user) + 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 @@ -7838,7 +7843,9 @@ 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 = encoreTargets or explicitTarget and Targeting.actionTargets(self, user, def, chosen) + local nativeSpread = GameVersion.isGen3() + and (targetKind == Targeting.BOTH or targetKind == Targeting.FOES_AND_ALLY) + local got = encoreTargets or (explicitTarget or nativeSpread) and Targeting.actionTargets(self, user, def, chosen) or Targeting.resolve(self, user, def, chosen) if #got > 0 then list = got end end @@ -8469,9 +8476,10 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end local Schedule = require("src.battle.GbaItemSchedule") local parent = self._gbaMoveFrame + if not isCalled then require("src.battle.Gen3PostHitItems").markMoved(self, user) end local caller = self._gbaNextCaller self._gbaNextCaller = nil - local frame = { parent = parent, user = user, move = self:moveDef(moveInst), moveId = moveInst.id, + local frame = { parent = parent, user = user, target = target, 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 diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 68b491647..fa03f6ab5 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -205,6 +205,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) if gba and record and record.gbaAfterAccuracy then record.gbaAfterAccuracy(ctx) end if gba then hits = hitCount(ctx, record) end + if gba and user.mon.hp <= 0 and not (record and record.explode) then + ctx.gbaNoEffect = true + if battle._gbaMoveFrame then battle._gbaMoveFrame.noEffect = true end + return + end -- damage per hit local dmg, info @@ -377,6 +382,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local substituteBefore = target.substituteHP 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 + if gba then require("src.battle.Gen3PostHitItems").damage(battle, hitDamage, dealt, hadSub) end totalDealt = totalDealt + dealt if gba and not hadSub and require("src.battle.Damage").isSpecial(move.type) then ctx.gbaSpecialHit = true @@ -526,8 +532,12 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- 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 + if gba then + local frame = battle._gbaMoveFrame + if frame then frame.noEffect = ctx.gbaNoEffect end + require("src.battle.GbaItemSchedule").moveEnd(battle) + end + if not gba and totalDealt > 0 and battle.shellBellDrain then local flinch = HoldItems.flinchChance(user, move) if flinch > 0 and target.mon.hp > 0 and not target.substituteHP and (battle.rng or love.math.random)(0, 99) < flinch diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index 9ffd72d43..7e6949126 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -41,6 +41,7 @@ function Schedule.moveEnd(battle, completedFrame) for _, b in ipairs(Schedule.battlers(battle)) do battle:holdItemTrigger(b, "moveEnd") end + require("src.battle.Gen3PostHitItems").run(battle, frame) end function Schedule.afterAction(battle) diff --git a/src/battle/Gen3PostHitItems.lua b/src/battle/Gen3PostHitItems.lua new file mode 100644 index 000000000..464ff0a12 --- /dev/null +++ b/src/battle/Gen3PostHitItems.lua @@ -0,0 +1,86 @@ +-- Native move-end command shared by King's Rock and Shell Bell. It reads +-- once, with a second player-slot read only for the flinch effect. +local V = require("src.core.GameVersion") +local Held = require("src.battle.HoldItems") +local Abilities = require("src.battle.Abilities") +local Strings = require("src.core.Strings") +local P = {} +function P.markMoved(battle, actor) + actor.gbaMovedTurn = battle.turnCount or 0 +end +function P.moved(battle, actor) + return actor.gbaMovedTurn ~= nil and actor.gbaMovedTurn == (battle.turnCount or 0) +end +function P.damage(battle, damage, dealt, substitute) + local frame = battle._gbaMoveFrame + if not frame then + return + end + if (frame.firstDmg or 0) == 0 then + local rs = V.get() == "ruby" or V.get() == "sapphire" + frame.firstDmg = substitute and not rs and damage or dealt + end + if not substitute and dealt > 0 then + frame.targetDamaged = true + end +end +local function maxHP(actor) + return actor.mon.stats and actor.mon.stats.hp or actor.mon.maxHp or actor.mon.hp +end +function P.run(battle, frame) + if not V.isGen3() or not frame or not frame.user or not frame.target or (frame.firstDmg or 0) == 0 then + return false + end + local user, target = frame.user, frame.target + local effect, parameter, def, changed = Held.effectOf(user, "flinch") + if effect == "FLINCH" then + -- The native command reads the canonical player battler even for an + -- enemy attacker. A changed first answer alone selects its parameter. + local player = battle.linkRole == "guest" and battle.enemy or battle.player + local _, chance = Held.effectOf(player, "flinch") + if changed then + chance = parameter + end + if + not frame.noEffect + and frame.targetDamaged + and (battle.rng or love.math.random)(0, 99) < (tonumber(chance) or 0) + and frame.move + and frame.move.flags + and math.floor((tonumber(frame.move.flags) or 0) / 32) % 2 == 1 + and target.mon.hp > 0 + then + if + Abilities.of(target) ~= "SHIELD_DUST" + and not (target.substituteHP and target.substituteHP > 0) + and not Abilities.refusesFlinch(target) + and not P.moved(battle, target) + then + target.flinched = true + end + return true + end + elseif effect == "SHELL_BELL" then + if not frame.noEffect and user ~= target and user.mon.hp ~= maxHP(user) and user.mon.hp > 0 then + local amount = math.floor(frame.firstDmg / math.max(1, tonumber(parameter) or 0)) + if amount == 0 then + amount = 1 + end + frame.firstDmg = 0 + battle.nextInsert = (battle.nextInsert or 0) + 1 + table.insert(battle.queue, battle.nextInsert, { + anim = "HELD_ITEM_EFFECT", + attackerIsPlayer = user.isPlayer, + attackerPosition = user.position, + targetPosition = user.position, + }) + local label = def and def.name or "the item" + battle:sayNext(Strings("%s's %s restored\na little HP!", user.name or user.mon.nickname, label)) + user.mon.hp = math.min(maxHP(user), user.mon.hp + amount) + battle:drainNext(user, user.mon.hp) + return true + end + end + return false +end +return P diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index 5c41ba24d..1f84adbb0 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -50,12 +50,14 @@ end -- the same silence an unknown ability gets. local function nativeEffect(battler, native) local def, item = HoldItems.defOf(battler) - -- The native Choice Band entry is intrinsic (ITEM_HOLD[186]); mutable + -- Native Choice Band, King's Rock and Shell Bell entries are intrinsic; 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 + if require("src.core.GameVersion").isGen3() then + local number = tonumber(item) or tonumber(def and def.index) + if number == 186 then return "CHOICE_BAND", 0, def end + if number == 187 then return "FLINCH", 10, def end + if number == 219 then return "SHELL_BELL", 8, def end end if type(native) == "table" then return native.effect, native.parameter, def end if not def then return nil end @@ -70,8 +72,8 @@ 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) - return chosen, amount, def + local chosen, amount, changed = require("src.battle.HeldItemRead").read(battler, definition, effect, param or 0, trigger, 3) + return chosen, amount, def, changed end HoldItems.effect = HoldItems.effectOf diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index e52f58b96..e37e8c4cc 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 12 or nil + return require("src.core.GameVersion").isGen3() and 13 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index c0a039ffb..1fb261333 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -923,7 +923,7 @@ function LinkBattle.newSpectator(game, net, opts) s.afterQueue = "finish" end - self.hostMsg, self.guestMsg = nil, nil + local hostActions, guestActions = {}, {} local baseUpdate = self.update self.update = function(s, dt) net:update() @@ -931,12 +931,8 @@ function LinkBattle.newSpectator(game, net, opts) if msg.type == "spectate" then local inner = msg.msg if inner.type == "action" then - if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end - if s.hostMsg and s.guestMsg then - local h, g = s.hostMsg, s.guestMsg - s.hostMsg, s.guestMsg = nil, nil - resolveSpecTurn(s, h, g) - end + local pending = msg.side == "host" and hostActions or guestActions + pending[#pending + 1] = inner elseif inner.type == "baton_pass" then require("src.link.Gen3BatonChoice").receive(s, inner, msg.side) elseif inner.type == "bye" or inner.type == "forfeit" then @@ -955,6 +951,13 @@ function LinkBattle.newSpectator(game, net, opts) if net.closed and not s.linkEnded and not s.result then endSpectate(s) end + -- Participants can finish animations before the spectator. Keep later + -- actions FIFO and advance the logical turn only after the current queue + -- has completed; otherwise its remaining commands observe the next turn. + if not s.result and not s.linkEnded and (s.phase == "menu" or s.phase == "waitBoth") + and #hostActions > 0 and #guestActions > 0 then + resolveSpecTurn(s, table.remove(hostActions, 1), table.remove(guestActions, 1)) + end -- same as the real-participant loop: every path that skips baseUpdate -- still has to advance the presentational clock, and a spectator sits in -- waitBoth between every single turn diff --git a/tests/enemy_actions/post_hit_items.lua b/tests/enemy_actions/post_hit_items.lua new file mode 100644 index 000000000..f7105a3d5 --- /dev/null +++ b/tests/enemy_actions/post_hit_items.lua @@ -0,0 +1,482 @@ +-- Identical command inputs drive unchanged native code and the shared host +-- command. Record every held read, RNG call, activation, HP and flinch state. +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/held_item/fixture.lua").new(root, backend, version, 3, "unified") + f.game.save.options.ruleset = "gen3_emerald" + if native then + f.data.gen3Items._byId[219] = + { name = "SHELL BELL", pocket = "ITEMS", price = 100, holdEffect = 62, holdEffectParam = 8 } + else + f.data.items.ITEM_219 = { + id = "ITEM_219", + index = 219, + name = "SHELL BELL", + price = 100, + holdEffect = "SHELL_BELL", + holdEffectParam = 8, + } + end + function f.put(actor, id) + if native then + actor.item, actor.mon.item = id or 0, id or 0 + else + actor.mon.item = id and ("ITEM_%03d"):format(id) or nil + end + end + function f.field(double) + local b = f.reset(nil, double) + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = f.actor(id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + a.mon.level = 30 + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33 }, { 10 }, { 10 } + 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.mon.moves = { { id = "MOVE_033", pp = 10 } } + a.curMoves = a.mon.moves + a.mon.stats.hp, a.abilityOverride, a.curTypes = 400, false, { "NORMAL" } + a.curStats.attack, a.curStats.defense, a.curStats.spatk, a.curStats.spdef = 50, 50, 50, 50 + a.curStats.speed = 100 - id * 10 + end + f.put(a, nil) + end + return b + end + return f +end +function M.command(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local file = assert(io.open(root .. "/tests/post_hit_items/command-cases.json", "rb")) + local inputs = require("src.link.Json").decode(file:read("*a")) + file:close() + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, case in ipairs(inputs.command_cases) do + for _, roll in ipairs(inputs.rng_values) do + local b = f.field(double) + local user = f.actor(actor) + local target = f.actor(case.same_actor and actor or actor % 2 == 0 and actor + 1 or actor - 1) + user.mon.hp, target.mon.hp = case.user_hp, case.target_hp + if native then + user.mon.maxHp = case.user_max_hp + else + user.mon.stats.hp = case.user_max_hp + end + local item = case.physical_item or case.item_effect == "SHELL_BELL" and 562 or nil + f.put(user, item) + if case.player_item then + f.put(f.actor(0), case.player_item) + end + if case.metadata_cleared and item then + if native then + f.data.gen3Items._byId[item].holdEffect = 0 + else + f.data.items[("ITEM_%03d"):format(item)].holdEffect = "NONE" + end + elseif item == 187 then + if native then + f.data.gen3Items._byId[item].holdEffect = 30 + else + f.data.items.ITEM_187.holdEffect = "FLINCH" + end + elseif item == 219 then + if native then + f.data.gen3Items._byId[item].holdEffect = 62 + else + f.data.items.ITEM_219.holdEffect = "SHELL_BELL" + end + end + target.substituteHP = case.substitute_hp + if case.target_moved then + if native then + target.expMovedThisTurn = true + else + target.gbaMovedTurn = b.turnCount or 0 + end + end + if case.ability then + if native then + target.ability = case.ability + else + target.abilityOverride = case.ability + end + 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, + } + local n, hook = #trace, case.hook + if n == 1 and hook == "suppress_attacker" then + return nil, 0 + end + if n == 1 and (hook == "replace_attacker" or hook == "replace_both_differently") then + return case.hook_effect, case.hook_parameter + end + if n == 2 and (hook == "replace_player" or hook == "replace_both_differently") then + return case.second_effect, case.second_parameter + end + if hook == "alternate_each_read" then + if n % 2 == 1 then + return nextRead(ctx) + end + return ctx.effect or "HELD_NONE", (ctx.parameter or 0) + 64 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, roll } + return roll + end + local move = not case.no_move and { flags = case.move_flags } or nil + if move and case.no_flags then + move.flags = nil + end + local frame = { + user = not case.no_user and user or nil, + target = not case.no_target and target or nil, + move = move, + firstDmg = case.first_damage, + targetDamaged = case.target_damaged, + noEffect = case.no_effect, + } + local active, animations = false, {} + if native then + local ad = require("src.core.game3.battle.adapter").new(b) + frame.adapter, frame.st = ad, b + active = require("src.core.game3.battle.held_items").kingsRockShellBell(frame) + for _, event in ipairs(ad:eventsSince(0)) do + if event.kind == "anim" then + animations[#animations + 1] = { + name = event.name, + user = event.attackerId, + target = event.targetId, + } + end + end + else + active = require("src.battle.Gen3PostHitItems").run(b, frame) + for _, event in ipairs(b.queue) do + if event.anim then + animations[#animations + 1] = { + name = event.anim, + user = event.attackerPosition, + target = event.targetPosition, + } + end + end + end + off() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + state[#state + 1] = { hp = a.mon.hp, flinched = a.flinched or false } + end + f.T.eq(#f.result.errors, 0, "held command callbacks remain error-free") + rows[#rows + 1] = { + case = case.label, + double = double, + actor = actor, + roll = roll, + active = active or false, + firstDamage = frame.firstDmg, + state = state, + trace = trace, + draws = draws, + animations = animations, + } + end + end + end + end + f.close() + return rows +end +function M.execution(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local ids = {} + for _, row in ipairs({ + { 33, "TACKLE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT", 15, 0 }, + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT", 10, 0 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + }) do + local number, name, effect, binding, power, target = unpack(row) + ids[number] = ("MOVE_%03d"):format(number) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[number], moves.BY_NUM[number], moves._numByName[name] = def, name, number + f.data.gen3Moves._rom[number], f.data.gen3Pokemon._moveNames[number] = def, name + else + f.data.moves[ids[number]] = { + id = ids[number], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + if not native then + f.data.type_chart.matchups[#f.data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + require("src.battle.TypeChart").load(f.data) + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, number in ipairs({ 33, 3, 167, 57, 89, 214 }) do + for _, itemCase in ipairs({ + "empty", + "king", + "king_player_shell", + "king_player_king", + "shell", + "fake_shell", + "override_shell", + "override_rock", + "suppressed", + "zero_parameter", + }) do + for _, kind in ipairs({ + "ordinary", + "substitute_5", + "substitute_100", + "target_low_hp", + "heal_cap", + "miss", + "immune", + "target_moved", + "live_drop", + "live_equip", + "user_faint", + }) do + for _, chanceRoll in ipairs({ 0, 99 }) do + local b = f.field(double) + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + user.mon.hp = kind == "heal_cap" and 399 or 200 + local item = itemCase:match("^king") and 187 + or itemCase == "shell" and 219 + or itemCase == "fake_shell" and 562 + or itemCase == "suppressed" and 187 + or itemCase == "zero_parameter" and 219 + or nil + f.put(user, item) + if actor ~= 0 and itemCase == "king_player_shell" then + f.put(f.actor(0), 219) + end + if actor ~= 0 and itemCase == "king_player_king" then + f.put(f.actor(0), 187) + end + if kind == "substitute_5" then + target.substituteHP = 5 + end + if kind == "substitute_100" then + target.substituteHP = 100 + end + if kind == "target_low_hp" then + target.mon.hp = 5 + end + if kind == "target_moved" then + if native then + target.expMovedThisTurn = true + else + target.gbaMovedTurn = b.turnCount or 0 + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + if number == 214 then + user.mon.status, user.sleepTurns = "SLP", 4 + end + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { number }, { 10 }, { 10 } + if number == 214 then + user.mon.moves[2], user.mon.pp[2], user.mon.maxPp[2] = 3, 10, 10 + end + else + user.mon.moves = { { id = ids[number], pp = 10 } } + if number == 214 then + user.mon.moves[2] = { id = ids[3], pp = 10 } + end + user.curMoves = user.mon.moves + f.T.eq(b.ruleset.baseDamageStyle, "gba", "script comparison uses native GBA damage") + end + local trace, draws, damage, 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 ctx.trigger == "flinch" and ctx.mon.nickname == user.mon.nickname then + if itemCase == "override_shell" then + return "HELD_SHELL_BELL", 8 + end + if itemCase == "override_rock" then + return "HELD_FLINCH", 128 + end + if itemCase == "suppressed" then + return nil, 0 + end + if itemCase == "zero_parameter" then + return "HELD_SHELL_BELL", 0 + end + end + return nextRead(ctx) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offAccuracy + if kind == "miss" then + offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function() + return false + end) + end + local offMove = 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) + 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, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 then + if kind == "live_drop" then + f.put(user, nil) + end + if kind == "live_equip" then + f.put(user, 219) + end + if kind == "user_faint" then + user.mon.hp = 0 + end + end + end) + b.rng = function(lo, hi) + local value = lo == 0 and hi == 99 and chanceRoll or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + offCrit() + offMove() + offDamage() + if offAccuracy then + offAccuracy() + end + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pp = {} + for i = 1, 4 do + local value + if native then + value = a.mon.pp[i] + else + value = a.curMoves[i] and a.curMoves[i].pp + end + pp[i] = value == nil and "absent" or value + end + state[#state + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + flinched = a.flinched or false, + substitute = a.substituteHP or 0, + } + end + f.T.eq(#f.result.errors, 0, "full held-item script callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + move = number, + itemCase = itemCase, + case = kind, + roll = chanceRoll, + trace = trace, + draws = draws, + damage = damage, + events = events, + state = state, + } + end + end + 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 88cc007e6..8672f29be 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 11 -> 12 only. +-- Legacy hash retained; native hash independently audits revision 12 -> 13 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "6ce58ced7ffad4d9" +local NATIVE = "beacb71804a6af34" 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 12 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 13 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_move_used_link.lua b/tests/engine/gen3_move_used_link.lua index aea661e1d..73f2b3658 100644 --- a/tests/engine/gen3_move_used_link.lua +++ b/tests/engine/gen3_move_used_link.lua @@ -6,7 +6,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local current = H.hello(f.game, "battle") local old = Copy(current) old.nativeBattleRevision = 11 - T.eq(current.nativeBattleRevision, 12, "PP command ordering advertises revision twelve") + T.check(current.nativeBattleRevision >= 12, "PP command ordering advertises at least revision twelve") T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") for _, host in ipairs({ false, true }) do local state = setmetatable( diff --git a/tests/engine/gen3_post_hit_execution.lua b/tests/engine/gen3_post_hit_execution.lua new file mode 100644 index 000000000..6957a1e29 --- /dev/null +++ b/tests/engine/gen3_post_hit_execution.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/post_hit_items.lua").execution(".", "gen2", version) +end +require("tests.modkit").finish("native GBA post-hit items in actual scripts") diff --git a/tests/engine/gen3_post_hit_items.lua b/tests/engine/gen3_post_hit_items.lua new file mode 100644 index 000000000..953d3e795 --- /dev/null +++ b/tests/engine/gen3_post_hit_items.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/post_hit_items.lua").command(".", "gen2", version) +end +require("tests.modkit").finish("native GBA shared post-hit item command") diff --git a/tests/engine/gen3_post_hit_link.lua b/tests/engine/gen3_post_hit_link.lua new file mode 100644 index 000000000..bd5e145f5 --- /dev/null +++ b/tests/engine/gen3_post_hit_link.lua @@ -0,0 +1,176 @@ +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 + for _, hostKing in ipairs({ false, true }) do + local f = dofile("tests/enemy_actions/post_hit_items.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 12 + T.eq(current.nativeBattleRevision, 13, "shared post-hit commands advertise revision thirteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + f.data.moves.MOVE_033.flags = 51 + f.data.moves.MOVE_033.gen3Effect = 0 + 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 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_033", pp = 10 } } + game.save.party[1].item = (i == 1 and hostKing or i ~= 1 and not hostKing) and "ITEM_187" or "ITEM_219" + end + local seen, reads, draws, pending = {}, {}, {}, {} + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = ctx.user.mon.moves[1].pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + local trace = reads[ctx.battle] or {} + reads[ctx.battle] = trace + local row = { + user = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + } + trace[#trace + 1] = row + if ctx.trigger == "flinch" then + if pending[ctx.battle] then + pending[ctx.battle] = false + row.effectOut, row.parameterOut = "HELD_FLINCH", 256 + return row.effectOut, row.parameterOut + end + if ctx.effect == "HELD_FLINCH" then + pending[ctx.battle] = true + end + end + local effect, parameter = nextRead(ctx) + row.effectOut, row.parameterOut = effect, parameter + return effect, parameter + 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 got = inbox + inbox = {} + return got + 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 role uses native GBA rules") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 500, 1000 + actor.curStats.speed = actor.mon.nickname == "HOST" and 200 or 100 + end + end + for _, battle in ipairs({ a, b, s }) do + local random = battle.rng + draws[battle] = {} + battle.rng = function(lo, hi) + local value = random(lo, hi) + draws[battle][#draws[battle] + 1] = { lo, hi, value } + return value + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + T.same(reads[a], reads[b], "host and guest retain all stateful held reads") + T.same(reads[a], reads[s], "spectator retains all stateful held reads") + T.same(draws[a], draws[b], "host and guest retain every RNG draw") + T.same(draws[a], draws[s], "spectator retains every RNG draw") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), hostKing and 2 or 4, "moved targets resist flinch; waiting targets skip") + local host = battle.linkRole == "guest" and battle.enemy or battle.player + local guest = battle.linkRole == "guest" and battle.player or battle.enemy + T.eq(host.mon.moves[1].pp, 8, "host acts on both turns") + T.eq(guest.mon.moves[1].pp, hostKing and 10 or 8, "native flinch prevents the waiting action") + local commandReads = 0 + for _, row in ipairs(reads[battle] or {}) do + if row.trigger == "flinch" and row.parameterOut == 256 then + commandReads = commandReads + 1 + T.eq(row.user, "HOST", "the second flinch read uses canonical host player on every role") + end + end + T.eq(commandReads, 2, "one complete King's Rock command runs per turn") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() + end +end +T.finish("native post-hit items in actual host guest and spectator") diff --git a/tests/post_hit_items/command-cases.json b/tests/post_hit_items/command-cases.json new file mode 100644 index 000000000..a182302e6 --- /dev/null +++ b/tests/post_hit_items/command-cases.json @@ -0,0 +1,793 @@ +{ + "schema_version": 1, + "rng_values": [ + 0, + 6, + 7, + 9, + 10, + 15, + 16, + 49, + 50, + 63, + 64, + 99 + ], + "command_cases": [ + { + "label": "zero-first-damage", + "first_damage": 0, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate" + }, + { + "label": "no-item", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate" + }, + { + "label": "physical-kings-rock", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-flag", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 0, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-target-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 0, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-user-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 0, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-effect", + "first_damage": 32, + "target_damaged": true, + "no_effect": true, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-real-target-damage", + "first_damage": 32, + "target_damaged": false, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "substituted-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_attacker", + "hook_effect": "HELD_FLINCH", + "hook_parameter": 128 + }, + { + "label": "suppressed-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "suppress_attacker", + "physical_item": 187 + }, + { + "label": "changed-first-parameter-wins", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_both_differently", + "physical_item": 187, + "hook_effect": "HELD_FLINCH", + "hook_parameter": 128, + "second_effect": "HELD_FLINCH", + "second_parameter": 256 + }, + { + "label": "unchanged-first-uses-player", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_player", + "physical_item": 187, + "second_effect": "HELD_FLINCH", + "second_parameter": 128 + }, + { + "label": "unchanged-first-uses-nonflinch-player-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_player", + "physical_item": 187, + "second_effect": "HELD_SHELL_BELL", + "second_parameter": 64 + }, + { + "label": "stateful-read", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "alternate_each_read", + "physical_item": 187 + }, + { + "label": "shell-bell-divisor-0-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-0-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-0-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-1-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-1-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-1-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-3-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-3-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-3-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-8-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-8-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-8-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-64-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-divisor-64-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-divisor-64-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-full-hp", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 400, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-user-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 0, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-target-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 0, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-self-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": true, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-no-effect", + "first_damage": 32, + "target_damaged": true, + "no_effect": true, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-substitute", + "first_damage": 32, + "target_damaged": false, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-zero-first", + "first_damage": 0, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "no-user", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_user": true + }, + { + "label": "no-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_target": true + }, + { + "label": "no-move", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_move": true + }, + { + "label": "no-flags", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_flags": true + }, + { + "label": "substitute-blocks-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "substitute_hp": 10 + }, + { + "label": "already-moved-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "target_moved": true + }, + { + "label": "inner-focus-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "ability": "INNER_FOCUS" + }, + { + "label": "shield-dust-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "ability": "SHIELD_DUST" + }, + { + "label": "physical-kings-rock-metadata-cleared", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "metadata_cleared": true + }, + { + "label": "physical-shell-bell", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "delegate", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8, + "physical_item": 219 + }, + { + "label": "physical-shell-bell-metadata-cleared", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "delegate", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8, + "physical_item": 219, + "metadata_cleared": true + }, + { + "label": "shell-bell-heal-cap", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 399, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "player-berry-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "player_item": 139 + }, + { + "label": "player-shell-bell-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "player_item": 219 + }, + { + "label": "native-fake-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 530 + }, + { + "label": "flinch-on-self", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": true, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + } + ] +} diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index fc7615d58..70e6ee32b 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,8 @@ MODES = [ + (3, 'post_hit_items_command'), + (3, 'post_hit_items_execution'), (3, 'move_used_pp'), (2, 'choices'), (3, 'choices'), @@ -70,6 +72,7 @@ def main(): 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/post_hit_items").glob("*.json")), *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")), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index a1bcc4c60..ee06b0770 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 == "move_used_pp" then +if mode == "post_hit_items_execution" then + rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").execution(root, backend, version) +elseif mode == "post_hit_items_command" then + rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").command(root, backend, version) +elseif mode == "move_used_pp" then rows = dofile(root .. "/tests/enemy_actions/move_used_pp.lua").run(root, backend, version) elseif mode == "choice_band_call_failure" then rows = dofile(root .. "/tests/enemy_actions/choice_call_failure.lua").run(root, backend, version) From d88be1fc42df80756dfc7daa918b7d5c77e80004 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:30:23 +0100 Subject: [PATCH 097/122] Supply identical native Tackle flags in held-item contract fixtures --- docs/native-gen3-post-hit-items.md | 5 +++++ tests/held_item/fixture.lua | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/native-gen3-post-hit-items.md b/docs/native-gen3-post-hit-items.md index 799844b36..5e58e0790 100644 --- a/docs/native-gen3-post-hit-items.md +++ b/docs/native-gen3-post-hit-items.md @@ -52,6 +52,11 @@ Development checkpoint, before the final complete regression gate: ten ordered held reads in both engines; awake/empty/blocked cases retain 2/2/4. Failed and superseded fixture observations are retained separately in local QA. +The first complete run also exposed missing GBA Tackle flags in the older held +fixture: the source side already supplied 51, while the host side omitted it. +Supplying the same flags preserves all assertions and passes 5,511 checks. The +initial eight failures remain recorded; no production fallback or relaxed +expectation is used to hide the mismatched input. The first link probe omitted the owned move's flinch flag; after supplying the native-shaped flag, it exposed the real spectator turn-overlap bug. No rows are filtered to establish equality. Final baseline, full engine regression, wider diff --git a/tests/held_item/fixture.lua b/tests/held_item/fixture.lua index d3f001c13..1b66f3073 100644 --- a/tests/held_item/fixture.lua +++ b/tests/held_item/fixture.lua @@ -46,6 +46,11 @@ UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK data.constants.generation = gen end if gen == 3 then + if backend == "gen2" then + -- Match the native-shaped reference Tackle record. The command + -- tests its actual bit flag, independently of public hook output. + data.moves.MOVE_033.flags = 51 + end 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) From 80c8bd8e4d2e403b94679f6e9283d616d77f5007 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:42:39 +0100 Subject: [PATCH 098/122] Retain timeout evidence and budget complete post-hit script comparisons --- docs/native-gen3-post-hit-items.md | 5 ++++- tools/enemy-action-contract-check/check.py | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/native-gen3-post-hit-items.md b/docs/native-gen3-post-hit-items.md index 5e58e0790..fd0093cd0 100644 --- a/docs/native-gen3-post-hit-items.md +++ b/docs/native-gen3-post-hit-items.md @@ -55,7 +55,10 @@ Failed and superseded fixture observations are retained separately in local QA. The first complete run also exposed missing GBA Tackle flags in the older held fixture: the source side already supplied 51, while the host side omitted it. Supplying the same flags preserves all assertions and passes 5,511 checks. The -initial eight failures remain recorded; no production fallback or relaxed +initial eight failures remain recorded. The expanded script worker also hit the +old 60-second wall-clock limit under concurrent QA. Its bound is now 180 seconds; +a timeout still fails and retains partial text and exact captured bytes. No +production fallback or relaxed expectation is used to hide the mismatched input. The first link probe omitted the owned move's flinch flag; after supplying the native-shaped flag, it exposed the real spectator turn-overlap bug. No rows are diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 70e6ee32b..8ff4ea07f 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -87,11 +87,25 @@ def main(): 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), mode] - run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=60) + # This matrix executes thousands of complete scripts per worker. + # Retain a real failure on timeout, including partial raw output. + timeout = 180 if mode == "post_hit_items_execution" else 60 + timed_out = None + try: + run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired as error: + def captured(value): + return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or "" + timed_out = {"seconds": timeout, + "stdout_hex": error.stdout.hex() if isinstance(error.stdout, bytes) else None, + "stderr_hex": error.stderr.hex() if isinstance(error.stderr, bytes) else None} + run = subprocess.CompletedProcess(cmd, 124, captured(error.stdout), + captured(error.stderr) + "\nWorker timed out and was terminated.\n") 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, "mode": mode, - "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr}) + "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr, + "timeout": timed_out}) if run.returncode or len(rows) != 1: failed = True else: From 1ea0f875ea03cc1ca7b22f88191d9bb15b51bac0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:50:21 +0100 Subject: [PATCH 099/122] Document verified post-hit contracts and imported-data reproduction --- .github/workflows/enemy-action-contract.yml | 2 + docs/native-gen3-post-hit-items.md | 94 +++++++++----- docs/qa/native-gen3-post-hit-items.json | 63 ++++++++++ .../imported-post-hit-smoke.lua | 117 ++++++++++++++++++ 4 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 docs/qa/native-gen3-post-hit-items.json create mode 100644 tools/enemy-action-contract-check/imported-post-hit-smoke.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index a1ad858f5..40aaf12d6 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -35,6 +35,8 @@ on: - 'tests/engine/gen3_move_used*.lua' - 'tests/engine/gen3_post_hit*.lua' - 'tests/post_hit_items/**' + - 'tests/held_item/**' + - 'tests/charge_required/**' - 'tools/enemy-action-contract-check/**' push: branches: [main, dev] diff --git a/docs/native-gen3-post-hit-items.md b/docs/native-gen3-post-hit-items.md index fd0093cd0..162d06e63 100644 --- a/docs/native-gen3-post-hit-items.md +++ b/docs/native-gen3-post-hit-items.md @@ -36,34 +36,72 @@ Native battle protocol revision is 13; party codec remains 1. The independent unchanged-hasher token audit changes only revision 12 to 13, yielding `beacb71804a6af34`; legacy digest `e33d251961f2a41d` is retained. -## Validation status - -Development checkpoint, before the final complete regression gate: - -- Shared command: 7,488 paired rows, retaining all held reads, public parameters, - RNG, activation, first-damage consumption, HP, flinch and animation metadata. -- Initial actual-script matrix: 13,200 paired rows match for Tackle, Double Slap, - Triple Kick, Surf and Sleep Talk. A subsequent matrix also includes Earthquake - and must pass before publication. -- Actual host, guest and spectator: 172 checks pass across FireRed and Emerald, - including physical items, a stateful second held read, already-moved targets, - skipped PP, complete held/event/RNG traces and completed state hashes. -- The previously mismatched complete successful Sleep Talk probe now has the same - ten ordered held reads in both engines; awake/empty/blocked cases retain 2/2/4. - -Failed and superseded fixture observations are retained separately in local QA. -The first complete run also exposed missing GBA Tackle flags in the older held -fixture: the source side already supplied 51, while the host side omitted it. -Supplying the same flags preserves all assertions and passes 5,511 checks. The -initial eight failures remain recorded. The expanded script worker also hit the -old 60-second wall-clock limit under concurrent QA. Its bound is now 180 seconds; -a timeout still fails and retains partial text and exact captured bytes. No -production fallback or relaxed -expectation is used to hide the mismatched input. -The first link probe omitted the owned move's flinch flag; after supplying the -native-shaped flag, it exposed the real spectator turn-overlap bug. No rows are -filtered to establish equality. Final baseline, full engine regression, wider -contract matrix and combined-fork acceptance are still pending at this checkpoint. +## Validation + +Full gate source/test trees: feature `d88be1fc` passes **149/158** engine suites, +SDK **3/3** and B30. Its exact Counter of **25 inherited FAIL lines in nine +suites** equals fresh unchanged #244 (`f7c18701`, **146/155**). Combined +`d9cf080d` passes **267/267**, SDK **3/3**, every quick tier and B30, with zero +FAIL lines. Subsequent checker, reproduction-tool and documentation changes +preserve those production and test trees. + +The final checker (`80c8bd8e` / combined `f8294625`) passes **160 workers / +103,868 identical paired enemy/action rows** per runtime, with every worker +exiting zero and no timeout. Additional held-damage and ordinary-damage +comparisons pass **2,040 + 6,912** rows: **168 workers / 112,820** selected +paired observations per runtime. + +- Shared post-hit command: **7,488 paired rows**, retaining all held reads, + public parameters, RNG, activation, first-damage consumption, HP, flinch and + animation metadata. +- Actual scripts: **15,840 paired rows** for Tackle, Double Slap, Triple Kick, + Surf, Earthquake and Sleep Talk; single/double actor slots, substitutes, + misses/immunity, target/attacker fainting, live item changes and overrides. + The exact #244 production differs in **12,032** of these rows. Nothing is + filtered from failed or successful comparisons. +- Actual host/guest/spectator: **172 checks** across both profiles, physical + items, stateful canonical reads, already-moved targets, skipped PP, all held, + event and RNG traces, and completed state hashes. The old spectator module + fails **14** checks in the same test. +- Strict held scheduling: four API formats, **40 workers / 1,914 paired rows**. + Charge-decision contracts: four formats, **64 workers / 1,020 rows**, retaining + the feature prerequisite's explicit legacy enemy-PP policy; the combined + fork requires raw native-policy parity through existing #162. +- Fresh full Sleep Talk held-phase probes match every captured payload: + **10/2/2/4 reads** for success/awake/empty/blocked, on both profiles and both + runtimes. The old successful host trace had eleven. +- Existing imported-data modes pass. The additional reproduction tool exercises + **32 CPU-only scenarios** using actual imported item/move definitions and + controlled battlers: physical items, both sides, single/multi-hit and public + overrides. These are not graphical or complete-playthrough acceptance. +- Independent unchanged-hasher token audit, AST-verified formatting, + whitespace checks and actionlint pass. + +Failed and superseded observations remain separate in local QA. The first link +fixture omitted its move flag; supplying native-shaped flags exposed the real +spectator turn-overlap bug. The older held fixture likewise supplied flag 51 +only on its source side. Matching the host input preserves all expectations and +passes **5,511** checks; the initial eight failures remain recorded. The large +script worker also hit the old sixty-second wall-clock limit under concurrent +QA. Its bound is now 180 seconds. A forced-timeout probe proves failure is +retained with partial text and exact captured bytes, while subsequent comparisons +complete. Final accepted matrices contain no timeout or nonzero worker. + +### Reproduction + +Use the pinned maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`. +The shared checker requires a clean read-only Gen1 checkout at the revision above. + +```sh +LUA=/path/to/luajit bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py --gen1 /path/to/read-only-gen1 --lua /path/to/luajit --report /tmp/enemy-action-report.json +/path/to/luajit tests/engine/gen3_post_hit_link.lua +/path/to/luajit tools/enemy-action-contract-check/imported-post-hit-smoke.lua /path/to/existing/local-cache +``` + +The last command needs existing FireRed/Emerald imports; it never creates or +publishes cartridge data. Source-bound acceptance metadata and raw-report digests +are in [the QA receipt](qa/native-gen3-post-hit-items.json). ## Scope diff --git a/docs/qa/native-gen3-post-hit-items.json b/docs/qa/native-gen3-post-hit-items.json new file mode 100644 index 000000000..fe58f3309 --- /dev/null +++ b/docs/qa/native-gen3-post-hit-items.json @@ -0,0 +1,63 @@ +{ + "feature": { + "full_gate_ref": "d88be1fc42df80756dfc7daa918b7d5c77e80004", + "oracle_ref": "80c8bd8e4d2e403b94679f6e9283d616d77f5007", + "engine_suites": "149/158", + "fail_counter_lines": 25, + "oracle_workers": 160, + "oracle_rows": 103868, + "post_hit_command_rows": 7488, + "post_hit_execution_rows": 15840, + "oracle_sha256": "cf5b7057e2a4aa0f5de258e199459499865ebdf12022ced188e8fa8ec3ef4255", + "full_log_sha256": "3caa779b45c625ed14f0408786b152976d74c63a1621029b465bbd4aeb3f063a" + }, + "combined": { + "full_gate_ref": "d9cf080d676eccefa861b8ecc93de621782dd5a6", + "oracle_ref": "f829462500c6dc2035fd06739f2e2cc95495df2f", + "engine_suites": "267/267", + "fail_counter_lines": 0, + "oracle_workers": 160, + "oracle_rows": 103868, + "post_hit_command_rows": 7488, + "post_hit_execution_rows": 15840, + "oracle_sha256": "d7bd74e730b482c95b709a291d6f261a16b0bfbd09d3cb610404c1b51b492b4d", + "full_log_sha256": "1d8cdba4cadf8e8c5f2043a1e3c24399cabdaf656c5f3c0b3594316c9b21433b" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "native_command_revision": 13, + "party_codec_revision": 1, + "native_digest": "beacb71804a6af34", + "legacy_digest": "e33d251961f2a41d", + "sdk_suites": "3/3", + "b30": "passed", + "additional_damage_rows": { + "held_command": 2040, + "ordinary_damage": 6912 + }, + "strict_held": { + "workers": 40, + "rows": 1914 + }, + "charge": { + "workers": 64, + "rows": 1020, + "strict_native_policy": "combined" + }, + "old_execution_different_rows": 12032, + "old_spectator_failed_checks": 14, + "post_hit_link_checks": 172, + "actual_import_controlled_scenarios": 32, + "sleep_talk_phase_reads": { + "success": 10, + "awake": 2, + "empty": 2, + "blocked": 4 + }, + "failed_observations_retained": [ + "initial mismatched fixture flags", + "initial spectator turn overlap", + "superseded sixty-second checker timeout" + ], + "timeout_negative": "exit 124 remains failed; partial text and exact bytes retained; later comparisons complete", + "scope": "Selected FireRed/Emerald native command and script contracts; no full-mod, graphical-playthrough, full-catalog or Ruby/Sapphire per-hit acceptance" +} diff --git a/tools/enemy-action-contract-check/imported-post-hit-smoke.lua b/tools/enemy-action-contract-check/imported-post-hit-smoke.lua new file mode 100644 index 000000000..9b1d39084 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-post-hit-smoke.lua @@ -0,0 +1,117 @@ +-- CPU-only scenarios using existing local imported definitions and controlled battlers. +-- No cache, normal save, mod assets or graphics are written. +package.path = "./?.lua;./?/init.lua;" .. package.path +local root = assert(arg[1]) +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, actor in ipairs({ 0, 1 }) do + for _, number in ipairs({ 33, 3 }) do + for _, item in ipairs({ 187, 219 }) do + for _, override in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new( + ".", + "gen2", + version, + 3, + "unified", + function(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. "/" .. version .. "/data/generated/" .. 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 + ) + f.game.save.options.ruleset = "gen3_emerald" + local b = f.fresh("TACKLE").battle + b:syncSides() + b.ruleset.enemyUnlimitedPP = false + local user = actor == 0 and b.player or b.enemy + local target = actor == 0 and b.enemy or b.player + for i, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.mon.nickname = "P" .. (i - 1) + a.mon.item = nil + a.abilityOverride = false + end + user.mon.hp = 2000 + local key + for id, def in pairs(f.data.items) do + if type(def) == "table" and def.index == item then + key = id + break + end + end + user.mon.item = assert(key) + local move = assert(f.data.constants.moveOrder[number]) + local def = assert(f.data.moves[move]) + assert(math.floor(def.flags / 32) % 2 == 1) + user.mon.moves = { { id = move, pp = 10 } } + user.curMoves = user.mon.moves + local reads, draws, damage = {}, {}, {} + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + reads[#reads + 1] = { + holder = c.mon.nickname, + item = c.item, + trigger = c.trigger, + effect = c.effect, + parameter = c.parameter, + } + if override and c.trigger == "flinch" and c.mon.nickname == user.mon.nickname then + if item == 187 then + return "HELD_FLINCH", 256 + else + return "HELD_SHELL_BELL", 0 + end + end + return nextRead(c) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = e.damage + end) + b.rng = function(lo, hi) + local v = lo == 0 and hi == 99 and 0 or lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, v } + return v + end + b:executeAction(user, target, user.curMoves[1]) + assert(#damage > 0 and damage[1] > 0) + if item == 219 then + assert( + user.mon.hp == 2000 + math.max(1, math.floor(damage[1] / (override and 1 or 8))), + "first-damage Shell Bell share" + ) + else + assert( + (target.flinched or false) == (override or actor == 0), + "native canonical player parameter" + ) + end + assert(user.curMoves[1].pp == 9 and b._gbaMoveFrame == nil) + assert(#f.result.loader.errors == 0, table.concat(f.result.loader.errors, "\n")) + assert(#f.result.errors == 0, "no swallowed public callback errors") + rows[#rows + 1] = { + version = version, + actor = actor, + move = number, + item = item, + override = override, + hp = user.mon.hp, + damage = damage, + reads = reads, + draws = draws, + } + f.close() + end + end + end + end +end +print("IMPORTED_POST_HIT_SMOKE " .. require("src.link.Json").encode(rows)) From dc1a2d32f28cf60e3a8517d4cf4bdfe97958ca3f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:17:56 +0100 Subject: [PATCH 100/122] Add native GBA Nature Power selection and child-owned PP commands --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-nature-power.md | 66 +++++ src/battle/BattleState.lua | 9 +- src/battle/Gen3Builtins.lua | 8 + src/battle/Gen3CalledMove.lua | 32 ++ src/battle/Gen3ChoiceBand.lua | 4 +- src/battle/Gen3MoveEffects.lua | 5 +- src/battle/Gen3NaturePower.lua | 46 +++ src/battle/Gen3PPCommand.lua | 13 +- src/battle/Gen3SleepTalk.lua | 27 +- src/link/Fingerprint.lua | 2 +- src/mods/Builtins.lua | 3 + tests/enemy_actions/nature_power.lua | 290 +++++++++++++++++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_nature_power.lua | 60 ++++ tests/engine/gen3_nature_power_link.lua | 138 +++++++++ tests/engine/gen3_post_hit_link.lua | 2 +- tools/enemy-action-contract-check/check.py | 1 + tools/enemy-action-contract-check/worker.lua | 4 +- 19 files changed, 675 insertions(+), 42 deletions(-) create mode 100644 docs/native-gen3-nature-power.md create mode 100644 src/battle/Gen3CalledMove.lua create mode 100644 src/battle/Gen3NaturePower.lua create mode 100644 tests/enemy_actions/nature_power.lua create mode 100644 tests/engine/gen3_nature_power.lua create mode 100644 tests/engine/gen3_nature_power_link.lua diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 40aaf12d6..1166be083 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -34,6 +34,7 @@ on: - 'tests/engine/gen3_choice*.lua' - 'tests/engine/gen3_move_used*.lua' - 'tests/engine/gen3_post_hit*.lua' + - 'tests/engine/gen3_nature_power*.lua' - 'tests/post_hit_items/**' - 'tests/held_item/**' - 'tests/charge_required/**' diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md new file mode 100644 index 000000000..3c7164877 --- /dev/null +++ b/docs/native-gen3-nature-power.md @@ -0,0 +1,66 @@ +# Native GBA Nature Power caller + +Development checkpoint stacked on #248. Gen1 is an unchanged, read-only +reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented contract + +Nature Power announces before its terrain-selected child. The child owns the +selected Nature Power PP slot, reduces it after its public event subscribers, +and uses its own target/Pressure rules. Ordinary called moves continue to own +no PP slot. Zero PP is admitted/refused before the parent script; changing PP +inside a later callback cannot rewrite that admission decision. + +The native terrain table covers grass, long grass, sand, underwater, water, +pond, mountain, cave, building and plain. Explicit numeric/string terrain +inputs retain the reference's coercion and fallback. Cached host terrain names +use the existing GBA terrain identifiers. The new builtin is installed only for +GBA, including the ordinary Loader path and no-Loader native fallback. + +Shared called-target dispatch preserves existing Sleep Talk targets and caller +provenance. Nature Power lends PP ownership only to the first child body, so +spread continuations do not spend it repeatedly. Choice Band and move-end +commands identify the native parent. Generic authored calls keep their existing +ownership path. This is a public-event/native-command change, not a claim of +complete graphical or full-mod acceptance. + +Native command revision is 14; party codec remains 1. The independent unchanged +#248 hasher, with only revision 13 changed to 14, reproduces +`cb314093f24370e7`; legacy digest `e33d251961f2a41d` is retained. + +## Checkpoint evidence + +- **6,048 equal paired rows** across FireRed and Emerald, all single/double + actors, fourteen terrain inputs, four held-item cases, ordinary/Pressure/zero + PP, callback zero/refill, replaced/removed/reordered slots, and consecutive + public event subscribers. Captured payloads, native/public PP, every held + read, RNG, damage, actor state and retired PP are compared without filtering. +- Exact #248 production differs in **5,376** of the same rows. Both negative + workers exit zero: these are behavioral differences, not fixture crashes. +- The direct engine suite passes **28,926** checks, including generation + boundaries and event-visible child PP. Actual host/guest/spectator completes + two native menu turns per profile with stateful held steering, **108** checks, + matching event traces, PP and every paired state hash/component. +- The initial fixture lacked a descriptive Choice Band record on both sides; + the reference's intrinsic fallback then exposed different public IDs. Both + sides now receive identical owned item definitions. All failed/superseded raw + observations remain in local QA; no production fallback or relaxed assertion + hides them. + +The fixture uses owned, controlled native-shaped child definitions to isolate +caller behavior; it does not certify the complete cartridge child catalog. +Full engine regression, other called-move contracts, final combined-fork gates, +actual imported Nature Power scenarios and broader public-content integration +remain pending. This checkpoint is not yet a published pull request or a new +stable combined testing snapshot. No ROMs, caches, normal saves or mod assets +are included. + +## Reproduction + +Use maintained LuaJIT at `a4c064539ea931274d407e1249b4bb575424a0f4`. + +```sh +python3 tools/enemy-action-contract-check/check.py --gen1 /path/to/read-only-gen1 --lua /path/to/luajit --mode nature_power --report /tmp/nature-power-report.json +/path/to/luajit tests/engine/gen3_nature_power.lua +/path/to/luajit tests/engine/gen3_nature_power_link.lua +``` diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6bfb17014..6d0647bef 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8232,6 +8232,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni and self.ruleset and self.ruleset.enemyUnlimitedPP local nativePP = GameVersion.isGen3() and require("src.battle.Gen3PPCommand").prepare(self, user, target, moveInst, isContinuation, isCalled) + if nativePP and self._gbaMoveFrame then self._gbaMoveFrame.ppIndex = nativePP.index end if not nativePP and 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!")) @@ -8299,7 +8300,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) return end - moveInst = nativePP.reduce() + if not (record and record.gbaNaturePower) then moveInst = nativePP.reduce() end end -- PROTECT stops a status move too, and it is the only check that has to @@ -8327,7 +8328,8 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni self:cancelMoveAnim() end if pick then - if record.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, pick) + if record.gbaNaturePower and GameVersion.isGen3() then require("src.battle.Gen3NaturePower").call(ctx, pick) + elseif 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 @@ -8480,7 +8482,8 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima local caller = self._gbaNextCaller self._gbaNextCaller = nil local frame = { parent = parent, user = user, target = target, move = self:moveDef(moveInst), moveId = moveInst.id, - calledBy = isCalled and caller and caller.parent == parent and caller.move or nil } + calledBy = isCalled and caller and caller.parent == parent and caller.move or nil, + calledPPSlot = isCalled and caller and caller.parent == parent and caller.ppSlot or nil } if parent then parent.called = true end self._gbaMoveFrame = frame if self._gbaActionFrame then self._gbaActionFrame.moved = true end diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index a9f2cf0c4..60fac72f9 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -1,6 +1,9 @@ -- Adapt complete builtin records before mods capture them for delegation. local M = {} function M.record(id, base) + if id == "NATURE_POWER_EFFECT" and require("src.core.GameVersion").isGen3() then + return base or require("src.battle.Gen3NaturePower").record() + end base = require("src.battle.Gen3Disable").builtin(id, base) base = require("src.battle.Gen3MultiHit").builtin(id, base) base = require("src.battle.Gen3Rage").builtin(id, base) @@ -8,4 +11,9 @@ function M.record(id, base) base = require("src.battle.Gen3ChoiceBand").builtin(id, base) return require("src.battle.Gen3SleepTalk").builtin(id, base) end +function M.install(registry, owner) + if require("src.core.GameVersion").isGen3() then + registry:register("NATURE_POWER_EFFECT", require("src.battle.Gen3NaturePower").record(), owner) + end +end return M diff --git a/src/battle/Gen3CalledMove.lua b/src/battle/Gen3CalledMove.lua new file mode 100644 index 000000000..d8296dd66 --- /dev/null +++ b/src/battle/Gen3CalledMove.lua @@ -0,0 +1,32 @@ +-- Shared native called-move targeting and provenance. A caller may lend its +-- selected PP slot to the first child body; ordinary calls lend no PP. +local C = {} +function C.call(ctx, pick, ppSlot) + 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 + require("src.battle.Gen3ChoiceBand").call(ctx, pick, target, i > 1, i > 1, i == 1 and ppSlot or nil) + end + user.lastMove = ctx.move.id +end +return C diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index 13369eb2a..afb867d19 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -132,13 +132,13 @@ function C.execution(user, action) end return action end -function C.call(ctx, pick, target, suppressAnimation, spreadContinuation) +function C.call(ctx, pick, target, suppressAnimation, spreadContinuation, ppSlot) 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 } + battle._gbaNextCaller = { parent = parent, move = ctx.move.id, ppSlot = ppSlot } local ok, err = pcall( battle.performMove, battle, diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 2e6c0103f..56182d862 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -30,8 +30,8 @@ -- as it is being resolved -- bouncing it back, stealing it, forbidding it -- -- which needs a resolution step performMove does not have. -- --- ASSIST and NATURE POWER call a move chosen from outside the user's own --- move list (the rest of the party; the terrain), and SECRET POWER's side +-- ASSIST calls a move from the rest of the party. NATURE POWER now uses +-- native Gen3NaturePower registration in Gen3Builtins. SECRET POWER's side -- effect is chosen the same way. The terrain half of that is real work -- in the world layer, not the battle one. -- @@ -57,6 +57,7 @@ local S = Gen3MoveEffects.secondary local F = Gen3MoveEffects.full local ACC = Gen3MoveEffects.accuracyChecked + -- MoveEffects is required lazily inside the callbacks: it requires THIS file -- while it is still loading, so a top-level require here would be a cycle. local function changeStage(battle, who, stat, delta, fromEnemy) diff --git a/src/battle/Gen3NaturePower.lua b/src/battle/Gen3NaturePower.lua new file mode 100644 index 000000000..3b89b9f44 --- /dev/null +++ b/src/battle/Gen3NaturePower.lua @@ -0,0 +1,46 @@ +-- Native terrain caller. Nature Power announces before its child, while the +-- child's PP command owns the original selected slot and child target rules. +local N = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +N.MOVES = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local TERRAIN = { + GRASS = 0, + LONG_GRASS = 1, + SAND = 2, + UNDERWATER = 3, + WATER = 4, + POND = 5, + MOUNTAIN = 6, + CAVE = 7, + BUILDING = 8, + PLAIN = 9, +} +function N.terrain(battle) + if battle.terrain ~= nil then + return tonumber(battle.terrain) or 8 + end + local terrain = battle.gen3Terrain or require("src.battle.Gen3Battle").terrainFor(battle.game) + return TERRAIN[terrain] or tonumber(terrain) or 8 +end +function N.pick(ctx) + if not V.isGen3() then + return nil + end + local number = N.MOVES[N.terrain(ctx.battle)] or 129 + local id, move = require("src.battle.EnemyMoveChoice").resolveMove(ctx.data, number) + if not id then + ctx.say(Strings("But, it failed!")) + return nil + end + ctx.say(Strings("NATURE POWER turned into\n%s!", move.name)) + return id +end +function N.call(ctx, pick) + local frame = ctx.battle._gbaMoveFrame + return require("src.battle.Gen3CalledMove").call(ctx, pick, frame and frame.ppIndex) +end +function N.record() + return { kind = "full", announceAnim = false, gbaNaturePower = true, callsMove = N.pick } +end +return N diff --git a/src/battle/Gen3PPCommand.lua b/src/battle/Gen3PPCommand.lua index 412dd9e9b..4d33222b2 100644 --- a/src/battle/Gen3PPCommand.lua +++ b/src/battle/Gen3PPCommand.lua @@ -1,5 +1,6 @@ -- PP admission precedes the script; the PP command itself reads the current --- selected slot after public callbacks. Called children have no selected PP. +-- selected slot after public callbacks. Nature Power lends its selected slot +-- to its child; ordinary called children have no selected PP. local Choices = require("src.battle.EnemyMoveChoice") local P = {} function P.prepare(battle, user, target, action, continuation, called) @@ -13,15 +14,21 @@ function P.prepare(battle, user, target, action, continuation, called) end end end + local frame = battle._gbaMoveFrame + local calledSlot = called and frame and frame.calledPPSlot + if calledSlot then + index = calledSlot + end local skip = continuation - or called + or (called and not calledSlot) or action.struggle or require("src.battle.Gen3ChoiceBand").enemyUnlimited(battle, user) local move = battle:moveDef(action) local number = move and tonumber(move.index or move.numId) local current = index and (user.curMoves or user.mon.moves or {})[index] local command = { - blocked = not skip and number ~= 165 and current and tonumber(current.pp) == 0 or false, + index = index, + blocked = not called and not skip and number ~= 165 and current and tonumber(current.pp) == 0 or false, done = false, } function command.reduce() diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index ea79c90b4..e9dd827a9 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -107,32 +107,7 @@ function S.pick(ctx) 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 - require("src.battle.Gen3ChoiceBand").call(ctx, pick, target, i > 1, i > 1) - end - user.lastMove = ctx.move.id + return require("src.battle.Gen3CalledMove").call(ctx, pick) end function S.run(ctx) if ctx.user.mon.status == "SLP" then diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index e37e8c4cc..f9202a607 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 13 or nil + return require("src.core.GameVersion").isGen3() and 14 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 85b3ccb05..e5d970ceb 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -119,6 +119,9 @@ function Builtins.install(content, data) end end end + if registry and entry.name == "move_effects" then + require("src.battle.Gen3Builtins").install(registry, Builtins.OWNER) + end end end diff --git a/tests/enemy_actions/nature_power.lua b/tests/enemy_actions/nature_power.lua new file mode 100644 index 000000000..94c8c51c5 --- /dev/null +++ b/tests/enemy_actions/nature_power.lua @@ -0,0 +1,290 @@ +-- Owned native-shaped definitions exercise the terrain caller independently +-- of cartridge assets. Both engines execute their unchanged native dispatcher. +local M = {} +local DEFS = { + { 267, "NATURE_POWER", 173, "NATURE_POWER_EFFECT", 0, 1 }, + { 78, "STUN_SPORE", 67, "PARALYZE_EFFECT", 0, 0 }, + { 75, "RAZOR_LEAF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 56, "HYDRO_PUMP", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 61, "BUBBLEBEAM", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 157, "ROCK_SLIDE", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 247, "SHADOW_BALL", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 129, "SWIFT", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, +} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").fixture(root, backend, version) + if native then + f.data.gen3Items._byId[186] = + { name = "CHOICE BAND", pocket = "ITEMS", price = 100, holdEffect = 29, holdEffectParam = 0 } + else + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + price = 100, + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end + for _, row in ipairs(DEFS) do + local n, name, effect, binding, power, target = unpack(row) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[n], moves.BY_NUM[n], moves._numByName[name] = def, name, n + f.data.gen3Moves._rom[n], f.data.gen3Pokemon._moveNames[n] = def, name + else + local id = ("MOVE_%03d"):format(n) + f.data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + return f +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, terrain in ipairs({ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, 10, "4", "bad" }) do + for _, kind in ipairs({ + "ordinary", + "pressure", + "zero_pp", + "parent_zero", + "parent_replace", + "parent_remove", + "parent_reorder", + "child_zero", + "child_refill", + }) do + for _, item in ipairs({ 0, 186, 187, 219 }) do + local b = f.field(double) + b.terrain = terrain + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + user.mon.hp = 200 + f.put(user, item ~= 0 and item or nil) + local original + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = + { 267, 33 }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } + original = user.mon.pp + else + user.mon.moves = + { { id = "MOVE_267", pp = kind == "zero_pp" and 0 or 10 }, { id = "MOVE_033", pp = 10 } } + user.curMoves = user.mon.moves + original = user.curMoves[1] + end + if kind == "pressure" then + for id = 0, double and 3 or 1 do + local a = f.actor(id) + if a ~= user then + if native then + a.ability = "PRESSURE" + else + a.abilityOverride = "PRESSURE" + end + end + end + end + local trace, draws, events, damage = {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + holder = c.mon.nickname, + item = c.item, + definition = c.def and c.def.id, + trigger = c.trigger, + effect = c.effect, + parameter = c.parameter, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function pp() + local value + if native then + value = user.mon.pp[1] + else + value = user.curMoves[1] and user.curMoves[1].pp + end + return value == nil and "absent" or value + end + local function setPP(value) + if native then + user.mon.pp[1] = value + else + user.curMoves[1].pp = value + end + end + local offMove = 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, + pp = pp(), + publicPP = e.user.mon.moves[1] and e.user.mon.moves[1].pp or "absent", + } + if not e.isCalled then + if kind == "parent_zero" then + setPP(0) + end + if kind == "parent_replace" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, 267 }, { 3, 8 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_033", pp = 3 }, { id = "MOVE_267", pp = 8 } } + user.curMoves = user.mon.moves + end + end + if kind == "parent_remove" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { [2] = 33 }, { [2] = 10 }, { [2] = 10 } + else + user.mon.moves = { [2] = { id = "MOVE_033", pp = 10 } } + user.curMoves = user.mon.moves + end + elseif kind == "parent_reorder" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, 267 }, { 4, 10 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_033", pp = 4 }, { id = "MOVE_267", pp = 10 } } + user.curMoves = user.mon.moves + end + end + elseif kind == "child_zero" then + setPP(0) + elseif kind == "child_refill" then + setPP(7) + end + end) + local observers = {} + local offObserver = f.e.api.events:on("battle.move_used", function(e) + observers[#observers + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = pp(), + publicPP = e.user.mon.moves[1] and e.user.mon.moves[1].pp or "absent", + } + 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, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local selector = 0 + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if lo == 0 and hi == 3 then + value = selector % 4 + selector = selector + 1 + end + draws[#draws + 1] = { lo, hi, value } + return value + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + 267, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + offHeld() + offCrit() + offMove() + offObserver() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pps = {} + for i = 1, 4 do + local v + if native then + v = a.mon.pp[i] + else + v = a.curMoves[i] and a.curMoves[i].pp + end + pps[i] = v == nil and "absent" or v + end + local last = native and a.lastMoveId or a.lastMove + if not native and last then + last = f.data.moves[last] and f.data.moves[last].index or last + end + local choice = a.choicedMove + state[#state + 1] = { + hp = a.mon.hp, + pp = pps, + status = a.mon.status, + flinched = a.flinched or false, + lastMove = last, + choice = choice, + } + end + local oldPP = native and original[1] or original.pp + f.T.eq(#f.result.errors, 0, "Nature Power callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + terrain = terrain, + case = kind, + item = item, + events = events, + observers = observers, + damage = damage, + trace = trace, + draws = draws, + state = state, + retiredPP = oldPP, + } + end + 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 8672f29be..bdb7c0442 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 12 -> 13 only. +-- Legacy hash retained; native hash independently audits revision 13 -> 14 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "beacb71804a6af34" +local NATIVE = "cb314093f24370e7" 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 13 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 14 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_nature_power.lua b/tests/engine/gen3_nature_power.lua new file mode 100644 index 000000000..b50d02fcc --- /dev/null +++ b/tests/engine/gen3_nature_power.lua @@ -0,0 +1,60 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").run(".", "gen2", version) + T.eq(#rows, 3024, "all terrain, slot, PP, item and pressure cases execute") + for _, row in ipairs(rows) do + if row.case == "zero_pp" then + T.eq(#row.events, 1, "zero PP announces only the caller") + T.eq(#row.damage, 0, "zero PP never admits the child") + else + T.eq(#row.events, 2, "caller and called child announce exactly once") + T.eq(row.events[1].called, false, "the selected Nature Power is the caller") + T.eq(row.events[2].called, true, "the terrain attack is a native child") + local expected = row.case == "parent_zero" and 0 + or row.case == "parent_replace" and 3 + or row.case == "parent_remove" and "absent" + or row.case == "parent_reorder" and 4 + or 10 + T.eq(row.events[2].pp, expected, "the child event precedes the selected-slot PP command") + end + end + V.set(version) + local N = require("src.battle.Gen3NaturePower") + for name, number in pairs({ + GRASS = 0, + LONG_GRASS = 1, + SAND = 2, + UNDERWATER = 3, + WATER = 4, + POND = 5, + MOUNTAIN = 6, + CAVE = 7, + BUILDING = 8, + PLAIN = 9, + }) do + T.eq( + N.terrain({ gen3Terrain = name, game = {} }), + number, + "native cached terrain names retain the GBA identifier" + ) + end + T.eq( + N.terrain({ terrain = "bad", gen3Terrain = "GRASS" }), + 8, + "explicit invalid terrain retains reference fallback" + ) + T.eq(N.terrain({ terrain = "4" }), 4, "numeric terrain string is retained") +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq( + require("src.battle.Gen3Builtins").record("NATURE_POWER_EFFECT", nil), + nil, + "other generations gain no native Nature Power binding" + ) +end +V.set(before) +T.finish("native Nature Power selection and child PP command") diff --git a/tests/engine/gen3_nature_power_link.lua b/tests/engine/gen3_nature_power_link.lua new file mode 100644 index 000000000..b1d0de4b6 --- /dev/null +++ b/tests/engine/gen3_nature_power_link.lua @@ -0,0 +1,138 @@ +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/nature_power.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 13 + T.eq(current.nativeBattleRevision, 14, "Nature Power commands advertise revision fourteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + 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 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_267", pp = 10 } } + game.save.party[1].item = i == 1 and "ITEM_186" or "ITEM_219" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 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 got = inbox + inbox = {} + return got + 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 role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 8, "two actual turns announce caller and child for both actors") + for i, event in ipairs(seen[battle] or {}) do + T.eq(event.pp, i <= 4 and 10 or 9, "event sees current PP before the command") + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Nature Power events and selected PP in actual linked roles") diff --git a/tests/engine/gen3_post_hit_link.lua b/tests/engine/gen3_post_hit_link.lua index bd5e145f5..f2c691bd7 100644 --- a/tests/engine/gen3_post_hit_link.lua +++ b/tests/engine/gen3_post_hit_link.lua @@ -7,7 +7,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local current = H.hello(f.game, "battle") local old = Copy(current) old.nativeBattleRevision = 12 - T.eq(current.nativeBattleRevision, 13, "shared post-hit commands advertise revision thirteen") + T.check(current.nativeBattleRevision >= 13, "shared post-hit commands advertise at least revision thirteen") T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") for _, host in ipairs({ false, true }) do local state = setmetatable( diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 8ff4ea07f..8b87a0c28 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,7 @@ MODES = [ + (3, 'nature_power'), (3, 'post_hit_items_command'), (3, 'post_hit_items_execution'), (3, 'move_used_pp'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index ee06b0770..8a61262a6 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 == "post_hit_items_execution" then +if mode == "nature_power" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").run(root, backend, version) +elseif mode == "post_hit_items_execution" then rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").execution(root, backend, version) elseif mode == "post_hit_items_command" then rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").command(root, backend, version) From f7e8f4958a022c3ab1500be62cd26a81f28f8300 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:29:53 +0100 Subject: [PATCH 101/122] Verify unchanged API-3 Nature Power registration and execution --- docs/native-gen3-nature-power.md | 6 +- tests/enemy_actions/nature_power.lua | 103 ++++++++++++++++--- tests/engine/gen3_nature_power_content.lua | 13 +++ tools/enemy-action-contract-check/check.py | 1 + tools/enemy-action-contract-check/worker.lua | 4 +- 5 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 tests/engine/gen3_nature_power_content.lua diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md index 3c7164877..d378d3d99 100644 --- a/docs/native-gen3-nature-power.md +++ b/docs/native-gen3-nature-power.md @@ -37,6 +37,10 @@ Native command revision is 14; party codec remains 1. The independent unchanged read, RNG, damage, actor state and retired PP are compared without filtering. - Exact #248 production differs in **5,376** of the same rows. Both negative workers exit zero: these are behavioral differences, not fixture crashes. +- A second **6,048-row** matrix runs the same authored API-3 mod on both engines. + It registers `PORTABLE_NATURE` with canonical effect `NATURE_POWER`, verifies + its public record round trip and retains the same full execution evidence. + Its direct regression passes **17,474** checks. - The direct engine suite passes **28,926** checks, including generation boundaries and event-visible child PP. Actual host/guest/spectator completes two native menu turns per profile with stateful held steering, **108** checks, @@ -50,7 +54,7 @@ Native command revision is 14; party codec remains 1. The independent unchanged The fixture uses owned, controlled native-shaped child definitions to isolate caller behavior; it does not certify the complete cartridge child catalog. Full engine regression, other called-move contracts, final combined-fork gates, -actual imported Nature Power scenarios and broader public-content integration +actual imported Nature Power scenarios and broader public-content overrides remain pending. This checkpoint is not yet a published pull request or a new stable combined testing snapshot. No ROMs, caches, normal saves or mod assets are included. diff --git a/tests/enemy_actions/nature_power.lua b/tests/enemy_actions/nature_power.lua index 94c8c51c5..55a4aa7e9 100644 --- a/tests/enemy_actions/nature_power.lua +++ b/tests/enemy_actions/nature_power.lua @@ -13,9 +13,44 @@ local DEFS = { { 247, "SHADOW_BALL", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, { 129, "SWIFT", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, } -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, options) local native = backend == "gen1" - local f = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").fixture(root, backend, version) + options = options or {} + local f + if options.entry then + f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture( + root, + backend, + version, + { entry = options.entry } + ) + local field = f.field + f.field = function(double) + local b = field(double) + f.actor = function(id) + return f.at(b, id) + end + return b + end + f.put = f.hold + else + f = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").fixture(root, backend, version) + end + f.game.save.options.ruleset = "gen3_emerald" + for _, item in ipairs({ + { 187, "KINGS ROCK", 30, "FLINCH", 10 }, + { 219, "SHELL BELL", 62, "SHELL_BELL", 8 }, + }) do + local n, name, effect, kind, parameter = unpack(item) + if native then + f.data.gen3Items._byId[n] = + { name = name, pocket = "ITEMS", price = 100, holdEffect = effect, holdEffectParam = parameter } + else + local id = ("ITEM_%03d"):format(n) + f.data.items[id] = + { id = id, index = n, name = name, price = 100, holdEffect = kind, holdEffectParam = parameter } + end + end if native then f.data.gen3Items._byId[186] = { name = "CHOICE BAND", pocket = "ITEMS", price = 100, holdEffect = 29, holdEffectParam = 0 } @@ -66,9 +101,39 @@ function M.fixture(root, backend, version) end 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) + local f = M.fixture(root, backend, version, options) + local caller = native and 267 or "MOVE_267" + if options.public then + if native then + caller = assert(require("src.mods.Gen3Compat").moveId("PORTABLE_NATURE")) + local moves = require("src.core.game3.battle.moves") + moves._rom[caller] = assert(f.data.gen3Moves._rom[caller]) + moves.BY_NUM[caller], moves._numByName.PORTABLE_NATURE = "PORTABLE_NATURE", caller + else + caller = "PORTABLE_NATURE" + end + end + local function history(value) + if not options.public or value == nil then + return value + end + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local row = f.data.moves[value] + if row then + return row.name + end + for _, def in pairs(f.data.moves) do + if type(def) == "table" and def.index == value then + return def.name + end + end + return value + end local rows = {} for _, double in ipairs({ false, true }) do for actor = 0, double and 3 or 1 do @@ -94,11 +159,11 @@ function M.run(root, backend, version) local original if native then user.mon.moves, user.mon.pp, user.mon.maxPp = - { 267, 33 }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } + { caller, 33 }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } original = user.mon.pp else user.mon.moves = - { { id = "MOVE_267", pp = kind == "zero_pp" and 0 or 10 }, { id = "MOVE_033", pp = 10 } } + { { id = caller, pp = kind == "zero_pp" and 0 or 10 }, { id = "MOVE_033", pp = 10 } } user.curMoves = user.mon.moves original = user.curMoves[1] end @@ -161,9 +226,9 @@ function M.run(root, backend, version) end if kind == "parent_replace" then if native then - user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, 267 }, { 3, 8 }, { 10, 10 } + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, caller }, { 3, 8 }, { 10, 10 } else - user.mon.moves = { { id = "MOVE_033", pp = 3 }, { id = "MOVE_267", pp = 8 } } + user.mon.moves = { { id = "MOVE_033", pp = 3 }, { id = caller, pp = 8 } } user.curMoves = user.mon.moves end end @@ -176,9 +241,9 @@ function M.run(root, backend, version) end elseif kind == "parent_reorder" then if native then - user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, 267 }, { 4, 10 }, { 10, 10 } + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, caller }, { 4, 10 }, { 10, 10 } else - user.mon.moves = { { id = "MOVE_033", pp = 4 }, { id = "MOVE_267", pp = 10 } } + user.mon.moves = { { id = "MOVE_033", pp = 4 }, { id = caller, pp = 10 } } user.curMoves = user.mon.moves end end @@ -222,7 +287,7 @@ function M.run(root, backend, version) require("src.core.game3.battle.engine").resolveMove( actor, targetId, - 267, + caller, 1, require("src.core.game3.battle.adapter").new(b), b, @@ -259,8 +324,8 @@ function M.run(root, backend, version) pp = pps, status = a.mon.status, flinched = a.flinched or false, - lastMove = last, - choice = choice, + lastMove = history(last), + choice = history(choice), } end local oldPP = native and original[1] or original.pp @@ -287,4 +352,16 @@ function M.run(root, backend, version) f.close() return rows end +function M.content(root, backend, version) + return M.run(root, backend, version, { + public = true, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_NATURE", {id="PORTABLE_NATURE",name="PORTABLE_NATURE", + type="NORMAL",power=0,accuracy=100,pp=10,effect="NATURE_POWER"})) + local move=assert(mod.content.moves:get("PORTABLE_NATURE")) + assert(move.effect=="NATURE_POWER" and move.pp==10 and move.type=="NORMAL" and move.power==0) + mod.exports.api=mod + end]=], + }) +end return M diff --git a/tests/engine/gen3_nature_power_content.lua b/tests/engine/gen3_nature_power_content.lua new file mode 100644 index 000000000..614a92998 --- /dev/null +++ b/tests/engine/gen3_nature_power_content.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").content(".", "gen2", version) + T.eq(#rows, 3024, "same API-3 registered Nature Power caller exercises every contract case") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "PORTABLE_NATURE", "public registered caller retains its canonical identity") + if row.case ~= "zero_pp" then + T.eq(#row.events, 2, "registered caller dispatches one child announcement") + end + end +end +T.finish("public API-3 Nature Power content registration and native execution") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 8b87a0c28..e690ea89c 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -13,6 +13,7 @@ MODES = [ (3, 'nature_power'), + (3, 'nature_power_content'), (3, 'post_hit_items_command'), (3, 'post_hit_items_execution'), (3, 'move_used_pp'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 8a61262a6..631f25601 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 == "nature_power" then +if mode == "nature_power_content" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").content(root, backend, version) +elseif mode == "nature_power" then rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").run(root, backend, version) elseif mode == "post_hit_items_execution" then rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").execution(root, backend, version) From 474b3df8c23d9e609f145fa6f57227eb0046b0b1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:33:16 +0100 Subject: [PATCH 102/122] Exercise actual Nature Power imports through public battle submissions --- docs/native-gen3-nature-power.md | 13 +- .../imported-nature-power-smoke.lua | 153 ++++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 tools/enemy-action-contract-check/imported-nature-power-smoke.lua diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md index d378d3d99..f8164e9a9 100644 --- a/docs/native-gen3-nature-power.md +++ b/docs/native-gen3-nature-power.md @@ -53,9 +53,16 @@ Native command revision is 14; party codec remains 1. The independent unchanged The fixture uses owned, controlled native-shaped child definitions to isolate caller behavior; it does not certify the complete cartridge child catalog. -Full engine regression, other called-move contracts, final combined-fork gates, -actual imported Nature Power scenarios and broader public-content overrides -remain pending. This checkpoint is not yet a published pull request or a new +Actual local FireRed/Emerald imports additionally pass **80 controlled public-menu +scenarios**: native and API-3 registered callers, all ten terrains, Choice Band +and Shell Bell, event-visible PP, selected-slot ownership, advertised child IDs, +actual Stun Spore status and damaging children. Every move is submitted through +`mod.battle:submit()`, and the native text/result queue returns to its menu. This +is CPU-only constructed-battle acceptance with the intro skipped, not a graphics +or complete-playthrough result. + +Full engine regression, other called-move contracts, final combined-fork gates +and broader public-content overrides remain pending. This checkpoint is not yet a published pull request or a new stable combined testing snapshot. No ROMs, caches, normal saves or mod assets are included. diff --git a/tools/enemy-action-contract-check/imported-nature-power-smoke.lua b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua new file mode 100644 index 000000000..5d0352af7 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua @@ -0,0 +1,153 @@ +-- Existing local imports, controlled battlers and actual public menu submissions. +-- No normal save or generated asset is written; no graphical acceptance is claimed. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local children = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for terrain = 0, 9 do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[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(fn)choice=fn end + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " local choice", + [=[ assert(mod.content.moves:register("PORTABLE_NATURE",{id="PORTABLE_NATURE",name="PORTABLE_NATURE",type="NORMAL",power=0,accuracy=100,pp=10,effect="NATURE_POWER"})) + local choice]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. 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, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + b.terrain = terrain + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_NATURE" or id(267) + local publicParent = assert(f.e.api.content.moves:get(parent)) + assert(publicParent.effect == "NATURE_POWER") + local child = assert(f.e.api.content.moves:get(id(children[terrain]))) + local learned = { { id = parent, pp = 10 }, { id = id(33), pp = 10 } } + b.player.mon.moves, b.player.curMoves = learned, learned + b.enemy.mon.moves = { { id = id(33), pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + for _, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.abilityOverride = false + end + b.player.mon.hp = 2000 + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, damage, draws = {}, {}, {} + local beforeFoe + f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + target = e.target.mon.nickname, + } + else + beforeFoe = b.player.mon.hp + end + end) + f.e.api.events:on("battle.damage_dealt", function(e) + if e.user.mon.nickname == "PLAYER" then + damage[#damage + 1] = e.damage + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + 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 snapshot = assert(f.e.api.battle:snapshot()) + local ok, why = + f.e.api.battle:submit({ kind = "move", slot = 1, id = 1, revision = snapshot.revision }) + assert(ok, 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 + assert(b.phase == "menu" and b.turnCount == 1, "actual submitted imported turn must finish") + assert(#used == 2 and used[1].move == publicParent.id and not used[1].called) + assert( + used[2].move == child.id and used[2].called, + "actual terrain child uses advertised public identity" + ) + assert(used[1].pp == 10 and used[2].pp == 10, "both public announcements precede child PP ownership") + assert(learned[1].pp == 9 and learned[2].pp == 10, "only selected caller slot spends PP") + local healing = itemNumber == 219 and damage[1] and math.max(1, math.floor(damage[1] / 8)) or 0 + assert(beforeFoe == 2000 + healing, "native move-end healing completes before the next actor") + if terrain == 0 then + assert(b.enemy.mon.status == "PAR" and #damage == 0, "actual Stun Spore status handler executes") + else + assert(#damage > 0 and damage[1] > 0, "actual imported damaging child executes") + end + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration remain error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + terrain = terrain, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + pp = learned[1].pp, + beforeFoe = beforeFoe, + } + f.close() + end + end + end +end +print("IMPORTED_NATURE_POWER_SMOKE " .. require("src.link.Json").encode(rows)) From 0536521ea23e41af8c206961a266db96f5ff6eb1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:57:45 +0100 Subject: [PATCH 103/122] Preserve nested native caller history and sparse Sleep Talk slots --- docs/native-gen3-nature-power.md | 23 +++++++++++--- src/battle/BattleState.lua | 1 + src/battle/Gen3CalledMove.lua | 16 ++++++++-- src/battle/Gen3ChoiceBand.lua | 7 +++-- src/battle/Gen3SleepTalk.lua | 8 ++--- tests/enemy_actions/nature_power.lua | 16 ++++++++-- tests/engine/gen3_nature_power_chained.lua | 18 +++++++++++ tools/enemy-action-contract-check/check.py | 1 + .../imported-nature-power-smoke.lua | 31 ++++++++++++++++--- tools/enemy-action-contract-check/worker.lua | 4 ++- 10 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 tests/engine/gen3_nature_power_chained.lua diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md index f8164e9a9..403ed8644 100644 --- a/docs/native-gen3-nature-power.md +++ b/docs/native-gen3-nature-power.md @@ -21,7 +21,9 @@ Shared called-target dispatch preserves existing Sleep Talk targets and caller provenance. Nature Power lends PP ownership only to the first child body, so spread continuations do not spend it repeatedly. Choice Band and move-end commands identify the native parent. Generic authored calls keep their existing -ownership path. This is a public-event/native-command change, not a claim of +ownership path. Nested native callers preserve the innermost caller's resulting +history: Sleep Talk -> Nature Power -> terrain move records Nature Power. Sleep +Talk scans all four native slots even when a callback removes an earlier slot. This is a public-event/native-command change, not a claim of complete graphical or full-mod acceptance. Native command revision is 14; party codec remains 1. The independent unchanged @@ -41,6 +43,14 @@ Native command revision is 14; party codec remains 1. The independent unchanged It registers `PORTABLE_NATURE` with canonical effect `NATURE_POWER`, verifies its public record round trip and retains the same full execution evidence. Its direct regression passes **17,474** checks. +- Another **6,048** paired rows exercise native Sleep Talk -> Nature Power + chains, including slot removal, replacement, reordering and Pressure. The + previous feature production differs in **4,032** chained rows. A complete + actual-script probe originally showed last move 267 in the reference versus + 214 in the host; that failure is retained. +- Related Sleep Talk, Choice Band, Baton Pass, authored calls and PP contracts + pass **36 workers / 21,474 paired rows**, including all three Nature Power + modes. The direct chained regression passes **15,458** checks. - The direct engine suite passes **28,926** checks, including generation boundaries and event-visible child PP. Actual host/guest/spectator completes two native menu turns per profile with stateful held steering, **108** checks, @@ -53,16 +63,19 @@ Native command revision is 14; party codec remains 1. The independent unchanged The fixture uses owned, controlled native-shaped child definitions to isolate caller behavior; it does not certify the complete cartridge child catalog. -Actual local FireRed/Emerald imports additionally pass **80 controlled public-menu +Actual local FireRed/Emerald imports additionally pass **160 controlled public-menu scenarios**: native and API-3 registered callers, all ten terrains, Choice Band -and Shell Bell, event-visible PP, selected-slot ownership, advertised child IDs, +and Shell Bell, direct and Sleep Talk callers, event-visible PP, selected-slot +ownership, resulting history, advertised child IDs, actual Stun Spore status and damaging children. Every move is submitted through `mod.battle:submit()`, and the native text/result queue returns to its menu. This is CPU-only constructed-battle acceptance with the intro skipped, not a graphics or complete-playthrough result. -Full engine regression, other called-move contracts, final combined-fork gates -and broader public-content overrides remain pending. This checkpoint is not yet a published pull request or a new +The first full feature/combined batches were explicitly interrupted after the +new nested-history failure was proven; partial raw logs and interruption records +are retained. They are not final acceptance. Full engine regression, final +combined-fork gates and broader public-content overrides remain pending. This checkpoint is not yet a published pull request or a new stable combined testing snapshot. No ROMs, caches, normal saves or mod assets are included. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6d0647bef..0965dd415 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8484,6 +8484,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima local frame = { parent = parent, user = user, target = target, move = self:moveDef(moveInst), moveId = moveInst.id, calledBy = isCalled and caller and caller.parent == parent and caller.move or nil, calledPPSlot = isCalled and caller and caller.parent == parent and caller.ppSlot or nil } + if isCalled and caller and caller.parent == parent then caller.frame = frame end if parent then parent.called = true end self._gbaMoveFrame = frame if self._gbaActionFrame then self._gbaActionFrame.moved = true end diff --git a/src/battle/Gen3CalledMove.lua b/src/battle/Gen3CalledMove.lua index d8296dd66..6fa3e2f3b 100644 --- a/src/battle/Gen3CalledMove.lua +++ b/src/battle/Gen3CalledMove.lua @@ -24,9 +24,21 @@ function C.call(ctx, pick, ppSlot) else targets = { tonumber(move.target) == 16 and user or b:defaultTargetFor(user) } end + local chained = false for i, target in ipairs(targets) do - require("src.battle.Gen3ChoiceBand").call(ctx, pick, target, i > 1, i > 1, i == 1 and ppSlot or nil) + chained = require("src.battle.Gen3ChoiceBand").call( + ctx, + pick, + target, + i > 1, + i > 1, + i == 1 and ppSlot or nil + ) or chained + end + -- A nested native caller owns the resulting history; an ordinary child + -- records this caller, including the existing switch/resume behavior. + if not chained then + user.lastMove = ctx.move.id end - user.lastMove = ctx.move.id end return C diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index afb867d19..c5f3c3f9c 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -138,7 +138,8 @@ function C.call(ctx, pick, target, suppressAnimation, spreadContinuation, ppSlot if parent then parent.nativeCalled = true end - battle._gbaNextCaller = { parent = parent, move = ctx.move.id, ppSlot = ppSlot } + local call = { parent = parent, move = ctx.move.id, ppSlot = ppSlot } + battle._gbaNextCaller = call local ok, err = pcall( battle.performMove, battle, @@ -155,7 +156,9 @@ function C.call(ctx, pick, target, suppressAnimation, spreadContinuation, ppSlot error(err, 0) end ctx.user = parent and parent.user or ctx.user - ctx.user.lastMove = ctx.move.id + local chained = call.frame and call.frame.nativeCalled or false + if not chained then ctx.user.lastMove = ctx.move.id end + return chained end function C.builtin(id, base) if not V.isGen3() or not base or (id ~= "METRONOME_EFFECT" and id ~= "MIRROR_MOVE_EFFECT") then diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua index e9dd827a9..23f7c1d9c 100644 --- a/src/battle/Gen3SleepTalk.lua +++ b/src/battle/Gen3SleepTalk.lua @@ -83,11 +83,9 @@ function S.pick(ctx) 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] + for slot = 1, 4 do + local inst = (user.curMoves or user.mon.moves or {})[slot] + local move = inst and ctx.data.moves[inst.id] local n = number(move) if move diff --git a/tests/enemy_actions/nature_power.lua b/tests/enemy_actions/nature_power.lua index 55a4aa7e9..c54674239 100644 --- a/tests/enemy_actions/nature_power.lua +++ b/tests/enemy_actions/nature_power.lua @@ -3,6 +3,7 @@ local M = {} local DEFS = { { 267, "NATURE_POWER", 173, "NATURE_POWER_EFFECT", 0, 1 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, { 78, "STUN_SPORE", 67, "PARALYZE_EFFECT", 0, 0 }, { 75, "RAZOR_LEAF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, @@ -116,6 +117,11 @@ function M.run(root, backend, version, options) caller = "PORTABLE_NATURE" end end + local second = native and 33 or "MOVE_033" + if options.sleepCaller then + second = caller + caller = native and 214 or "MOVE_214" + end local function history(value) if not options.public or value == nil then return value @@ -155,15 +161,18 @@ function M.run(root, backend, version, options) local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 local target = f.actor(targetId) user.mon.hp = 200 + if options.sleepCaller then + user.mon.status, user.sleepTurns = "SLP", 4 + end f.put(user, item ~= 0 and item or nil) local original if native then user.mon.moves, user.mon.pp, user.mon.maxPp = - { caller, 33 }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } + { caller, second }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } original = user.mon.pp else user.mon.moves = - { { id = caller, pp = kind == "zero_pp" and 0 or 10 }, { id = "MOVE_033", pp = 10 } } + { { id = caller, pp = kind == "zero_pp" and 0 or 10 }, { id = second, pp = 10 } } user.curMoves = user.mon.moves original = user.curMoves[1] end @@ -364,4 +373,7 @@ function M.content(root, backend, version) end]=], }) end +function M.chained(root, backend, version) + return M.run(root, backend, version, { sleepCaller = true }) +end return M diff --git a/tests/engine/gen3_nature_power_chained.lua b/tests/engine/gen3_nature_power_chained.lua new file mode 100644 index 000000000..200932d42 --- /dev/null +++ b/tests/engine/gen3_nature_power_chained.lua @@ -0,0 +1,18 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").chained(".", "gen2", version) + T.eq(#rows, 3024, "nested native caller keeps every slot, PP and terrain case") + for _, row in ipairs(rows) do + local last = row.state[row.actor + 1].lastMove + if #row.events == 3 then + T.eq(row.events[2].move, "NATURE_POWER", "Sleep Talk invokes the native terrain caller") + T.eq(last, 267, "the inner native caller owns resulting move history") + elseif #row.events == 2 then + T.eq(last, 214, "changed learned slots preserve ordinary Sleep Talk ownership") + else + T.eq(row.case, "zero_pp", "only zero-PP admission omits every child") + end + end +end +T.finish("native Sleep Talk to Nature Power caller chains") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index e690ea89c..96756ee24 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -14,6 +14,7 @@ MODES = [ (3, 'nature_power'), (3, 'nature_power_content'), + (3, 'nature_power_chained'), (3, 'post_hit_items_command'), (3, 'post_hit_items_execution'), (3, 'move_used_pp'), diff --git a/tools/enemy-action-contract-check/imported-nature-power-smoke.lua b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua index 5d0352af7..e94a44841 100644 --- a/tools/enemy-action-contract-check/imported-nature-power-smoke.lua +++ b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua @@ -3,6 +3,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local chained = arg[2] == "--sleep-talk" local children = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } local rows = {} for _, version in ipairs({ "firered", "emerald" }) do @@ -51,7 +52,9 @@ for _, version in ipairs({ "firered", "emerald" }) do local publicParent = assert(f.e.api.content.moves:get(parent)) assert(publicParent.effect == "NATURE_POWER") local child = assert(f.e.api.content.moves:get(id(children[terrain]))) - local learned = { { id = parent, pp = 10 }, { id = id(33), pp = 10 } } + local selected = chained and id(214) or parent + local publicSelected = assert(f.e.api.content.moves:get(selected)) + local learned = { { id = selected, pp = 10 }, { id = chained and parent or id(33), pp = 10 } } b.player.mon.moves, b.player.curMoves = learned, learned b.enemy.mon.moves = { { id = id(33), pp = 10 } } b.enemy.curMoves = b.enemy.mon.moves @@ -62,6 +65,9 @@ for _, version in ipairs({ "firered", "emerald" }) do a.abilityOverride = false end b.player.mon.hp = 2000 + if chained then + b.player.mon.status, b.player.sleepTurns = "SLP", 4 + end for key, def in pairs(f.data.items) do if type(def) == "table" and tonumber(def.index) == itemNumber then b.player.mon.item = key @@ -116,12 +122,28 @@ for _, version in ipairs({ "firered", "emerald" }) do game.stack:update(1 / 60) end assert(b.phase == "menu" and b.turnCount == 1, "actual submitted imported turn must finish") - assert(#used == 2 and used[1].move == publicParent.id and not used[1].called) + assert(#used == (chained and 3 or 2) and used[1].move == publicSelected.id and not used[1].called) + local final = used[#used] assert( - used[2].move == child.id and used[2].called, + final.move == child.id and final.called, "actual terrain child uses advertised public identity" ) - assert(used[1].pp == 10 and used[2].pp == 10, "both public announcements precede child PP ownership") + assert(used[1].pp == 10, "selected announcement precedes its PP command") + if chained then + assert( + used[2].move == publicParent.id and used[2].called and used[2].pp == 9 and final.pp == 9, + "native called Nature Power borrows no PP from the outer Sleep Talk" + ) + else + assert(final.pp == 10, "Nature Power child announces before its selected PP command") + end + assert(b.player.lastMove == parent, "inner native caller preserves resulting move history") + if itemNumber == 186 then + assert( + b.player.choicedMove == require("src.battle.Gen3ChoiceBand").identity(b, parent), + "native Choice Band owns the innermost caller" + ) + end assert(learned[1].pp == 9 and learned[2].pp == 10, "only selected caller slot spends PP") local healing = itemNumber == 219 and damage[1] and math.max(1, math.floor(damage[1] / 8)) or 0 assert(beforeFoe == 2000 + healing, "native move-end healing completes before the next actor") @@ -137,6 +159,7 @@ for _, version in ipairs({ "firered", "emerald" }) do rows[#rows + 1] = { version = version, authored = authored, + chained = chained, terrain = terrain, item = itemNumber, used = used, diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 631f25601..5f1c455ca 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 == "nature_power_content" then +if mode == "nature_power_chained" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").chained(root, backend, version) +elseif mode == "nature_power_content" then rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").content(root, backend, version) elseif mode == "nature_power" then rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").run(root, backend, version) From 812ec1f2354d4f98a5f4ebff15876120743ea7a9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:17:57 +0100 Subject: [PATCH 104/122] Record final native and public Nature Power acceptance --- docs/native-gen3-nature-power.md | 43 +++++++++++++++-- docs/qa/native-gen3-nature-power.json | 68 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 docs/qa/native-gen3-nature-power.json diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md index 403ed8644..b53a271d0 100644 --- a/docs/native-gen3-nature-power.md +++ b/docs/native-gen3-nature-power.md @@ -1,6 +1,6 @@ # Native GBA Nature Power caller -Development checkpoint stacked on #248. Gen1 is an unchanged, read-only +Tested contribution stacked on #248. Gen1 is an unchanged, read-only reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. ## Implemented contract @@ -73,10 +73,41 @@ is CPU-only constructed-battle acceptance with the intro skipped, not a graphics or complete-playthrough result. The first full feature/combined batches were explicitly interrupted after the -new nested-history failure was proven; partial raw logs and interruption records -are retained. They are not final acceptance. Full engine regression, final -combined-fork gates and broader public-content overrides remain pending. This checkpoint is not yet a published pull request or a new -stable combined testing snapshot. No ROMs, caches, normal saves or mod assets +nested-history failure was proven. Partial raw logs and interruption records +are retained separately from final acceptance. + +## Final acceptance + +Feature `0536521ea23e41af8c206961a266db96f5ff6eb1` passes **153/162** engine +suites, SDK **3/3** and B30. The exact Counter of **25 inherited FAIL lines in +nine suites** equals fresh unchanged #248 (`1ea0f875`, **149/158**). +Combined `0ed0602be9019102a06f4df3ee22480bca1d800f` passes **271/271**, SDK +**3/3**, all quick tiers and B30, with zero FAIL lines. + +Each runtime passes **172 workers / 122,012 identical paired enemy/action +rows**, plus **2,040 held-command** and **6,912 ordinary-damage rows**: +**180 workers / 130,964** selected paired observations. Every accepted worker +exits zero; there is no accepted timeout or filtered observation. The native, +public-content and chained Nature Power matrices each contribute 6,048 rows. + +All four held-item formats pass strict scheduling (**40 workers / 1,914 rows**). +Charge contracts pass four formats (**64 workers / 1,020 rows**), preserving +the feature prerequisite's explicit legacy enemy-PP policy; the combined fork +requires raw native policy through existing #162. Existing imported Sleep Talk +and post-hit scenarios pass alongside the 160 new Nature Power scenarios. +Independent protocol audit, AST-verified formatting, actionlint and whitespace +checks pass. Later publication edits preserve these runtime, test and tool trees. + +Source-bound summaries and raw-report digests are in +[the QA receipt](qa/native-gen3-nature-power.json). + +## Remaining scope + +Additional special callers/catalogs/controllers, broad public-effect overrides, +Ruby/Sapphire-specific qualification, native text-resource/pixel parity and +unchanged full-mod/complete graphical playthrough acceptance remain open. +No claim of complete battle or mod portability is made by this contribution. +No ROMs, caches, normal saves or mod assets are included. ## Reproduction @@ -87,4 +118,6 @@ Use maintained LuaJIT at `a4c064539ea931274d407e1249b4bb575424a0f4`. python3 tools/enemy-action-contract-check/check.py --gen1 /path/to/read-only-gen1 --lua /path/to/luajit --mode nature_power --report /tmp/nature-power-report.json /path/to/luajit tests/engine/gen3_nature_power.lua /path/to/luajit tests/engine/gen3_nature_power_link.lua +/path/to/luajit tools/enemy-action-contract-check/imported-nature-power-smoke.lua /path/to/local-cache +/path/to/luajit tools/enemy-action-contract-check/imported-nature-power-smoke.lua /path/to/local-cache --sleep-talk ``` diff --git a/docs/qa/native-gen3-nature-power.json b/docs/qa/native-gen3-nature-power.json new file mode 100644 index 000000000..71511620f --- /dev/null +++ b/docs/qa/native-gen3-nature-power.json @@ -0,0 +1,68 @@ +{ + "feature": { + "runtime_ref": "0536521ea23e41af8c206961a266db96f5ff6eb1", + "engine": "153/162", + "fail_counter_lines": 25, + "sdk": "3/3", + "oracle_workers": 172, + "oracle_rows": 122012, + "selected_paired_workers": 180, + "selected_paired_rows": 130964, + "new_nature_rows": { + "nature_power": 6048, + "nature_power_content": 6048, + "nature_power_chained": 6048 + }, + "imported_scenarios": { + "imported-nature-power": 80, + "imported-nature-power-chained": 80 + }, + "oracle_sha256": "1d4dd0098213dd91b5dd31c5b6db8541342a8b8b77b713c30a0f6e7dd193f7ba", + "full_gate_sha256": "ba8b46bf7a65667f4399737518b06c75dcf61d8e6698265e146db0d6f3f40557" + }, + "combined": { + "runtime_ref": "0ed0602be9019102a06f4df3ee22480bca1d800f", + "engine": "271/271", + "fail_counter_lines": 0, + "sdk": "3/3", + "oracle_workers": 172, + "oracle_rows": 122012, + "selected_paired_workers": 180, + "selected_paired_rows": 130964, + "new_nature_rows": { + "nature_power": 6048, + "nature_power_content": 6048, + "nature_power_chained": 6048 + }, + "imported_scenarios": { + "imported-nature-power": 80, + "imported-nature-power-chained": 80 + }, + "oracle_sha256": "7a06e058137eb17884e4fd3d5087022d66e2e992fa13a8233a58e06f8174130c", + "full_gate_sha256": "e95d0f5030fc4221b410f8a6238300c71aeab3e7153055c10cdb75554dafd3ec" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "prerequisite_ref": "1ea0f875ea03cc1ca7b22f88191d9bb15b51bac0", + "native_command_revision": 14, + "party_codec_revision": 1, + "native_digest": "cb314093f24370e7", + "legacy_digest": "e33d251961f2a41d", + "strict_held": { + "workers": 40, + "rows": 1914 + }, + "charge": { + "workers": 64, + "rows": 1020, + "strict_native_policy": "combined" + }, + "native_linked_checks": 108, + "nature_direct_checks": 28926, + "nature_public_checks": 17474, + "nature_chained_checks": 15458, + "old_prerequisite_different_rows": 5376, + "before_chain_fix_different_rows": 4032, + "old_public_registration": "unsupported: effect has no native handler: NATURE_POWER", + "raw_failed_and_interrupted_evidence_retained": true, + "scope": "Selected native GBA caller/PP/history and public API-3 registration; owned native-shaped matrix definitions and separate actual-import CPU menu scenarios; no full-catalog, graphical or full-mod acceptance" +} From dc7e13c22eb297fa345b4e92f7a9d5a5a01b8ecd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:58:58 +0100 Subject: [PATCH 105/122] Implement native GBA Assist and portable party move calls --- docs/native-gen3-assist.md | 56 ++ src/battle/BattleState.lua | 10 +- src/battle/Gen3Assist.lua | 99 ++++ src/battle/Gen3Builtins.lua | 4 + src/battle/Gen3ChoiceBand.lua | 2 +- src/link/Fingerprint.lua | 2 +- tests/enemy_actions/assist.lua | 547 ++++++++++++++++++ tests/engine/gen3_assist.lua | 34 ++ tests/engine/gen3_assist_content.lua | 17 + tests/engine/gen3_assist_failed_callers.lua | 19 + tests/engine/gen3_assist_link.lua | 145 +++++ tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_nature_power_link.lua | 2 +- tools/enemy-action-contract-check/check.py | 4 + .../imported-assist-smoke.lua | 185 ++++++ tools/enemy-action-contract-check/worker.lua | 23 +- 16 files changed, 1142 insertions(+), 13 deletions(-) create mode 100644 docs/native-gen3-assist.md create mode 100644 src/battle/Gen3Assist.lua create mode 100644 tests/enemy_actions/assist.lua create mode 100644 tests/engine/gen3_assist.lua create mode 100644 tests/engine/gen3_assist_content.lua create mode 100644 tests/engine/gen3_assist_failed_callers.lua create mode 100644 tests/engine/gen3_assist_link.lua create mode 100644 tools/enemy-action-contract-check/imported-assist-smoke.lua diff --git a/docs/native-gen3-assist.md b/docs/native-gen3-assist.md new file mode 100644 index 000000000..180be45b0 --- /dev/null +++ b/docs/native-gen3-assist.md @@ -0,0 +1,56 @@ +# Native GBA Assist and portable content + +This contribution builds on #252. The unchanged Gen1 reference is +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; all changes target Gen2Recomped. + +## Public mod contract + +The same API-3 registration uses the canonical effect on both engines: + +```lua +mod.content.moves:register("PORTABLE_ASSIST", { + id = "PORTABLE_ASSIST", name = "PORTABLE_ASSIST", type = "NORMAL", + power = 0, accuracy = 100, pp = 10, effect = "ASSIST", +}) +``` + +The builtin is installed for GBA. Existing content reads return the canonical +`ASSIST` effect, and registered party moves execute their own native effects. +Mods do not need a private `BattleState` replacement or a generation-specific +implementation of the party candidate scan. + +## Native command behavior + +After the parent `battle.move_used` subscribers, Assist scans the current side's +party in order and the first four move slots in order. Each eligible occurrence +has its own weight. Fainted members and zero-PP party moves are eligible; the +current user, eggs, empty species and the reference's forbidden callers/moves +are excluded. Sparse move slots do not terminate the scan. Active partners can +contribute. The host's two trainer party arrays are read as one side party. + +The pool is captured before the caller's PP command. PP is spent before a +successful Assist animation and its selection RNG. The child announces with +`isCalled = true`, consumes no party member PP and uses its native targeting. +Nature Power may be a child and can dispatch its own terrain move. Empty pools +preserve the previous move history, including when an outer Metronome selected +Assist. The same early-return history handling applies to failed native Mirror +Move. Native Metronome/Mirror Move children use the shared called +move targeting path in double battles. + +GBA lockstep advertises command revision **15**; party codec revision stays **1**. +Revision-14 peers cannot start a native battle. The native fixture digest is +`662d7e5ee823ce82`, independently computed with the frozen parent serializer and +only the revision token changed. The legacy digest remains `e33d251961f2a41d`. + +## Validation scope + +The shared fixture executes the real native dispatch on the clean reference and +the actual Gen2 battle implementation. It retains full move/damage events, raw +and public PP, held-item callbacks, RNG bounds/values, queued move animations, +active state and party PP. Separate native linked-role and actual-import menu +submission tests cover the host execution path. + +These checks use owned controlled definitions and separate existing imported +FireRed/Emerald data. They do not establish whole-catalog Metronome parity, +visual animation parity, arbitrary mod compatibility or a complete playthrough. +Final acceptance counts and retained negative evidence are recorded with the PR. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0965dd415..b404eab2d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8300,6 +8300,9 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) return end + if record and record.gbaAssist and self._gbaMoveFrame then + self._gbaMoveFrame.assistPool = require("src.battle.Gen3Assist").pool(self, user) + end if not (record and record.gbaNaturePower) then moveInst = nativePP.reduce() end end @@ -8320,7 +8323,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni 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 + if frame then frame.finished, frame.nativeNoHistory = true, true end end -- Mirror Move never plays its own anim (MetronomePickMove does; -- MirrorMoveCopyMove only reloads the copied move or prints fail) @@ -8328,10 +8331,11 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni self:cancelMoveAnim() end if pick then - if record.gbaNaturePower and GameVersion.isGen3() then require("src.battle.Gen3NaturePower").call(ctx, pick) + if record.gbaAssist and GameVersion.isGen3() then require("src.battle.Gen3Assist").call(ctx, pick) + elseif record.gbaNaturePower and GameVersion.isGen3() then require("src.battle.Gen3NaturePower").call(ctx, pick) elseif 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) + require("src.battle.Gen3CalledMove").call(ctx, pick) else self:performMove(user, target, { id = pick, pp = 1 }, true) end end return diff --git a/src/battle/Gen3Assist.lua b/src/battle/Gen3Assist.lua new file mode 100644 index 000000000..5f8e751b5 --- /dev/null +++ b/src/battle/Gen3Assist.lua @@ -0,0 +1,99 @@ +-- Native Assist keeps one candidate per eligible party move occurrence. +local A = {} +local Strings = require("src.core.Strings") +local BLOCKED = { + [118] = true, + [165] = true, + [166] = true, + [102] = true, + [68] = true, + [243] = true, + [182] = true, + [197] = true, + [203] = true, + [194] = true, + [214] = true, + [168] = true, + [266] = true, + [289] = true, + [270] = true, + [343] = true, + [271] = true, + [264] = true, + [119] = true, + [274] = true, +} +function A.party(battle, user) + local party + if user.isPlayer then + party = battle.playerParty or battle.game and battle.game.save and battle.game.save.party or {} + else + party = battle.enemyParty or { user.mon } + if battle.enemyPartyB then + local combined = {} + for _, m in ipairs(party) do + combined[#combined + 1] = m + end + for _, m in ipairs(battle.enemyPartyB) do + combined[#combined + 1] = m + end + party = combined + end + end + local index + for i, mon in ipairs(party) do + if mon == user.mon then + index = i + break + end + end + return party, index or user.partyIndex +end +function A.pool(battle, user) + local party, own = A.party(battle, user) + local pool = {} + for i, mon in ipairs(party) do + if i ~= own and mon and mon.species and mon.species ~= 0 and not mon.isEgg then + for slot = 1, 4 do + local inst = mon.moves and mon.moves[slot] + local ref = type(inst) == "table" and inst.id or inst + local id, def = require("src.battle.EnemyMoveChoice").resolveMove(battle.data, ref) + local n = tonumber(ref) or def and tonumber(def.index or def.numId) or id + if n and n ~= 0 and not BLOCKED[n] then + pool[#pool + 1] = id or ref + end + end + end + end + return pool +end +function A.pick(ctx) + local b = ctx.battle + local frame = b._gbaMoveFrame + local pool = frame and frame.assistPool or A.pool(b, ctx.user) + if #pool == 0 then + if frame then + frame.finished, frame.nativeNoHistory = true, true + end + ctx.say(Strings("But, it failed!")) + return nil + end + b.nextInsert = (b.nextInsert or 0) + 1 + local row = { + anim = ctx.move.id, + attackerIsPlayer = ctx.user.isPlayer, + attackerPosition = ctx.user.position, + targetPosition = ctx.target and ctx.target.position, + animContext = { moveTurn = 0 }, + } + table.insert(b.queue, b.nextInsert, row) + b.moveAnimRow = row + return pool[ctx.rng(1, #pool)] +end +function A.call(ctx, pick) + return require("src.battle.Gen3CalledMove").call(ctx, pick) +end +function A.record() + return { kind = "full", announceAnim = false, gbaAssist = true, callsMove = A.pick } +end +return A diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index 60fac72f9..ae1ac34e9 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -1,6 +1,9 @@ -- Adapt complete builtin records before mods capture them for delegation. local M = {} function M.record(id, base) + if id == "ASSIST_EFFECT" and require("src.core.GameVersion").isGen3() then + return base or require("src.battle.Gen3Assist").record() + end if id == "NATURE_POWER_EFFECT" and require("src.core.GameVersion").isGen3() then return base or require("src.battle.Gen3NaturePower").record() end @@ -14,6 +17,7 @@ end function M.install(registry, owner) if require("src.core.GameVersion").isGen3() then registry:register("NATURE_POWER_EFFECT", require("src.battle.Gen3NaturePower").record(), owner) + registry:register("ASSIST_EFFECT", require("src.battle.Gen3Assist").record(), owner) end end return M diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua index c5f3c3f9c..6e1f038e9 100644 --- a/src/battle/Gen3ChoiceBand.lua +++ b/src/battle/Gen3ChoiceBand.lua @@ -156,7 +156,7 @@ function C.call(ctx, pick, target, suppressAnimation, spreadContinuation, ppSlot error(err, 0) end ctx.user = parent and parent.user or ctx.user - local chained = call.frame and call.frame.nativeCalled or false + local chained = call.frame and (call.frame.nativeCalled or call.frame.nativeNoHistory) or false if not chained then ctx.user.lastMove = ctx.move.id end return chained end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index f9202a607..6e56d2e3b 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 14 or nil + return require("src.core.GameVersion").isGen3() and 15 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/tests/enemy_actions/assist.lua b/tests/enemy_actions/assist.lua new file mode 100644 index 000000000..526ea9edd --- /dev/null +++ b/tests/enemy_actions/assist.lua @@ -0,0 +1,547 @@ +-- Owned party/move definitions drive the actual native Assist dispatch. +local M = {} +local BLOCKED = + { 118, 165, 166, 102, 68, 243, 182, 197, 203, 194, 214, 168, 266, 289, 270, 343, 271, 264, 119, 274 } +local NAMES = { + [118] = "METRONOME", + [165] = "STRUGGLE", + [166] = "SKETCH", + [102] = "MIMIC", + [68] = "COUNTER", + [243] = "MIRROR_COAT", + [182] = "PROTECT", + [197] = "DETECT", + [203] = "ENDURE", + [194] = "DESTINY_BOND", + [214] = "SLEEP_TALK", + [168] = "THIEF", + [266] = "FOLLOW_ME", + [289] = "SNATCH", + [270] = "HELPING_HAND", + [343] = "COVET", + [271] = "TRICK", + [264] = "FOCUS_PUNCH", + [119] = "MIRROR_MOVE", + [274] = "ASSIST", +} +function M.fixture(root, backend, version, options) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/nature_power.lua").fixture(root, backend, version, options) + local function mount(n, name, effect, binding, power, target) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = n == 274 and 0 or 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[n], moves.BY_NUM[n], moves._numByName[name] = def, name, n + f.data.gen3Moves._rom[n], f.data.gen3Pokemon._moveNames[n] = def, name + else + local id = ("MOVE_%03d"):format(n) + f.data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = n == 274 and 0 or 51, + secondaryChance = 0, + } + end + end + mount(274, "ASSIST", 180, "ASSIST_EFFECT", 0, 1) + mount(10, "SCRATCH", 0, "NO_ADDITIONAL_EFFECT", 35, 0) + for _, n in ipairs(BLOCKED) do + if n ~= 274 and n ~= 214 then + mount( + n, + NAMES[n], + n == 118 and 83 or n == 119 and 9 or 0, + n == 118 and "METRONOME_EFFECT" or n == 119 and "MIRROR_MOVE_EFFECT" or "NO_ADDITIONAL_EFFECT", + 0, + 0 + ) + end + end + local field = f.field + f.field = function(double) + local b = field(double) + local player, enemy = { f.actor(0).mon }, { f.actor(1).mon } + if double then + player[2], enemy[2] = f.actor(2).mon, f.actor(3).mon + end + b.enemyPartyB = nil + b.playerParty = player + if native then + b.foeParty = enemy + for id = 0, double and 3 or 1 do + f.actor(id).partyIndex = id < 2 and 1 or 2 + end + else + b.enemyParty = enemy + end + return b + end + f.setMoves = function(mon, numbers, pp) + if native then + mon.moves, mon.pp, mon.maxPp = {}, {}, {} + for slot, n in pairs(numbers) do + mon.moves[slot], mon.pp[slot], mon.maxPp[slot] = n, pp or 10, 10 + end + else + mon.moves = {} + for slot, n in pairs(numbers) do + mon.moves[slot] = + { id = type(n) == "number" and (n == 0 and 0 or ("MOVE_%03d"):format(n)) or n, pp = pp or 10 } + end + end + end + f.party = function(b, user) + if native then + return user.side == "player" and b.playerParty or b.foeParty + end + if user.isPlayer then + b.playerParty = b.playerParty or b.game.save.party + return b.playerParty + end + b.enemyParty = b.enemyParty or { user.mon } + return b.enemyParty + end + return f +end +local function cases() + local out = { + { name = "empty" }, + { name = "own-only", own = true }, + { name = "alive", moves = { 33 } }, + { name = "fainted", moves = { 33 }, hp = 0 }, + { name = "egg", moves = { 33 }, egg = true }, + { name = "zero-species", moves = { 33 }, species = 0 }, + { name = "missing-species", moves = { 33 }, missing = true }, + { name = "zero-pp", moves = { 33 }, pp = 0 }, + { name = "duplicate-slots", moves = { 33, 10, 33, 89 } }, + { name = "sparse-slots", moves = { [4] = 33 } }, + { name = "fifth-slot", moves = { [5] = 33 } }, + { name = "duplicates-members", moves = { 33, 10, 33 }, extra = { 33, 89 } }, + { name = "native-caller", moves = { 267 } }, + { name = "active-partner", partner = true }, + { name = "split-owner", moves = { 33 }, extra = { 10 }, split = true }, + { name = "split-partner", partner = true, extra = { 89 }, split = true }, + } + for _, n in ipairs(BLOCKED) do + out[#out + 1] = { name = "blocked-" .. n, moves = { n } } + out[#out + 1] = { name = "filtered-" .. n, moves = { n, 33, n, 10 } } + end + return out +end +function M.run(root, backend, version, options) + local native = backend == "gen1" + options = options or {} + local f = M.fixture(root, backend, version, options) + local caller = native and 274 or "MOVE_274" + local custom + if options.public then + if native then + local g, m = require("src.mods.Gen3Compat"), require("src.core.game3.battle.moves") + caller, custom = assert(g.moveId("PORTABLE_ASSIST")), assert(g.moveId("PORTABLE_STRIKE")) + for _, name in ipairs({ "PORTABLE_ASSIST", "PORTABLE_STRIKE" }) do + local n = assert(g.moveId(name)) + m._rom[n] = assert(f.data.gen3Moves._rom[n]) + m.BY_NUM[n], m._numByName[name] = name, n + end + else + caller, custom = "PORTABLE_ASSIST", "PORTABLE_STRIKE" + end + end + local function history(value) + if not options.public or value == nil then + return value + 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 row.index == value then + return row.name + end + end + return value + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, case in + ipairs((function() + local list = cases() + if options.held then + while #list > 16 do + table.remove(list) + end + end + if options.public then + list[#list + 1] = { name = "custom-bench", moves = { custom } } + end + return list + end)()) + do + for _, kind in ipairs({ "ordinary", "pressure", "zero-caller-pp", "parent-bench-change" }) do + for _, pick in ipairs({ 1, 2, 3, 4, 5 }) do + local b = f.field(double) + b.terrain = 8 + if options.item then + f.put(f.actor(actor), options.item) + end + for id = 0, double and 3 or 1 do + local a = f.actor(id) + f.setMoves(a.mon, { 274 }) + if not native then + a.curMoves = a.mon.moves + end + end + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + local party = f.party(b, user) + local ownIndex + for i, mon in ipairs(party) do + if mon == user.mon then + ownIndex = i + end + end + assert(ownIndex, "party fixture preserves the actual active owner") + if native then + user.partyIndex = ownIndex + end + f.setMoves( + user.mon, + case.own and { caller, 33 } or { caller }, + kind == "zero-caller-pp" and 0 or 10 + ) + if not native then + user.curMoves = user.mon.moves + end + local function bench(numbers) + local mon = require("src.mods.Merge").deepCopy(user.mon) + mon.nickname = "BENCH" .. (#party + 1) + mon.hp = case.hp or 400 + mon.isEgg = case.egg or nil + if case.missing then + mon.species = nil + elseif case.species ~= nil then + mon.species = case.species + end + f.setMoves(mon, numbers, case.pp or 10) + party[#party + 1] = mon + return mon + end + if case.moves then + bench(case.moves) + end + if case.extra then + bench(case.extra) + end + if case.partner then + if double then + local ally = f.actor(actor < 2 and actor + 2 or actor - 2) + f.setMoves(ally.mon, { 33 }) + if not native then + ally.curMoves = ally.mon.moves + end + else + bench({ 33 }) + end + end + if case.split and not native and not user.isPlayer then + b.enemyParty, b.enemyPartyB = { party[1] }, {} + for i = 2, #party do + b.enemyPartyB[#b.enemyPartyB + 1] = party[i] + end + end + if kind == "pressure" then + for id = 0, double and 3 or 1 do + local a = f.actor(id) + if a ~= user then + if native then + a.ability = "PRESSURE" + else + a.abilityOverride = "PRESSURE" + end + end + end + end + local trace, events, damage, draws = {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + holder = c.mon.nickname, + item = c.item, + definition = c.def and c.def.id, + effect = c.effect, + parameter = c.parameter, + trigger = c.trigger, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function pp() + if native then + return user.mon.pp[1] + end + return user.curMoves[1].pp + end + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + move = e.move.id, + called = e.isCalled or false, + target = e.target.mon.nickname, + user = e.user.mon.nickname, + pp = pp(), + publicPP = e.user.mon.moves[1].pp, + } + if not e.isCalled and kind == "parent-bench-change" then + if #party == ownIndex then + bench({ 10 }) + else + f.setMoves(party[#party], { 10 }) + end + end + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + move = e.move.id, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local selector = 0 + local picked = false + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if lo == 1 and hi <= 24 and not picked then + value = math.min(pick, hi) + picked = true + elseif lo == 0 and hi == 3 then + value = selector % 4 + selector = selector + 1 + end + draws[#draws + 1] = { lo, hi, value, pp = pp() } + return value + end + local adapter = native and require("src.core.game3.battle.adapter").new(b) + if native then + require("src.core.game3.battle.engine").resolveMove(actor, targetId, caller, 1, adapter, b, {}) + else + b:executeAction(user, target, user.curMoves[1]) + end + local animations = {} + for _, event in ipairs(native and adapter:eventsSince(0) or b.queue) do + if native and event.kind == "move" or not native and event.anim then + local move = native and event.moveId + or f.data.moves[event.anim] and f.data.moves[event.anim].index + or event.anim + animations[#animations + 1] = { + move = history(move), + user = native and event.attackerId or event.attackerPosition, + target = native and event.targetId or event.targetPosition, + turn = native and event.turn or event.animContext and event.animContext.moveTurn or 0, + } + end + end + offHeld() + offCrit() + offMove() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local last = native and a.lastMoveId or a.lastMove + if not native and last then + last = f.data.moves[last] and f.data.moves[last].index or last + end + state[#state + 1] = { + hp = a.mon.hp, + status = a.mon.status, + last = history(last), + choice = history(a.choicedMove), + flinched = a.flinched or false, + } + end + local partyState = {} + for _, mon in ipairs(party) do + local pps = {} + for slot = 1, 5 do + local value + if native then + value = mon.pp[slot] + else + value = mon.moves[slot] and mon.moves[slot].pp + end + pps[slot] = value == nil and "absent" or value + end + partyState[#partyState + 1] = + { name = mon.nickname, hp = mon.hp, egg = mon.isEgg or false, pp = pps } + end + f.T.eq(#f.result.errors, 0, "Assist public callbacks stay error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + case = case.name, + kind = kind, + pick = pick, + item = options.item, + events = events, + trace = trace, + draws = draws, + damage = damage, + animations = animations, + state = state, + party = partyState, + } + end + end + end + end + end + f.close() + return rows +end +function M.content(root, backend, version) + return M.run(root, backend, version, { + public = true, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_ASSIST",{id="PORTABLE_ASSIST",name="PORTABLE_ASSIST",type="NORMAL",power=0,accuracy=100,pp=10,effect="ASSIST"})) + assert(mod.content.moves:register("PORTABLE_STRIKE",{id="PORTABLE_STRIKE",name="PORTABLE_STRIKE",type="NORMAL",power=35,accuracy=100,pp=10,effect="NO_ADDITIONAL_EFFECT"})) + local record=assert(mod.content.moves:get("PORTABLE_ASSIST"));assert(record.effect=="ASSIST") + mod.exports.api=mod + end]=], + }) +end +function M.held(root, backend, version) + local rows = {} + for _, item in ipairs({ 186, 187, 219 }) do + for _, row in ipairs(M.run(root, backend, version, { held = true, item = item })) do + rows[#rows + 1] = row + end + end + return rows +end +-- A native caller that returns before move-end must retain the old history, +-- including when a real outer Metronome dispatch selected that failed caller. +function M.failedCallers(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, previous in ipairs({ 0, 33, 10 }) do + for _, child in ipairs({ 274, 119 }) do + local b = f.field(double) + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + for id = 0, double and 3 or 1 do + f.setMoves(f.actor(id).mon, { 274 }) + end + f.setMoves(user.mon, { 118 }) + if native then + user.lastMoveId, user.lastMove = + previous ~= 0 and previous or nil, previous ~= 0 and previous or nil + b.moveMax, b.moveExcluded = child, {} + for n = 1, child do + if n ~= child then + b.moveExcluded[n] = true + end + end + else + user.curMoves = user.mon.moves + user.lastMove = previous ~= 0 and ("MOVE_%03d"):format(previous) or nil + f.data.constants.moveOrder = { ("MOVE_%03d"):format(child) } + end + local events, trace, draws, damage = {}, {}, {}, {} + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + move = e.move.id, + called = e.isCalled or false, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + pp = e.user.mon.moves[1].pp, + } + end) + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + trigger = c.trigger, + holder = c.mon.nickname, + item = c.item, + effect = c.effect, + parameter = c.parameter, + } + return nextRead(c) + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { move = e.move.id, damage = e.damage } + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, lo } + return lo + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + 118, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + offMove() + offHeld() + offDamage() + local last = native and user.lastMoveId or user.lastMove + if not native then + last = last and f.data.moves[last].index + end + rows[#rows + 1] = { + double = double, + actor = actor, + previous = previous, + child = child, + last = last or 0, + pp = native and user.mon.pp[1] or user.curMoves[1].pp, + events = events, + trace = trace, + draws = draws, + damage = damage, + } + f.T.eq(#f.result.errors, 0, "failed native caller callbacks have no errors") + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_assist.lua b/tests/engine/gen3_assist.lua new file mode 100644 index 000000000..e73455548 --- /dev/null +++ b/tests/engine/gen3_assist.lua @@ -0,0 +1,34 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").run(".", "gen2", version) + T.eq(#rows, 6720, "all native party, rejection, weighting, callback and PP cases execute") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "ASSIST", "selected caller announces its public identity") + T.eq(row.events[1].called, false, "selected caller is not a called move") + if row.kind == "zero-caller-pp" then + T.eq(#row.events, 1, "zero PP cannot admit any child") + T.eq(#row.draws, 0, "zero PP cannot choose a party move") + else + for i = 2, #row.events do + T.eq(row.events[i].called, true, "party moves announce native provenance") + T.eq( + row.events[i].pp, + row.kind == "pressure" and 8 or 9, + "Assist spends its own PP before any child event" + ) + end + end + end +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq( + require("src.battle.Gen3Builtins").record("ASSIST_EFFECT", nil), + nil, + "other generations gain no GBA Assist binding" + ) +end +V.set(before) +T.finish("native Assist weighted party candidates and execution") diff --git a/tests/engine/gen3_assist_content.lua b/tests/engine/gen3_assist_content.lua new file mode 100644 index 000000000..94e97efc5 --- /dev/null +++ b/tests/engine/gen3_assist_content.lua @@ -0,0 +1,17 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").content(".", "gen2", version) + T.eq(#rows, 6840, "same API-3 registered Assist includes registered bench moves") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "PORTABLE_ASSIST", "registered caller retains its public identity") + if row.case == "custom-bench" and row.kind ~= "zero-caller-pp" and row.kind ~= "parent-bench-change" then + T.eq( + row.events[2].move, + "PORTABLE_STRIKE", + "custom party move executes through its registered canonical effect" + ) + end + end +end +T.finish("API-3 Assist and registered party moves") diff --git a/tests/engine/gen3_assist_failed_callers.lua b/tests/engine/gen3_assist_failed_callers.lua new file mode 100644 index 000000000..5558bd482 --- /dev/null +++ b/tests/engine/gen3_assist_failed_callers.lua @@ -0,0 +1,19 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").failedCallers(".", "gen2", version) + T.eq(#rows, 36, "all positions and previous histories execute failed native children") + for _, row in ipairs(rows) do + T.eq(row.last, row.previous, "failed child preserves previous native move history") + T.eq(row.pp, 9, "only the selected outer Metronome spends PP") + T.eq(#row.events, 2, "both actual caller announcements remain public") + T.eq(row.events[1].move, "METRONOME", "outer announcement remains native") + T.eq( + row.events[2].move, + row.child == 274 and "ASSIST" or "MIRROR_MOVE", + "selected failed child announces" + ) + T.eq(#row.damage, 0, "failed child does no damage") + end +end +T.finish("native failed Assist and Mirror Move preserve caller history") diff --git a/tests/engine/gen3_assist_link.lua b/tests/engine/gen3_assist_link.lua new file mode 100644 index 000000000..cb6502812 --- /dev/null +++ b/tests/engine/gen3_assist_link.lua @@ -0,0 +1,145 @@ +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/assist.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 14 + T.eq(current.nativeBattleRevision, 15, "Assist commands advertise revision fifteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + 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 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_274", pp = 10 } } + game.save.party[2] = Copy(game.save.party[1]) + game.save.party[2].nickname = "BENCH" + game.save.party[2].moves = { { id = i == 1 and "MOVE_033" or "MOVE_267", pp = 10 } } + game.save.party[1].item = i == 1 and "ITEM_186" or "ITEM_219" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 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 got = inbox + inbox = {} + return got + 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 role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 10, "two turns include ordinary and nested party calls") + for i, event in ipairs(seen[battle] or {}) do + T.eq( + event.pp, + (i <= 5 and 10 or 9) - (event.called and 1 or 0), + "only parent announcement precedes the Assist PP command" + ) + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Assist party calls, events and PP in actual linked roles") diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index bdb7c0442..eb3dbfd55 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 13 -> 14 only. +-- Legacy hash retained; native hash independently audits revision 14 -> 15 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "cb314093f24370e7" +local NATIVE = "662d7e5ee823ce82" 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 14 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 15 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_nature_power_link.lua b/tests/engine/gen3_nature_power_link.lua index b1d0de4b6..bea2b4541 100644 --- a/tests/engine/gen3_nature_power_link.lua +++ b/tests/engine/gen3_nature_power_link.lua @@ -6,7 +6,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local current = H.hello(f.game, "battle") local old = Copy(current) old.nativeBattleRevision = 13 - T.eq(current.nativeBattleRevision, 14, "Nature Power commands advertise revision fourteen") + T.check(current.nativeBattleRevision >= 14, "Nature Power commands advertise at least revision fourteen") T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") for _, host in ipairs({ false, true }) do local state = setmetatable( diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 96756ee24..8efc81c4a 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,10 @@ MODES = [ + (3, 'assist'), + (3, 'assist_content'), + (3, 'assist_held'), + (3, 'assist_failed_callers'), (3, 'nature_power'), (3, 'nature_power_content'), (3, 'nature_power_chained'), diff --git a/tools/enemy-action-contract-check/imported-assist-smoke.lua b/tools/enemy-action-contract-check/imported-assist-smoke.lua new file mode 100644 index 000000000..6739b5387 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-assist-smoke.lua @@ -0,0 +1,185 @@ +-- Existing local imports, controlled battlers and actual public menu submissions. +-- No normal save or generated asset is written; no graphical acceptance is claimed. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local nested = arg[2] == "--nature-power" +local children = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for terrain = 0, 9 do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[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(fn)choice=fn end + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " local choice", + [=[ assert(mod.content.moves:register("PORTABLE_ASSIST",{id="PORTABLE_ASSIST",name="PORTABLE_ASSIST",type="NORMAL",power=0,accuracy=100,pp=10,effect="ASSIST"})) + local choice]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. 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, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + b.terrain = terrain + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_ASSIST" or id(274) + local publicParent = assert(f.e.api.content.moves:get(parent)) + assert(publicParent.effect == "ASSIST") + local benchNumber = nested and 267 or children[terrain] + local child = assert(f.e.api.content.moves:get(id(children[terrain]))) + local selected = parent + local publicSelected = assert(f.e.api.content.moves:get(selected)) + local learned = { { id = selected, pp = 10 }, { id = id(33), pp = 10 } } + b.player.mon.moves, b.player.curMoves = learned, learned + b.enemy.mon.moves = { { id = id(33), pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + for _, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.abilityOverride = false + end + b.player.mon.hp = 2000 + local bench = require("src.mods.Merge").deepCopy(b.player.mon) + bench.nickname = "BENCH" + bench.moves = { { id = id(benchNumber), pp = 0 } } + b.playerParty = { b.player.mon, bench } + b.enemyParty = { b.enemy.mon } + f.game.save.party = b.playerParty + + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, damage, draws = {}, {}, {} + local beforeFoe + f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + target = e.target.mon.nickname, + } + else + beforeFoe = b.player.mon.hp + end + end) + f.e.api.events:on("battle.damage_dealt", function(e) + if e.user.mon.nickname == "PLAYER" then + damage[#damage + 1] = e.damage + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + 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 snapshot = assert(f.e.api.battle:snapshot()) + local ok, why = + f.e.api.battle:submit({ kind = "move", slot = 1, id = 1, revision = snapshot.revision }) + assert(ok, 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 + assert(b.phase == "menu" and b.turnCount == 1, "actual submitted imported turn must finish") + assert(#used == (nested and 3 or 2) and used[1].move == publicSelected.id and not used[1].called) + local final = used[#used] + assert( + final.move == child.id and final.called, + "actual terrain child uses advertised public identity" + ) + assert(used[1].pp == 10, "selected announcement precedes its PP command") + assert(final.pp == 9, "Assist spends its own PP before every called event") + if nested then + assert( + used[2].move == "NATURE_POWER" and used[2].called and used[2].pp == 9, + "native nested Nature Power borrows no PP from the selected Assist" + ) + end + local history = nested and id(267) or parent + assert(b.player.lastMove == history, "innermost native caller preserves move history") + if itemNumber == 186 then + assert( + b.player.choicedMove + == (not nested and require("src.battle.Gen3ChoiceBand").identity(b, history) or nil), + "Choice Band clears an unlearned inner caller and retains learned Assist" + ) + end + assert( + learned[1].pp == 9 and learned[2].pp == 10 and bench.moves[1].pp == 0, + "only selected Assist spends PP, including a zero-PP party child" + ) + local healing = itemNumber == 219 and damage[1] and math.max(1, math.floor(damage[1] / 8)) or 0 + assert(beforeFoe == 2000 + healing, "native move-end healing completes before the next actor") + if terrain == 0 then + assert(b.enemy.mon.status == "PAR" and #damage == 0, "actual Stun Spore status handler executes") + else + assert(#damage > 0 and damage[1] > 0, "actual imported damaging child executes") + end + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration remain error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + nested = nested, + terrain = terrain, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + pp = learned[1].pp, + beforeFoe = beforeFoe, + } + f.close() + end + end + end +end +print("IMPORTED_ASSIST_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 5f1c455ca..f476d0648 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 == "nature_power_chained" then +if mode == "assist_held" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").held(root, backend, version) +elseif mode == "assist_failed_callers" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").failedCallers(root, backend, version) +elseif mode == "assist_content" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").content(root, backend, version) +elseif mode == "assist" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").run(root, backend, version) +elseif mode == "nature_power_chained" then rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").chained(root, backend, version) elseif mode == "nature_power_content" then rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").content(root, backend, version) @@ -17,11 +25,13 @@ elseif mode == "move_used_pp" then elseif 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 }) + 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 }) + 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 @@ -53,7 +63,12 @@ elseif mode == "party_codec_hooks" then 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") + 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 From 5c71253f37466b49f56f0b7eb77ed4e7da65c7a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:24:26 +0100 Subject: [PATCH 106/122] Record native Assist validation and workflow coverage --- .github/workflows/enemy-action-contract.yml | 1 + docs/native-gen3-assist.md | 43 ++++++++++ docs/qa/native-gen3-assist.json | 93 +++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 docs/qa/native-gen3-assist.json diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 1166be083..5ea5832e8 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -35,6 +35,7 @@ on: - 'tests/engine/gen3_move_used*.lua' - 'tests/engine/gen3_post_hit*.lua' - 'tests/engine/gen3_nature_power*.lua' + - 'tests/engine/gen3_assist*.lua' - 'tests/post_hit_items/**' - 'tests/held_item/**' - 'tests/charge_required/**' diff --git a/docs/native-gen3-assist.md b/docs/native-gen3-assist.md index 180be45b0..91bbd5246 100644 --- a/docs/native-gen3-assist.md +++ b/docs/native-gen3-assist.md @@ -54,3 +54,46 @@ These checks use owned controlled definitions and separate existing imported FireRed/Emerald data. They do not establish whole-catalog Metronome parity, visual animation parity, arbitrary mod compatibility or a complete playthrough. Final acceptance counts and retained negative evidence are recorded with the PR. + +## Accepted source-bound results + +The combined runtime passes **275/275 engine suites**, **3/3 SDK suites**, B30 +and every quick tier. The isolated feature passes **157/166** and retains the +exact 25 inherited FAIL lines across the same nine suites as fresh #252 +(**153/162**). No additional failure is accepted. + +Each runtime passes **188 enemy/action workers / 160,724 identical paired +observations**, including **38,712 new Assist observations**. All workers exit +zero without timeout. The ordinary-damage and held-command comparisons add +**8 workers / 8,952 rows**, for **196 workers / 169,676 selected paired rows**. +Four held-item formats pass strict native schedules (**40 workers / 1,914 +rows**); charge contracts pass four formats (**64 workers / 1,020 rows**), with +strict raw native policy on the combined runtime. + +Actual linked host/guest/spectator menus pass **120 checks** across two turns +per profile, retaining every event and paired completed hash/state component. +Existing FireRed/Emerald imports pass **160 controlled public-submit scenarios** +with native/registered Assist, direct party children and nested Nature Power, +Choice Band and Shell Bell. The result queue returns to its native menu. + +The unchanged parent differs in **4,860/6,480** complete native rows and all +**36/36** selected failed-caller histories; public `ASSIST` registration is +rejected there. Initial fixture failures, the incorrect local PP assertion, +the too-strict imported Choice Band expectation, superseded observations and +the first native-targeting RNG difference remain retained as negative evidence. + +See [the receipt](qa/native-gen3-assist.json) for tested refs and raw-log digests. +Reproduce the portable comparison with the pinned LuaJIT and clean reference: + +```sh +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The actual-import CPU scenarios additionally accept an existing cache root: + +```sh +luajit tools/enemy-action-contract-check/imported-assist-smoke.lua /path/to/cache +luajit tools/enemy-action-contract-check/imported-assist-smoke.lua /path/to/cache --nature-power +``` diff --git a/docs/qa/native-gen3-assist.json b/docs/qa/native-gen3-assist.json new file mode 100644 index 000000000..638c81758 --- /dev/null +++ b/docs/qa/native-gen3-assist.json @@ -0,0 +1,93 @@ +{ + "feature": { + "runtime_ref": "dc7e13c22eb297fa345b4e92f7a9d5a5a01b8ecd", + "engine": "157/166", + "fail_counter_lines": 25, + "sdk": "3/3", + "oracle_workers": 188, + "oracle_rows": 160724, + "selected_paired_workers": 196, + "selected_paired_rows": 169676, + "new_assist_rows": { + "assist": 13440, + "assist_content": 13680, + "assist_held": 11520, + "assist_failed_callers": 72 + }, + "strict_contracts": { + "held-item": { + "workers": 40, + "rows": 1914 + }, + "charge-required": { + "workers": 64, + "rows": 1020 + } + }, + "imported_scenarios": { + "imported-assist": 80, + "imported-assist-nested": 80 + }, + "oracle_sha256": "b9959b60be923020b9ed42baef3f19dc8af48039fe6abed8da2b94b39629ccab", + "full_gate_sha256": "91a20b9ad104feb74c60ffaefa913803644b3691cb67245a5863bdfa68d901f9" + }, + "combined": { + "runtime_ref": "5e384409322e98dfd292716b9c97d2bdcdf9936e", + "engine": "275/275", + "fail_counter_lines": 0, + "sdk": "3/3", + "oracle_workers": 188, + "oracle_rows": 160724, + "selected_paired_workers": 196, + "selected_paired_rows": 169676, + "new_assist_rows": { + "assist": 13440, + "assist_content": 13680, + "assist_held": 11520, + "assist_failed_callers": 72 + }, + "strict_contracts": { + "held-item": { + "workers": 40, + "rows": 1914 + }, + "charge-required": { + "workers": 64, + "rows": 1020 + } + }, + "imported_scenarios": { + "imported-assist": 80, + "imported-assist-nested": 80 + }, + "oracle_sha256": "08131511b25504d4371855de52d314610c1cc5020df4ed252cedb9279ef306f4", + "full_gate_sha256": "b2119bb37065b4d59eda6181e005903bd6edb0eb7702223e4ba20a577b2cf29d" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "prerequisite_ref": "812ec1f2354d4f98a5f4ebff15876120743ea7a9", + "native_command_revision": 15, + "party_codec_revision": 1, + "native_digest": "662d7e5ee823ce82", + "legacy_digest": "e33d251961f2a41d", + "old_parent_negative": { + "native_rows": 6480, + "native_different": 4860, + "failed_caller_rows": 36, + "failed_history_different": 36 + }, + "old_public_registration": "unsupported: effect has no native handler: ASSIST", + "native_linked_checks": 120, + "assist_direct_checks": 60846, + "assist_public_checks": 27482, + "assist_failed_caller_checks": 506, + "independent_hash_audit_sha256": "8925ad25ff57739b2ddd91486ff95e1c9542922902cf642586af91a8411bbbbc", + "frozen_parent_serializer_sha256": "688baad34c546f3c6fb77727e6f2e10ba1a72fb30067323d75f6a3307b93a048", + "raw_failed_and_superseded_evidence_retained": true, + "scope": "Selected native GBA Assist/party/PP/history/targeting contracts and API-3 registration; owned native-shaped definitions plus separate actual-import CPU menu scenarios; no whole-catalog, graphical, full-mod or full-playthrough acceptance", + "negative_evidence_sha256": { + "animations-and-failed.json": "2139a0a918383cb089e18958b90038b5ef1b38a477253667025cf845e2b9cd8a", + "parent-native-negative.log": "7960e5db4b9061f3bc5b48fe9cf4ee45f463272d9dc92b6a60e8e7efc469fd4e", + "parent-failed-callers-negative.log": "04241920b057c3ce7684704915b72764e73179ee4f75f042f4e28dcf8f0ba8e1", + "parent-public-negative.log": "5225ec67f4ceefb099a0423f18432e30395d2e8baf278bde33f65844ce1dd4eb" + } +} From a3a80a193328b8261e572304b5cfd8aae93fb2ef Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:03:12 +0100 Subject: [PATCH 107/122] Develop native GBA Mirror Move received-history contract --- docs/native-gen3-mirror-move.md | 44 +++ src/battle/BattleState.lua | 5 +- src/battle/EffectRegistry.lua | 3 + src/battle/GbaItemSchedule.lua | 1 + src/battle/Gen3Builtins.lua | 1 + src/battle/Gen3MirrorMove.lua | 50 +++ src/link/Fingerprint.lua | 2 +- src/link/LinkBattle.lua | 1 + tests/enemy_actions/mirror_move.lua | 322 +++++++++++++++++++ tests/engine/gen3_assist_link.lua | 2 +- tests/engine/gen3_battle_protocol.lua | 6 +- tools/enemy-action-contract-check/check.py | 3 + tools/enemy-action-contract-check/worker.lua | 8 +- 13 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 docs/native-gen3-mirror-move.md create mode 100644 src/battle/Gen3MirrorMove.lua create mode 100644 tests/enemy_actions/mirror_move.lua diff --git a/docs/native-gen3-mirror-move.md b/docs/native-gen3-mirror-move.md new file mode 100644 index 000000000..721c4d44e --- /dev/null +++ b/docs/native-gen3-mirror-move.md @@ -0,0 +1,44 @@ +# Native GBA Mirror Move work in progress + +This development branch builds on Gen2Recomped #258. It is not yet an accepted +PR or a replacement for the combined testing branch. The unchanged Gen1 +reference is read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented gameplay path + +GBA Mirror Move reads the recipient's received-move history at its native +command, instead of the current target's last selected action. Native move-end +updates this history using the chosen caller's mirror flag after item commands. +Misses, protection, immunities and fainted recipients do not replace it. +Called moves use the existing native child targeting and PP handling. A +registered Mirror Move has no caller animation of its own. The new volatile +history participates in native link signatures; command revision 16 rejects +older semantics. Party codec revision stays 1. The frozen parent serializer +with only the revision token changed independently yields `b251a4d1054d6555`; +legacy game digests remain `e33d251961f2a41d`. + +## Evidence and remaining work + +The original 80 command sequences had 68 differing full observations on #258. +The first implementation matches all 80, retaining complete captured events, +held reads, RNG, PP, damage, actor state and move-animation metadata. + +The expanded native, API-3 registered and Metronome -> registered Mirror Move +matrices execute 1,920 paired observations. All workers exit zero. The complete +comparison is still **failed**: 144 observations differ in Substitute animation +commands. These differences are retained without removing rows or fields. +Actual default animation playback and Substitute transitions must be addressed +before accepting the wider matrix or publishing this PR. Broader history +command conditions, linked-role tests, actual-import scenarios and full +regression checks also remain required. + +Both public APIs reject raw `flags` fields and new writable `native` diagnostic +envelopes. The fixture's initially invalid registrations are retained as failed +observations; the valid registration uses only public fields. Imported/native +moves retain their flags. Newly registered incoming moves with default metadata +are tested separately; this branch does not invent a public flag-writing API. + +Raw successes, negative controls, fixture corrections and failed expanded +comparisons are kept in the ignored local `tmp/mirror-move-qa/` directory. +The fresh #258 baseline remains 157/166 engine suites with the same nine inherited +failing suites; this branch has not yet completed its full regression gate. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b404eab2d..5ffc08aa2 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5205,7 +5205,9 @@ end -- 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) + local hit, reason = require("src.battle.Gen3Accuracy").check(self, move, user, target, accuracyRaw) + if not hit and self._gbaMoveFrame then self._gbaMoveFrame.missed = true end + return hit, reason end -- LOCK-ON / MIND READER: the next move cannot miss, and the aim is spent -- whether it was needed or not. @@ -8310,6 +8312,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni -- happen before the effect record is consulted at all. if target.protecting and move.power == 0 and move.protectAffected ~= false and record and record.kind == "primary" then + if GameVersion.isGen3() and self._gbaMoveFrame then self._gbaMoveFrame.missed = true end self:cancelMoveAnim() self:sayNext(Strings("%s\nprotected itself!", displayName(target))) return diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index fa03f6ab5..73cc45210 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -144,6 +144,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) 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 + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.missed = true end battle:cancelMoveAnim() if record and record.onProtect and record.onProtect(ctx) then return end battle:sayNext(Strings("%s\nprotected itself!", displayName(target))) @@ -257,6 +258,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if info.typeMult == 0 and info.ability then battle.arenaOutcome.ability = true end end if info.typeMult == 0 then + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.noEffect = true end -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -274,6 +276,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) return end if info.missed then + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.missed = true end -- 0.25x floored the damage to zero: the original registers a miss if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua index 7e6949126..9502b4e94 100644 --- a/src/battle/GbaItemSchedule.lua +++ b/src/battle/GbaItemSchedule.lua @@ -42,6 +42,7 @@ function Schedule.moveEnd(battle, completedFrame) battle:holdItemTrigger(b, "moveEnd") end require("src.battle.Gen3PostHitItems").run(battle, frame) + require("src.battle.Gen3MirrorMove").history(battle, frame) end function Schedule.afterAction(battle) diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua index ae1ac34e9..784d7de42 100644 --- a/src/battle/Gen3Builtins.lua +++ b/src/battle/Gen3Builtins.lua @@ -11,6 +11,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.Gen3MirrorMove").builtin(id, base) base = require("src.battle.Gen3ChoiceBand").builtin(id, base) return require("src.battle.Gen3SleepTalk").builtin(id, base) end diff --git a/src/battle/Gen3MirrorMove.lua b/src/battle/Gen3MirrorMove.lua new file mode 100644 index 000000000..de9acdae7 --- /dev/null +++ b/src/battle/Gen3MirrorMove.lua @@ -0,0 +1,50 @@ +-- GBA Mirror Move reads the recipient's native move-end history, not the +-- current target's last selected action. Only qualifying receipts replace it. +local M = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +function M.pick(ctx) + local previous = ctx.user.gbaLastTakenMove + if previous and previous ~= 0 then + local id = require("src.battle.EnemyMoveChoice").resolveMove(ctx.data, previous) + if id then + return id + end + end + ctx.say(Strings("The MIRROR MOVE\nfailed!")) +end +function M.builtin(id, base) + if not V.isGen3() or id ~= "MIRROR_MOVE_EFFECT" or not base 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.callsMove, out.announceAnim = M.pick, false + cache[base] = out + return out +end +function M.history(battle, frame) + if not V.isGen3() or not frame then + return + end + local user, target = frame.user, frame.target + if not user or not target or target == user then + return + end + local chosen = frame.calledBy or frame.moveId + local move = frame.calledBy and battle.data.moves[frame.calledBy] or frame.move + local eligible = move and math.floor((tonumber(move.flags) or 0) / 16) % 2 == 1 + if not frame.noEffect and not frame.missed and eligible and target.mon.hp > 0 then + target.gbaLastTakenMove = chosen + end +end +function M.signature(user) + return ":mirror=" .. tostring(user.gbaLastTakenMove) +end +return M diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 6e56d2e3b..22d2607d7 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 15 or nil + return require("src.core.GameVersion").isGen3() and 16 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 1fb261333..363d3a1d2 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -167,6 +167,7 @@ local function activeStr(b, role) .. (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 "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3MirrorMove").signature(b) or "") end local function benchStr(party) diff --git a/tests/enemy_actions/mirror_move.lua b/tests/enemy_actions/mirror_move.lua new file mode 100644 index 000000000..37ed7e1f9 --- /dev/null +++ b/tests/enemy_actions/mirror_move.lua @@ -0,0 +1,322 @@ +-- Owned command sequences exercise unchanged native received-move history. +local M = {} +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version, options) + local rows = {} + local caller, custom = native and 119 or "MOVE_119", nil + if options.public then + if native then + local compat, moves = require("src.mods.Gen3Compat"), require("src.core.game3.battle.moves") + caller, custom = assert(compat.moveId("PORTABLE_MIRROR")), assert(compat.moveId("PORTABLE_STRIKE")) + for _, name in ipairs({ "PORTABLE_MIRROR", "PORTABLE_STRIKE" }) do + local n = assert(compat.moveId(name)) + moves._rom[n] = assert(f.data.gen3Moves._rom[n]) + moves.BY_NUM[n], moves._numByName[name] = name, n + end + else + caller, custom = "PORTABLE_MIRROR", "PORTABLE_STRIKE" + end + end + local selected = options.outer and (native and 118 or "MOVE_118") or caller + local function definition(n) + return native and f.data.gen3Moves._rom[n] or f.data.moves[("MOVE_%03d"):format(n)] + end + definition(119).flags = 0 + if not native then + definition(119).mirrorMoveAffected = false + end + local function number(value) + if options.public and value ~= nil then + 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 row.index == value then + return row.name + end + end + return value + end + if native or value == nil then + return value + end + local def = f.data.moves[value] + return def and def.index or value + end + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, kind in ipairs({ + "none", + "hit", + "unreflectable", + "miss", + "miss-first", + "callback-change", + "other-target", + "protected", + "substitute", + "immune", + "flag-change", + "zero-pp", + "pressure", + "custom-incoming", + }) do + if (kind ~= "other-target" or double) and (kind ~= "custom-incoming" or options.public) then + for _, item in ipairs({ 0, 186, 187, 219 }) do + local b = f.field(double) + definition(33).flags = 51 + definition(118).flags = 0 + if not native then + definition(33).mirrorMoveAffected = true + end + if options.outer then + if native then + b.moveMax, b.moveExcluded = caller, {} + for n = 1, caller do + if n ~= caller then + b.moveExcluded[n] = true + end + end + else + f.data.constants.moveOrder = { caller } + end + end + local user = f.actor(actor) + local foeId = actor % 2 == 0 and actor + 1 or actor - 1 + local foe = f.actor(foeId) + for id = 0, double and 3 or 1 do + local a = f.actor(id) + f.setMoves(a.mon, { selected, kind == "custom-incoming" and custom or 33, 10 }) + if not native then + a.curMoves = a.mon.moves + end + end + f.put(user, item ~= 0 and item or nil) + if kind == "pressure" then + if native then + foe.ability = "PRESSURE" + else + foe.abilityOverride = "PRESSURE" + end + elseif kind == "substitute" then + user.substituteHP = 100 + end + local scratch = definition(10) + scratch.type = native and 0 or "NORMAL" + scratch.flags, scratch.accuracy = kind == "unreflectable" and 35 or 51, 100 + if not native then + scratch.mirrorMoveAffected = kind ~= "unreflectable" + end + local step = "setup" + local trace, events, damage, draws, animations = {}, {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + step = step, + holder = c.mon.nickname, + item = c.item, + effect = c.effect, + parameter = c.parameter, + definition = c.def and c.def.id, + trigger = c.trigger, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + local pp = {} + for i = 1, 3 do + pp[i] = e.user.mon.moves[i].pp + end + events[#events + 1] = { + step = step, + move = e.move.id, + called = e.isCalled or false, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + pp = pp, + } + if step == "copy" and not e.isCalled and kind == "flag-change" then + local changed = definition(33) + changed.flags = 35 + if not native then + changed.mirrorMoveAffected = false + end + end + if step == "copy" and not e.isCalled and kind == "callback-change" then + if native then + foe.lastMoveId, foe.lastMove = 10, 10 + else + foe.lastMove = "MOVE_010" + end + end + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + step = step, + move = e.move.id, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local targetRoll = 0 + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and (step == "miss" and 100 or 1) or hi + if lo == 0 and hi == 3 then + value = targetRoll % 4 + targetRoll = targetRoll + 1 + end + draws[#draws + 1] = { step = step, lo = lo, hi = hi, value = value } + return value + end + local function act(attackerId, targetId, slot, label) + step = label + local a, t = f.actor(attackerId), f.actor(targetId) + local adapter = native and require("src.core.game3.battle.adapter").new(b) + if native then + require("src.core.game3.battle.engine").resolveMove( + attackerId, + targetId, + a.mon.moves[slot], + slot, + adapter, + b, + {} + ) + else + b:executeAction(a, t, a.curMoves[slot]) + end + for _, e in ipairs(native and adapter:eventsSince(0) or b.queue) do + if native and (e.kind == "move" or e.kind == "anim") or not native and e.anim then + local ref = native and (e.kind == "move" and e.moveId or e.name) or e.anim + local isMove = native and e.kind == "move" or not native and f.data.moves[e.anim] ~= nil + animations[#animations + 1] = { + step = step, + kind = isMove and "move" or "anim", + move = isMove and number(ref) or ref, + user = native and e.attackerId or e.attackerPosition, + target = native and e.targetId or e.targetPosition, + turn = native and e.turn or e.animContext and e.animContext.moveTurn or 0, + } + end + end + if not native then + b.queue, b.nextInsert = {}, 0 + end + end + if kind ~= "none" and kind ~= "miss-first" then + act(foeId, actor, 2, "incoming") + end + if kind == "miss" or kind == "miss-first" then + scratch.accuracy = 50 + act(foeId, actor, 3, "miss") + scratch.accuracy = 100 + elseif kind == "unreflectable" then + act(foeId, actor, 3, "unreflectable") + elseif kind == "protected" then + if native then + user.expProtected = true + else + user.protecting = true + end + act(foeId, actor, 3, "protected") + user.expProtected, user.protecting = nil, nil + elseif kind == "immune" then + if native then + user.type1, user.type2 = 7, 7 + else + user.curTypes = { "GHOST" } + f.data.type_chart.matchups[#f.data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + f.data.type_chart.types.GHOST = { id = "GHOST", index = 7, category = "physical" } + require("src.battle.TypeChart").load(f.data) + end + act(foeId, actor, 3, "immune") + if native then + user.type1, user.type2 = 0, 0 + else + user.curTypes = { "NORMAL" } + end + elseif kind == "other-target" then + act(foeId, actor < 2 and actor + 2 or actor - 2, 3, "other-target") + end + if kind == "zero-pp" then + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + end + act(actor, foeId, 1, "copy") + offHeld() + offCrit() + offMove() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pp = {} + for i = 1, 3 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + state[#state + 1] = { + hp = a.mon.hp, + status = a.mon.status, + last = number(native and a.lastMoveId or a.lastMove), + received = number(native and a.expLastTakenMove or a.gbaLastTakenMove), + substituteHP = a.substituteHP or 0, + pp = pp, + choice = number(a.choicedMove), + flinched = a.flinched or false, + } + end + f.T.eq(#f.result.errors, 0, "received-move callbacks stay error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + case = kind, + item = item, + outer = options.outer or false, + state = state, + trace = trace, + events = events, + damage = damage, + draws = draws, + animations = animations, + } + end + end + end + end + end + f.close() + return rows +end +function M.content(root, backend, version, outer) + return M.run(root, backend, version, { + public = true, + outer = outer, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_MIRROR",{id="PORTABLE_MIRROR",name="PORTABLE_MIRROR",type="NORMAL",power=0,accuracy=100,pp=10,effect="MIRROR_MOVE_EFFECT"})) + assert(mod.content.moves:register("PORTABLE_STRIKE",{id="PORTABLE_STRIKE",name="PORTABLE_STRIKE",type="NORMAL",power=35,accuracy=100,pp=10,effect="NO_ADDITIONAL_EFFECT"})) + assert(mod.content.moves:get("PORTABLE_MIRROR").effect=="MIRROR_MOVE_EFFECT") + mod.exports.api=mod + end]=], + }) +end +function M.chained(root, backend, version) + return M.content(root, backend, version, true) +end +return M diff --git a/tests/engine/gen3_assist_link.lua b/tests/engine/gen3_assist_link.lua index cb6502812..b55e949a0 100644 --- a/tests/engine/gen3_assist_link.lua +++ b/tests/engine/gen3_assist_link.lua @@ -6,7 +6,7 @@ for _, version in ipairs({ "firered", "emerald" }) do local current = H.hello(f.game, "battle") local old = Copy(current) old.nativeBattleRevision = 14 - T.eq(current.nativeBattleRevision, 15, "Assist commands advertise revision fifteen") + T.check(current.nativeBattleRevision >= 15, "Assist commands advertise at least revision fifteen") T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") for _, host in ipairs({ false, true }) do local state = setmetatable( diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index eb3dbfd55..c578ed076 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 14 -> 15 only. +-- Legacy hash retained; native hash independently audits revision 15 -> 16 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "662d7e5ee823ce82" +local NATIVE = "b251a4d1054d6555" 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 15 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 16 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 8efc81c4a..65f1ab684 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,9 @@ MODES = [ + (3, 'mirror_move'), + (3, 'mirror_move_content'), + (3, 'mirror_move_chained'), (3, 'assist'), (3, 'assist_content'), (3, 'assist_held'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index f476d0648..7086842b1 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 == "assist_held" then +if mode == "mirror_move_content" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").content(root, backend, version) +elseif mode == "mirror_move_chained" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").chained(root, backend, version) +elseif mode == "mirror_move" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").run(root, backend, version) +elseif mode == "assist_held" then rows = dofile(root .. "/tests/enemy_actions/assist.lua").held(root, backend, version) elseif mode == "assist_failed_callers" then rows = dofile(root .. "/tests/enemy_actions/assist.lua").failedCallers(root, backend, version) From 2f6ae7888d8250ddb7d61f484c003d7ad52d5824 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:22:22 +0100 Subject: [PATCH 108/122] Fix Mirror Move recipient history in held-item scheduling fixture --- tests/held_item/scheduling.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/held_item/scheduling.lua b/tests/held_item/scheduling.lua index 35fa60f53..782044723 100644 --- a/tests/held_item/scheduling.lua +++ b/tests/held_item/scheduling.lua @@ -236,7 +236,7 @@ function M.run(f) if f.backend == "gen1" then f.battle.player.expLastTakenMove = 33 else - f.battle.enemy.lastMove = f.ids.TACKLE + f.battle.player.gbaLastTakenMove = f.ids.TACKLE end f.resolve("MIRROR_MOVE", true) observe( From 63084ecaffda7c80d4508ab2b9a911eaee096a37 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:36:25 +0100 Subject: [PATCH 109/122] Add native Substitute animation commands and controller contract coverage --- docs/native-gen3-substitute-presentation.md | 56 ++++++ src/battle/BattleState.lua | 34 +++- src/battle/EffectRegistry.lua | 4 + src/battle/Gen3MoveAnim.lua | 4 +- src/battle/Gen3SubstitutePresentation.lua | 170 ++++++++++++++++ tests/enemy_actions/multi_hit.lua | 30 ++- .../enemy_actions/substitute_presentation.lua | 184 ++++++++++++++++++ tests/engine/gen3_substitute_presentation.lua | 58 ++++++ tools/enemy-action-contract-check/check.py | 2 + tools/enemy-action-contract-check/worker.lua | 6 +- 10 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 docs/native-gen3-substitute-presentation.md create mode 100644 src/battle/Gen3SubstitutePresentation.lua create mode 100644 tests/enemy_actions/substitute_presentation.lua create mode 100644 tests/engine/gen3_substitute_presentation.lua diff --git a/docs/native-gen3-substitute-presentation.md b/docs/native-gen3-substitute-presentation.md new file mode 100644 index 000000000..ec5645c2c --- /dev/null +++ b/docs/native-gen3-substitute-presentation.md @@ -0,0 +1,56 @@ +# Native GBA Substitute presentation work in progress + +This branch builds on the unpublished Mirror Move development at +`2f6ae7888d8250ddb7d61f484c003d7ad52d5824`. It is not an accepted PR or the +combined playable testing branch. Gen1 remains a clean read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented + +Surviving move-animation rows receive their native Substitute-to-Pokemon and +Pokemon-to-Substitute commands. Each called move owns its own animation group. +Ordinary multi-hit moves lower once and restore only when the native last-hit +command is reached; early termination does not invent a restore command. + +The actual battle queue executes the offscreen exchange controller, including +five-pixel movement ticks, the separate exchange tick and the return trip. +Presentation is independent of gameplay Substitute HP. Animation options retain +the Transform/Substitute exceptions. Skipped commands add no waiting tick. +The renderer's sprite selection and native OAM Y consult that presentation; +queue completion clears its transient state. Legacy battles retain their path. + +## Evidence and open gates + +- The first 1,920 complete Mirror Move observations now match unchanged Gen1, + including all previously differing 144 animation command streams. +- The native sequencer/VM and actual host queue agree on all 360 controller + inputs and all 5,240 observed ticks. Both versions cover all six single/double + battler slots, animation options, ordinary/Transform/Substitute contexts, + lowering, restoration, missing protection and broken protection. +- Controller inputs use owned native-shaped animation pack and move records. + Restoration preparation executes the preceding move through the native VM + before observation begins at the actual restoration command. No Source + functions are replaced. CPU graphics doubles launch no game or GPU preview. +- The expanded multi-hit contract retains all existing state, events and RNG, + and adds the complete move/general animation stream. All 11,520 actions + execute, but **1,344 observations still differ** in animation commands: + Substitute fade, Focus Band and poison presentation. Those differences and + every failed/superseded fixture report remain in local ignored QA output. + +The native GBA Substitute doll artwork/import path still needs implementation. +The current legacy doll renderer is not graphical acceptance. Full native +artwork/positioning, general/status animation playback, broader regression and +Mirror Move history/link/actual-import gates remain open. No PR is published +and the stable testing branch is unchanged at this checkpoint. + +Reproduce the complete paired CPU contracts with: + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode substitute_multi_hit --mode substitute_presentation \ + --mode mirror_move --mode mirror_move_content --mode mirror_move_chained \ + --report /path/to/new-report.json +``` + +Local raw observations: `tmp/substitute-presentation-qa/`. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5ffc08aa2..de69b56ac 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2585,6 +2585,8 @@ function BattleState:updateQueue() if self.game.stack:top() ~= self then return true end self.waitingUI = nil end + if self._gbaSubstituteTransition + and require("src.battle.Gen3SubstitutePresentation").update(self) then return true end -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2704,7 +2706,16 @@ function BattleState:updateQueue() end if not self.current then local item = table.remove(self.queue, 1) - if not item then return false end + while item and item.gbaSubstituteSpecial do + if require("src.battle.Gen3SubstitutePresentation").start(self, item) then return true end + item = table.remove(self.queue, 1) + end + if not item then + if self._gbaSubstitutePresentation then + require("src.battle.Gen3SubstitutePresentation").reconcile(self) + end + return false + end if item.fn then self.nextInsert = 0 -- sayNext inserts right after this item item.fn() @@ -8286,6 +8297,7 @@ local function performMove(battle, user, target, moveInst, isCalled, suppressAni attackerPosition = user.position, targetPosition = target and target.position, animContext = animContext } + require("src.battle.Gen3SubstitutePresentation").mark(self, self.moveAnimRow, user) table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end @@ -8496,6 +8508,7 @@ 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) + require("src.battle.Gen3SubstitutePresentation").finish(self, frame) 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 @@ -11397,6 +11410,8 @@ end -- to happen where the mon is drawn rather than as a particle laid over it -- -- which is why it is here and not in Gen3MoveAnim's own draw. function BattleState:gen3AnimShake(battler) + local substituteX = GameVersion.isGen3() and battler + and require("src.battle.Gen3SubstitutePresentation").offset(self, battler) or 0 -- SINNOH'S ANIMATOR SPEAKS THE SAME THREE METHODS, which is why -- it plugs in here rather than bringing a second draw path: a -- second one would have none of the grow-in scale, hit shake or @@ -11408,11 +11423,18 @@ function BattleState:gen3AnimShake(battler) return 0, 0 end if not (self.gen3AnimPlaying and self.gen3Anim and battler) then - return 0, 0 + return substituteX, 0 end local ok, dx, dy = pcall(self.gen3Anim.monOffset, self.gen3Anim, battler) - if not ok then return 0, 0 end - return dx or 0, dy or 0 + if not ok then return substituteX, 0 end + return (dx or 0) + substituteX, dy or 0 +end + +function BattleState:substituteVisible(battler) + if GameVersion.isGen3() then + return require("src.battle.Gen3SubstitutePresentation").showing(self, battler) + end + return battler and battler.substituteHP end -- ...AND WHICH HBLANK ROW TRANSFORM ITS COPIED BG IS USING. @@ -11732,7 +11754,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) -- copies rather than being hidden behind them local ghosts = self:gen3AnimGhosts(battler) if ghosts and not battler.fainted and not self:fxFaintActive(battler) - and not battler.substituteHP then + and not self:substituteVisible(battler) then local img = self:battlerPic(battler) if img then local cr, cg, cb, ca = love.graphics.getColor() @@ -11746,7 +11768,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if battler.substituteHP and not self:fxFaintActive(battler) + if self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 73cc45210..924dc6a8e 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -371,6 +371,10 @@ function EffectRegistry.runDamaging(battle, ctx, record) animContext = hitCtx } table.insert(battle.queue, battle.nextInsert, hitRow) end + if gba then + require("src.battle.Gen3SubstitutePresentation").mark(battle, hitRow, user, + tripleKick and 4 - h or nativeMulti and hits - h + 1 or 0) + end local hadSub = target.substituteHP ~= nil local hitDamage = dmg if not hadSub then diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..4adb1af26 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -4018,7 +4018,9 @@ function Gen3MoveAnim:battlerOamY(battle, battler) local _, rawY = self:battlerRawCoords(battle, battler) if rawY == nil then return nil end local centreY - if battler.substituteHP and not battler.fainted then + local substitute = battle.substituteVisible and battle:substituteVisible(battler) + if battle.substituteVisible == nil then substitute = battler.substituteHP end + if substitute and not battler.fainted then -- GetSubstituteSpriteDefault_Y: raw BATTLER_COORD_Y + 17 (player) / 16 -- (opponent). The substitute still uses the ordinary 64x64 battler OAM. centreY = rawY + (battler.isPlayer and 17 or 16) diff --git a/src/battle/Gen3SubstitutePresentation.lua b/src/battle/Gen3SubstitutePresentation.lua new file mode 100644 index 000000000..bc82c18d4 --- /dev/null +++ b/src/battle/Gen3SubstitutePresentation.lua @@ -0,0 +1,170 @@ +-- Substitute has independent gameplay HP and controller-owned presentation. +-- A move can expose its user without removing the protective Substitute. +local M = {} +local V = require("src.core.GameVersion") + +local function protected(user) + return user and (tonumber(user.substituteHP) or 0) > 0 +end + +function M.mark(battle, row, user, left) + local frame = battle._gbaMoveFrame + if not V.isGen3() or not frame or not row or not row.anim then + return + end + frame.presentationOwner = frame.presentationOwner or {} + row.gbaPresentationOwner = frame.presentationOwner + row.gbaBehindSubstitute = protected(user) + row.gbaMultiHitLeft = left or 0 +end + +local function special(row, name) + return { + anim = name, + gbaSubstituteSpecial = true, + gbaSubstituteMove = row.anim, + attackerIsPlayer = row.attackerIsPlayer, + attackerPosition = row.attackerPosition, + targetPosition = row.attackerPosition, + } +end + +-- Finalize only this call's surviving animation rows. A called move owns its +-- own group; a multi-hit call keeps one lowered sprite until its final strike. +function M.finish(battle, frame) + local owner = frame and frame.presentationOwner + if not owner then + return + end + local lowered, i = false, 1 + local function insert(index, row) + table.insert(battle.queue, index, row) + if battle.nextInsert and index <= battle.nextInsert + 1 then + battle.nextInsert = battle.nextInsert + 1 + end + end + while i <= #battle.queue do + local row = battle.queue[i] + if row.gbaPresentationOwner == owner then + row.gbaPresentationOwner = nil + if row.gbaBehindSubstitute then + if not lowered then + insert(i, special(row, "SUBSTITUTE_TO_MON")) + lowered, i = true, i + 1 + end + if (row.gbaMultiHitLeft or 0) < 2 then + insert(i + 1, special(row, "MON_TO_SUBSTITUTE")) + lowered, i = false, i + 1 + end + end + row.gbaBehindSubstitute, row.gbaMultiHitLeft = nil, nil + end + i = i + 1 + end +end + +function M.showing(battle, user) + local state = battle._gbaSubstitutePresentation and battle._gbaSubstitutePresentation[user] + if state then + return state.substitute + end + return protected(user) +end + +function M.offset(battle, user) + local state = battle._gbaSubstitutePresentation and battle._gbaSubstitutePresentation[user] + return state and state.x or 0 +end + +local function actor(battle, row) + if row.attackerPosition ~= nil then + return battle:battlerAt(row.attackerPosition) + end + return row.attackerIsPlayer and battle.player or battle.enemy +end + +function M.start(battle, row) + if not V.isGen3() then + return false + end + local user = actor(battle, row) + if not user then + return false + end + local move = battle.data.moves[row.gbaSubstituteMove] + local number = move and tonumber(move.index) + if not battle:animationsOn() and number ~= 144 and number ~= 164 then + return false + end + local lower = row.anim == "SUBSTITUTE_TO_MON" + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + local state = states[user] + if not state then + state = { substitute = protected(user), x = 0 } + states[user] = state + end + if lower then + if not state.substitute then + return false + end + state.lowered = true + else + if not state.lowered then + return false + end + state.lowered = nil + if not protected(user) then + return false + end + end + local pos = tonumber(user.position) or (user.isPlayer and 0 or 1) + local coords = (battle.data.constants or {}).gen3BattlerCoords + local layout = coords and (battle:isDouble() and coords.doubles or coords.singles) + local point = layout and layout[pos + 1] + local baseX = tonumber(point and point.x) or (user.isPlayer and 72 or 176) + battle._gbaSubstituteTransition = { + user = user, + state = state, + phase = "out", + baseX = baseX, + direction = user.isPlayer and -1 or 1, + substitute = not lower, + } + return true +end + +-- Native SwapMonSpriteToFromSubstitute moves five pixels per controller tick. +-- The exchange happens offscreen, on its own tick, before the return trip. +function M.update(battle) + local transition = battle._gbaSubstituteTransition + if not transition then + return false + end + local state = transition.state + if transition.phase == "out" then + state.x = state.x + transition.direction * 5 + local right = transition.baseX + state.x + 32 + if right < 0 or right > 304 then + transition.phase = "exchange" + end + elseif transition.phase == "exchange" then + state.substitute = transition.substitute + transition.phase = "in" + else + state.x = state.x - transition.direction * 5 + if state.x * transition.direction <= 0 then + state.x = 0 + state.substitute = transition.substitute + battle._gbaSubstituteTransition = nil + return false + end + end + return true +end + +function M.reconcile(battle) + battle._gbaSubstituteTransition, battle._gbaSubstitutePresentation = nil, nil +end + +return M diff --git a/tests/enemy_actions/multi_hit.lua b/tests/enemy_actions/multi_hit.lua index 041b03692..86e72f0af 100644 --- a/tests/enemy_actions/multi_hit.lua +++ b/tests/enemy_actions/multi_hit.lua @@ -66,7 +66,8 @@ function M.fixture(root, backend, version, entry) entry ) 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) local T, Copy = f.T, require("src.mods.Merge").deepCopy @@ -224,6 +225,9 @@ function M.run(root, backend, version) draws, damage, used = {}, {}, {} local b = fresh(double, first, second) local user, target = at(b, uid), at(b, tid) + if options.substitute then + user.substituteHP = 400 + end liveUser, liveTarget = user, target if kind == "focus_crit" or kind == "focus_scope_crit" or kind == "max_crit" then user.focusEnergy = true @@ -288,7 +292,9 @@ function M.run(root, backend, version) if kind == "accuracy_miss" then def.accuracy = 90 end + local adapter if native then + adapter = require("src.core.game3.battle.adapter").new(b) local nativeSlot = slot if kind == "called" then nativeSlot = nil @@ -298,7 +304,7 @@ function M.run(root, backend, version) tid, user.mon.moves[slot], nativeSlot, - require("src.core.game3.battle.adapter").new(b), + adapter, b, {}, { called = kind == "called" } @@ -327,6 +333,26 @@ function M.run(root, backend, version) damage = Copy(damage), used = Copy(used), } + if options.substitute then + local animations = {} + for _, event in ipairs(native and adapter:eventsSince(0) or b.queue) do + if + native and (event.kind == "move" or event.kind == "anim") or not native and event.anim + then + local move = not native and f.data.moves[event.anim] + local isMove = native and event.kind == "move" or not native and move ~= nil + animations[#animations + 1] = { + kind = isMove and "move" or "anim", + move = native and (isMove and event.moveId or event.name) + or (isMove and move.index or event.anim), + user = native and event.attackerId or event.attackerPosition, + target = native and event.targetId or event.targetPosition, + turn = native and event.turn or event.animContext and event.animContext.moveTurn or 0, + } + end + end + rows[#rows].animations = animations + end if kind == "ordinary" then T.eq( #damage, diff --git a/tests/enemy_actions/substitute_presentation.lua b/tests/enemy_actions/substitute_presentation.lua new file mode 100644 index 000000000..239531220 --- /dev/null +++ b/tests/enemy_actions/substitute_presentation.lua @@ -0,0 +1,184 @@ +-- Owned controller inputs execute the real sequencer/VM and host battle queue. +-- No draw or graphics application is launched; every controller tick is kept. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local rows = {} + local Anim, Seq, Runtime, Presentation + if native then + Anim = require("src.core.game3.battle.anim") + Seq = require("src.core.game3.battle.anim_seq") + Runtime = require("src.core.game3.runtime") + local moves = require("src.core.game3.battle.moves") + for _, n in ipairs({ 144, 164 }) do + local def = {} + for k, v in pairs(moves._rom[33]) do + def[k] = v + end + moves._rom[n] = def + f.data.gen3Moves._rom[n] = def + local name = n == 144 and "TRANSFORM" or "SUBSTITUTE" + f.data.gen3Pokemon._moveNames[n] = name + moves.BY_NUM[n], moves._numByName[name] = name, n + end + local function swap(arg) + return { + { op = "createvisualtask", task = "SwapMonSpriteToFromSubstitute", priority = 2, args = { arg } }, + { op = "end" }, + } + end + -- Both qualified version-5 imports use these two decoded special programs. + -- Keep the name table contiguous, as required by the native pack lookup. + local names = {} + for i = 0, 4 do + names[i] = "UNUSED_" .. i + end + names[5], names[6] = "SUBSTITUTE_TO_MON", "MON_TO_SUBSTITUTE" + Anim.loadPack({ + version = 5, + special = { [5] = swap(1), [6] = swap(0) }, + specialNames = names, + tags = {}, + moves = { + [33] = { { op = "delay", frames = 200 }, { op = "end" } }, + [144] = { { op = "delay", frames = 200 }, { op = "end" } }, + [164] = { { op = "delay", frames = 200 }, { op = "end" } }, + }, + }) + else + Presentation = require("src.battle.Gen3SubstitutePresentation") + f.data.constants.gen3BattlerCoords = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { { x = 32, y = 80 }, { x = 200, y = 40 }, { x = 90, y = 88 }, { x = 152, y = 32 } }, + } + for _, n in ipairs({ 144, 164 }) do + f.data.moves[("MOVE_%03d"):format(n)] = { index = n } + end + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, scene in ipairs({ false, true }) do + for _, move in ipairs({ 33, 144, 164 }) do + for _, kind in ipairs({ "lower", "not-substitute", "restore", "broken-restore", "no-lower-restore" }) do + local b = f.field(double) + local user = f.actor(id) + local lower = kind == "lower" or kind == "not-substitute" + local visible = lower and kind ~= "not-substitute" + local wasLowered = kind == "restore" or kind == "broken-restore" + local hp = (kind == "broken-restore" or kind == "not-substitute") and 0 or 100 + -- The preceding Substitute animation creates its visible doll. + -- Both controller inputs begin with that same prepared display. + if not lower and move == 164 and scene and hp > 0 then + visible = true + end + user.substituteHP = hp > 0 and hp or nil + local name = lower and "SUBSTITUTE_TO_MON" or "MON_TO_SUBSTITUTE" + local reached, commandIndex = false, 1 + if native then + require("src.core.game3.battle")._st = b + Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } + Anim.reset({ headless = false, double = double }) + Anim.setSubstitute(id, visible) + local events = { + { kind = "anim", anim = "special", name = name, attackerId = id, targetId = id }, + { + kind = "move", + moveId = move, + attackerId = id, + targetId = id % 2 == 0 and id + 1 or id - 1, + }, + } + if not lower then + -- Native buildSteps associates restoration with the preceding + -- move. Execute that preparation normally, then observe every + -- tick of the restoration command from its actual launch. + table.insert(events, 1, { + kind = "move", + moveId = move, + attackerId = id, + targetId = id % 2 == 0 and id + 1 or id - 1, + }) + commandIndex = 2 + end + Seq.beginEvents(events, function() end) + Seq._subLowered[id] = wasLowered or nil + if not lower then + for preparation = 0, 250 do + if preparation > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + Seq.update() + end + if Seq._i >= commandIndex and Seq._waitFrames == 0 then + break + end + end + assert( + Seq._i >= commandIndex and Seq._waitFrames == 0, + "restoration preparation did not complete" + ) + end + else + f.game.save.options.animations = scene + b.queue = { + { + anim = name, + gbaSubstituteSpecial = true, + gbaSubstituteMove = ("MOVE_%03d"):format(move), + attackerPosition = id, + attackerIsPlayer = user.isPlayer, + }, + { + fn = function() + reached = true + end, + }, + } + b.current, b.waitFrames, b.nextInsert = nil, nil, 0 + b._gbaSubstituteTransition = nil + b._gbaSubstitutePresentation = + { [user] = { substitute = visible, lowered = wasLowered, x = 0 } } + end + local frames, done = {}, false + for tick = 0, 200 do + local showing, offset + if native then + if tick > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + Seq.update() + end + done = Seq._i > commandIndex + local state = Anim.present(id) + showing, offset = state.substitute, state.ox or 0 + else + b:updateQueue() + done = reached + showing, offset = Presentation.showing(b, user), Presentation.offset(b, user) + end + frames[#frames + 1] = { + tick = tick, + substitute = showing, + offset = offset, + done = done, + substituteHP = user.substituteHP or 0, + } + if done then + break + end + end + assert(done, "Substitute controller did not complete") + rows[#rows + 1] = + { double = double, actor = id, scene = scene, move = move, case = kind, frames = frames } + end + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/engine/gen3_substitute_presentation.lua b/tests/engine/gen3_substitute_presentation.lua new file mode 100644 index 000000000..1d6634122 --- /dev/null +++ b/tests/engine/gen3_substitute_presentation.lua @@ -0,0 +1,58 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/substitute_presentation.lua").run(".", "gen2", version) + T.eq(#rows, 180, "every actor/options/command input executes") + for _, row in ipairs(rows) do + local last = row.frames[#row.frames] + T.check(last.done, "native controller returns to the actual battle queue") + T.eq(last.offset, 0, "controller finishes at the original battler coordinate") + local initialHP = row.frames[1].substituteHP + for _, frame in ipairs(row.frames) do + T.eq(frame.substituteHP, initialHP, "sprite exchange never removes gameplay protection") + end + local allowed = row.scene or row.move == 144 or row.move == 164 + local plays = allowed and (row.case == "lower" or row.case == "restore") + if plays then + local x = row.double and ({ 32, 200, 90, 152 })[row.actor + 1] or (row.actor == 0 and 72 or 176) + local outward = row.actor % 2 == 0 and math.floor((x + 32) / 5) + 1 + or math.floor((304 - x - 32) / 5) + 1 + T.eq(last.tick, outward * 2 + 1, "offscreen swap uses its native outward/exchange/return ticks") + else + T.eq(last.tick, 0, "skipped controller command adds no waiting tick") + end + end +end +local V = require("src.core.GameVersion") +local B = require("src.battle.BattleState") +local P = require("src.battle.Gen3SubstitutePresentation") +local user = { isPlayer = true, position = 0, substituteHP = 100, mon = { species = "FIX", hp = 400 } } +local battle = setmetatable({ + player = user, + data = { + moves = { MOVE_033 = { index = 33 } }, + constants = { gen3BattlerCoords = { singles = { { x = 72, y = 80 }, { x = 176, y = 40 } } } }, + pokemon = { FIX = { backPicCoords = { yOffset = 8 } } }, + }, + game = { save = { options = { animations = true } } }, + queue = {}, +}, B) +local row = { anim = "SUBSTITUTE_TO_MON", gbaSubstituteMove = "MOVE_033", attackerIsPlayer = true } +T.check(P.start(battle, row), "native transition starts on the real battle controller") +for _ = 1, 43 do + P.update(battle) +end +T.eq(battle:substituteVisible(user), false, "lowered sprite shows the Pokemon with protection retained") +T.eq(user.substituteHP, 100, "lowering does not mutate Substitute HP") +local animator = setmetatable({ data = battle.data }, require("src.battle.Gen3MoveAnim")) +T.eq(animator:battlerOamY(battle, user), 56, "lowered Pokemon uses its native species OAM Y") +T.eq(battle:updateQueue(), false, "empty actual battle queue completes") +T.eq(battle._gbaSubstitutePresentation, nil, "queue completion releases transient presentation state") +T.eq(battle:substituteVisible(user), true, "completion reconciles the doll with real protection") +T.eq(animator:battlerOamY(battle, user), 65, "restored doll uses native Substitute OAM Y") +V.set("red") +T.eq(P.start(battle, row), false, "GBA controller commands cannot alter legacy battles") +T.eq(battle._gbaSubstituteTransition, nil, "legacy forwarding leaves no transition") +user.substituteHP = false +T.eq(battle:substituteVisible(user), false, "legacy false protection remains hidden") +T.finish("native GBA Substitute controller presentation") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 65f1ab684..da712522c 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,8 @@ MODES = [ + (3, 'substitute_multi_hit'), + (3, 'substitute_presentation'), (3, 'mirror_move'), (3, 'mirror_move_content'), (3, 'mirror_move_chained'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 7086842b1..63a69af6e 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 == "mirror_move_content" then +if mode == "substitute_multi_hit" then + rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version, { substitute = true }) +elseif mode == "substitute_presentation" then + rows = dofile(root .. "/tests/enemy_actions/substitute_presentation.lua").run(root, backend, version) +elseif mode == "mirror_move_content" then rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").content(root, backend, version) elseif mode == "mirror_move_chained" then rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").chained(root, backend, version) From 729c07fa7575230dde4b2ae0577b9898142f6ae5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:44:47 +0100 Subject: [PATCH 110/122] Preserve sparse battle queues when grouping Substitute animations --- docs/native-gen3-substitute-presentation.md | 6 ++++++ src/battle/Gen3SubstitutePresentation.lua | 21 +++++++++++++++---- tests/engine/gen3_substitute_presentation.lua | 9 ++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/native-gen3-substitute-presentation.md b/docs/native-gen3-substitute-presentation.md index ec5645c2c..66775ffa5 100644 --- a/docs/native-gen3-substitute-presentation.md +++ b/docs/native-gen3-substitute-presentation.md @@ -54,3 +54,9 @@ nice -n 19 python3 tools/enemy-action-contract-check/check.py \ ``` Local raw observations: `tmp/substitute-presentation-qa/`. + +The frozen `63084eca` full run passes 156/167 engine suites, SDK 3/3 and B30. +Its exact FAIL-line Counter adds two queue-hole crashes to the inherited 25 +lines. The follow-up preserves every sparse queue row/hole, avoids grouping +unprotected users, and passes the controller, Follow Me and move-used PP suites. +The failed full run remains retained; a new full regression is still required. diff --git a/src/battle/Gen3SubstitutePresentation.lua b/src/battle/Gen3SubstitutePresentation.lua index bc82c18d4..cfc66465a 100644 --- a/src/battle/Gen3SubstitutePresentation.lua +++ b/src/battle/Gen3SubstitutePresentation.lua @@ -12,6 +12,9 @@ function M.mark(battle, row, user, left) if not V.isGen3() or not frame or not row or not row.anim then return end + if not protected(user) then + return + end frame.presentationOwner = frame.presentationOwner or {} row.gbaPresentationOwner = frame.presentationOwner row.gbaBehindSubstitute = protected(user) @@ -36,16 +39,26 @@ function M.finish(battle, frame) if not owner then return end - local lowered, i = false, 1 + local lowered, i, last = false, 1, 0 + for index in pairs(battle.queue) do + if type(index) == "number" and index % 1 == 0 and index > last then + last = index + end + end local function insert(index, row) - table.insert(battle.queue, index, row) + -- Existing action callbacks can leave holes before later queued rows. + -- Preserve every row and hole instead of relying on Lua's sparse length. + for j = last, index, -1 do + battle.queue[j + 1] = battle.queue[j] + end + battle.queue[index], last = row, last + 1 if battle.nextInsert and index <= battle.nextInsert + 1 then battle.nextInsert = battle.nextInsert + 1 end end - while i <= #battle.queue do + while i <= last do local row = battle.queue[i] - if row.gbaPresentationOwner == owner then + if row and row.gbaPresentationOwner == owner then row.gbaPresentationOwner = nil if row.gbaBehindSubstitute then if not lowered then diff --git a/tests/engine/gen3_substitute_presentation.lua b/tests/engine/gen3_substitute_presentation.lua index 1d6634122..1749041b6 100644 --- a/tests/engine/gen3_substitute_presentation.lua +++ b/tests/engine/gen3_substitute_presentation.lua @@ -26,6 +26,15 @@ end local V = require("src.core.GameVersion") local B = require("src.battle.BattleState") local P = require("src.battle.Gen3SubstitutePresentation") +local animation, trailing = { anim = "MOVE_033", attackerPosition = 0 }, { fn = function() end } +local sparse = { queue = { [1] = animation, [3] = trailing }, nextInsert = 1, _gbaMoveFrame = {} } +P.mark(sparse, animation, { substituteHP = 100 }) +P.finish(sparse, sparse._gbaMoveFrame) +T.eq(sparse.queue[1].anim, "SUBSTITUTE_TO_MON", "sparse queue retains native lowering") +T.eq(sparse.queue[2], animation, "sparse queue retains original animation identity") +T.eq(sparse.queue[3].anim, "MON_TO_SUBSTITUTE", "sparse queue retains native restoration") +T.eq(sparse.queue[4], nil, "sparse queue preserves its original hole") +T.eq(sparse.queue[5], trailing, "sparse queue preserves trailing callback identity") local user = { isPlayer = true, position = 0, substituteHP = 100, mon = { species = "FIX", hp = 400 } } local battle = setmetatable({ player = user, From 6b71be27023f9d8905ce37e1b6c78d8aef113057 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:54:56 +0100 Subject: [PATCH 111/122] Record full regression parity after sparse queue repair --- docs/native-gen3-substitute-presentation.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/native-gen3-substitute-presentation.md b/docs/native-gen3-substitute-presentation.md index 66775ffa5..6ccba4cc5 100644 --- a/docs/native-gen3-substitute-presentation.md +++ b/docs/native-gen3-substitute-presentation.md @@ -59,4 +59,7 @@ The frozen `63084eca` full run passes 156/167 engine suites, SDK 3/3 and B30. Its exact FAIL-line Counter adds two queue-hole crashes to the inherited 25 lines. The follow-up preserves every sparse queue row/hole, avoids grouping unprotected users, and passes the controller, Follow Me and move-used PP suites. -The failed full run remains retained; a new full regression is still required. +The failed full run remains retained. Runtime `729c07fa` passes the repeated +full gate at 158/167 engine suites, SDK 3/3 and B30. Its exact anchored FAIL-line +Counter equals fresh #258: the same 25 inherited lines, with no additions or +removals. From bff8ec576df11a8b7e651eef70855edc061fcdf6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:55:18 +0100 Subject: [PATCH 112/122] Import and render native GBA Substitute doll artwork --- docs/native-gen3-substitute-art.md | 60 ++++++++ src/battle/BattleState.lua | 1 + src/battle/Gen3SubstituteArt.lua | 43 ++++++ src/import/Gen3SubstituteArt.lua | 74 +++++++++ src/import/RomExtractorGen3.lua | 12 +- tests/engine/gen3_substitute_art.lua | 140 ++++++++++++++++++ tests/substitute_art/fixture.lua | 41 +++++ tests/tools/test_gba_substitute_art.py | 93 ++++++++++++ .../imported-substitute-art.lua | 37 +++++ tools/gba_substitute_art.py | 47 ++++++ tools/gen3_discover.py | 10 ++ tools/rom_manifest_emerald.json | 3 + tools/rom_manifest_firered.json | 3 + 13 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen3-substitute-art.md create mode 100644 src/battle/Gen3SubstituteArt.lua create mode 100644 src/import/Gen3SubstituteArt.lua create mode 100644 tests/engine/gen3_substitute_art.lua create mode 100644 tests/substitute_art/fixture.lua create mode 100644 tests/tools/test_gba_substitute_art.py create mode 100644 tools/enemy-action-contract-check/imported-substitute-art.lua create mode 100644 tools/gba_substitute_art.py diff --git a/docs/native-gen3-substitute-art.md b/docs/native-gen3-substitute-art.md new file mode 100644 index 000000000..da315dbd8 --- /dev/null +++ b/docs/native-gen3-substitute-art.md @@ -0,0 +1,60 @@ +# Native GBA Substitute doll art work in progress + +This development branch extends the native Substitute command/controller work. +It imports the doll from the player's cartridge and uses its native 64x64 OBJ +geometry, including each double-battle slot and the controller's live offsets. +Existing caches need a reimport to populate the new art metadata. The previous +draw path remains available while that metadata is absent. + +## Cartridge contract + +The discovery helper identifies one verified loader literal pool: DMA3's +register/control pair, matching graphics-buffer literals, two distinct 2048-byte +LZ sheets and a valid 32-byte BGR555 palette. Missing, ambiguous, unmapped or +malformed candidates are refused. Symbols are recorded in the two supported +English manifests, each checked against its exact cartridge SHA-1. + +The asset stage validates every blob before writing either sheet. A versioned +`constants.gen3SubstituteArt` record names only successfully extracted images. +The animation-pack RGB555 contract retains the high five bits of each channel, +including RGB behind transparent palette index zero. No cartridge images are +included in this branch. + +## Verified evidence + +- Eight discovery rejection/acceptance tests, including ambiguous loaders, + invalid palette, mismatched buffer, oversized/truncated inputs and duplicate + sheets. +- 59 owned import/placement/render checks: 4bpp nibble order, separate facings, + native dimensions, rejection before partial output, all six battler slots, + widened native field, live offsets and graphics-color preservation through + the actual battle draw path. +- Actual targeted FireRed/Emerald stage imports into a separate CPU QA output + directory. Every RGBA channel in all four native sheets matches the existing + read-only Gen1 imports: **16,384 pixels, zero differences**, including + transparent RGB. No ordinary cache, save or game process is modified. +- The structural locator uniquely resolves complete art in five existing + cartridges: BPRE, BPEE, BPRD, BPED and BPGE, revision zero. The extra three + locator checks do not claim support for additional game profiles. + +These checks establish decoded artwork and draw commands. They are not final +scene screenshots or whole-battle graphical acceptance. General/status playback +for Substitute fade, Focus Band and poison remains open in the broader command +matrix; its 1,344 differing observations are retained. Mirror Move history, +linked-role and actual public-submission gates also remain open before a PR. + +Raw local QA is retained in `tmp/substitute-art-qa/`. Run the owned checks with: + +```sh +nice -n 19 python3 tests/tools/test_gba_substitute_art.py +nice -n 19 /path/to/pinned/luajit tests/engine/gen3_substitute_art.lua +``` + +For an actual targeted import, supply the matching manifest/cartridge and an +existing separate QA directory: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-substitute-art.lua \ + tools/rom_manifest_firered.json /private/firered.gba /private/qa-output +``` diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index de69b56ac..1392d4c38 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -11141,6 +11141,7 @@ end -- (32,72) player. local substDoll function BattleState:drawSubstituteDoll(battler) + if GameVersion.isGen3() and require("src.battle.Gen3SubstituteArt").draw(self, battler) then return end if substDoll == nil then local ok, img = pcall(love.graphics.newImage, "assets/generated/sprites/monster.png") diff --git a/src/battle/Gen3SubstituteArt.lua b/src/battle/Gen3SubstituteArt.lua new file mode 100644 index 000000000..3e6f98589 --- /dev/null +++ b/src/battle/Gen3SubstituteArt.lua @@ -0,0 +1,43 @@ +-- The native doll is a 64x64 OBJ centered on the battler's raw coordinates. +local M = {} +local V = require("src.core.GameVersion") +local Assets = require("src.render.Assets") + +function M.pose(battle, battler) + if not V.isGen3() or not battler then + return nil + end + if battle.gen3Layout and not battle:gen3Layout() then + return nil + end + local constants = battle.data and battle.data.constants or {} + local record, coords = constants.gen3SubstituteArt, constants.gen3BattlerCoords + if not record or record.version ~= 1 or record.width ~= 64 or record.height ~= 64 then + return nil + end + local positions = coords and (battle:isDouble() and coords.doubles or coords.singles) + local id = tonumber(battler.position) or (battler.isPlayer and 0 or 1) + local raw = positions and positions[id + 1] + local path = battler.isPlayer and record.back or record.front + if not raw or type(path) ~= "string" then + return nil + end + local dx, dy = battle:gen3AnimShake(battler) + return { + image = path, + x = require("src.battle.Gen3Battle").fieldOffset(battle) + raw.x - 32 + (dx or 0), + y = raw.y + (battler.isPlayer and 17 or 16) - 32 + (dy or 0), + } +end + +function M.draw(battle, battler) + local pose = M.pose(battle, battler) + if not pose then + return false + end + local image = Assets.image(pose.image) + love.graphics.draw(image, pose.x, pose.y) + return true +end + +return M diff --git a/src/import/Gen3SubstituteArt.lua b/src/import/Gen3SubstituteArt.lua new file mode 100644 index 000000000..d2b49a593 --- /dev/null +++ b/src/import/Gen3SubstituteArt.lua @@ -0,0 +1,74 @@ +-- Import only the player's own verified native Substitute sheets/palette. +local M = {} +local Rom = require("src.import.RomGba") +local ImageWriter = require("src.import.ImageWriter") + +local function blob(extractor, name, size) + local off = extractor:symbol(name) + local rom = extractor.rom + if not off or off < 0 or off % 4 ~= 0 or off + 4 > rom.size then + return nil, name + end + if rom:u8(off) ~= 0x10 or math.floor(rom:u32(off) / 256) ~= size then + return nil, name + end + local ok, raw = pcall(rom.lz77, rom, off) + if not ok or type(raw) ~= "table" or #raw ~= size then + return nil, name + end + return raw, off +end + +local function image(raw, colors) + local pixels = Rom.tiles4bpp(raw, 8, 8) + local out = ImageWriter.blank(64, 64) + for y = 1, 64 do + for x = 1, 64 do + local index = pixels[y][x] + local color = colors[index + 1] + out:setPixel(x - 1, y - 1, color[1] / 255, color[2] / 255, color[3] / 255, index == 0 and 0 or 1) + end + end + return out +end + +function M.extract(extractor) + local palette, palAt = blob(extractor, "gSubstituteDollPal", 32) + local front, frontAt = blob(extractor, "gSubstituteDollFrontGfx", 2048) + local back, backAt = blob(extractor, "gSubstituteDollBackGfx", 2048) + if not palette or not front or not back then + return nil, "missing or malformed native Substitute art" + end + for i = 2, 32, 2 do + if palette[i] >= 128 then + return nil, "invalid Substitute BGR555 palette" + end + end + -- The shared native animation-pack contract carries five-bit channels in + -- the high bits of each byte. Retain RGB even behind transparent index 0. + local colors = {} + for i = 1, 16 do + local word = palette[i * 2 - 1] + palette[i * 2] * 256 + colors[i] = { word % 32 * 8, math.floor(word / 32) % 32 * 8, math.floor(word / 1024) % 32 * 8 } + end + local frontImage, backImage = image(front, colors), image(back, colors) + local frontPath, backPath = "battle/substitute_front.png", "battle/substitute_back.png" + extractor:saveImage(frontImage, frontPath) + extractor:saveImage(backImage, backPath) + local record = { + version = 1, + width = 64, + height = 64, + paletteEncoding = "rgb555-msb", + front = "assets/generated/" .. frontPath, + back = "assets/generated/" .. backPath, + source = { palette = palAt, front = frontAt, back = backAt }, + } + local constants = extractor._constants or {} + constants.gen3SubstituteArt = record + extractor._constants = constants + extractor:write("constants", constants) + return record +end + +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..1283ee930 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -53,7 +53,7 @@ RomExtractorGen3.__index = RomExtractorGen3 -- The progress denominator. Kept honest with the two stage lists below: a -- mismatch does not break anything, it just makes the bar lie, which is the -- kind of small wrongness that survives for months. -local STAGE_COUNT = 94 +local STAGE_COUNT -- assigned from the completed stage lists below -- --------------------------------------------------------------------------- -- The stage tables below are keyed to Emerald's ROM. A sibling cartridge @@ -58837,7 +58837,7 @@ end RomExtractorGen3.ASSET_STAGES = { "extractAudioImage", "extractFanfares", "extractSongNames", "extractSfxNames", "extractEncounterMusic", - "extractSprites", "extractTrainerSprites", "extractIntroSprites", + "extractSprites", "extractSubstituteArt", "extractTrainerSprites", "extractIntroSprites", "extractMonIcons", "extractBattleBackgrounds", "extractBattleScenes", "extractSummaryScreen", "extractPlayerBackPic", @@ -58878,6 +58878,14 @@ RomExtractorGen3.ASSET_STAGES = { "extractTradeScene", } +STAGE_COUNT = #RomExtractorGen3.DATA_STAGES + #RomExtractorGen3.ASSET_STAGES + +function RomExtractorGen3:extractSubstituteArt() + self:beginStage("Gen3 Substitute art") + local record, why = require("src.import.Gen3SubstituteArt").extract(self) + if not record then Logger.warn("gen3: %s", why) end +end + -- STAGE: the in-game trade sequence's art -- the wireless GBA, the signal -- rings, the platform, the balls, the link mon and every palette and table -- DoTradeAnim_Wireless drives them with. Its own module, because it is keyed diff --git a/tests/engine/gen3_substitute_art.lua b/tests/engine/gen3_substitute_art.lua new file mode 100644 index 000000000..bff93cca4 --- /dev/null +++ b/tests/engine/gen3_substitute_art.lua @@ -0,0 +1,140 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local E = require("src.import.RomExtractorGen3") +T.eq(E.STAGE_COUNT, #E.DATA_STAGES + #E.ASSET_STAGES, "import progress includes every registered stage") +local Import = require("src.import.Gen3SubstituteArt") +local Fixture = dofile("tests/substitute_art/fixture.lua") +local previousImage = love.image +love.image = { newImageData = Fixture.image } +local function literal(raw) + local size, out = #raw, { string.char(16, #raw % 256, math.floor(#raw / 256), 0) } + for i = 1, size, 8 do + out[#out + 1] = string.char(0) + for j = i, math.min(size, i + 7) do + out[#out + 1] = string.char(raw[j]) + end + end + return table.concat(out) +end +local palette, front, back = {}, {}, {} +for i = 1, 32 do + palette[i] = 0 +end +palette[1], palette[2] = 0xB2, 0x63 -- transparent RGB is retained too +palette[3], palette[4] = 31, 0 +palette[5], palette[6] = 0xE0, 3 +for i = 1, 2048 do + front[i], back[i] = 0x21, 0x12 +end +front[2] = 0x10 +local function extractor(change) + local symbols = { gSubstituteDollPal = 0, gSubstituteDollFrontGfx = 64, gSubstituteDollBackGfx = 4096 } + local bytes = literal(palette) + bytes = bytes .. string.rep("\0", 64 - #bytes) .. literal(front) + bytes = bytes .. string.rep("\0", 4096 - #bytes) .. literal(back) + if change then + bytes, symbols = change(bytes, symbols) + end + local e, writes, images = E.new(bytes, "firered", { symbols = symbols }), {}, {} + e.saveImage = function(_, image, path) + images[path] = image + end + e.write = function(_, name, value) + writes[name] = value + end + return e, writes, images +end +local e, writes, images = extractor() +local record = assert(Import.extract(e)) +T.eq(record.width, 64, "native doll keeps its full OAM width") +T.eq(writes.constants.gen3SubstituteArt, record, "only decoded art is advertised to runtime") +local r, g, b, a = images["battle/substitute_front.png"]:getPixel(0, 0) +T.same({ r, g, b, a }, { 248 / 255, 0, 0, 1 }, "low nibble is the left pixel") +r, g, b, a = images["battle/substitute_front.png"]:getPixel(1, 0) +T.same({ r, g, b, a }, { 0, 248 / 255, 0, 1 }, "high nibble is the right pixel") +r, g, b, a = images["battle/substitute_front.png"]:getPixel(2, 0) +T.same({ r, g, b, a }, { 144 / 255, 232 / 255, 192 / 255, 0 }, "transparent native RGB is retained") +r, g, b, a = images["battle/substitute_back.png"]:getPixel(0, 0) +T.same({ r, g, b, a }, { 0, 248 / 255, 0, 1 }, "front and back are separate cartridge sheets") +for _, change in ipairs({ + function(bytes, symbols) + symbols.gSubstituteDollPal = nil + return bytes, symbols + end, + function(bytes, symbols) + symbols.gSubstituteDollBackGfx = #bytes + return bytes, symbols + end, + function(bytes, symbols) + return bytes:sub(1, 64) .. string.char(0) .. bytes:sub(66), symbols + end, + function(bytes, symbols) + return bytes:sub(1, 6) .. string.char(128) .. bytes:sub(8), symbols + end, + function(bytes, symbols) + return bytes:sub(1, 4096) .. string.char(16, 1, 0, 0), symbols + end, +}) do + local invalid, invalidWrites, invalidImages = extractor(change) + T.eq(Import.extract(invalid), nil, "malformed/missing art is refused") + T.eq(next(invalidWrites), nil, "invalid art cannot advertise a partial record") + T.eq(next(invalidImages), nil, "all three blobs validate before any image write") +end +local V, Draw = require("src.core.GameVersion"), require("src.battle.Gen3SubstituteArt") +V.set("firered") +local raw = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { { x = 32, y = 80 }, { x = 200, y = 40 }, { x = 90, y = 88 }, { x = 152, y = 32 } }, +} +local battle = { + data = { constants = { gen3SubstituteArt = record, gen3BattlerCoords = raw } }, + isDouble = function(self) + return self.double + end, + gen3AnimShake = function() + return -5, 2 + end, + gen3SurfaceWidth = function() + return 320 + end, +} +local graphics, Assets = love.graphics, require("src.render.Assets") +local originalDraw, originalNewImage = graphics.draw, graphics.newImage +local drawn = {} +graphics.newImage = function(path) + local relative = path:gsub("^assets/generated/", "") + local image = images[relative] + if not image then + return originalNewImage(path) + end + image.path = path + return image +end +graphics.draw = function(...) + drawn[#drawn + 1] = { ... } +end +Assets.flush() +graphics.setColor(0.2, 0.4, 0.6, 0.7) +for _, double in ipairs({ false, true }) do + battle.double = double + for id = 0, double and 3 or 1 do + local player = id % 2 == 0 + local actor = { position = id, isPlayer = player } + local pose = assert(Draw.pose(battle, actor)) + local point = (double and raw.doubles or raw.singles)[id + 1] + T.eq(pose.image, player and record.back or record.front, "each slot selects its native facing") + T.eq(pose.x, point.x + 40 - 32 - 5, "native OAM X includes centered wide field and live offset") + T.eq(pose.y, point.y + (player and 17 or 16) - 32 + 2, "native OAM Y uses the Substitute coordinate") + require("src.battle.BattleState").drawSubstituteDoll(battle, actor) + local call = drawn[#drawn] + T.eq(call[1].path, pose.image, "actual battle renderer loads the imported facing through Assets") + T.same({ call[2], call[3] }, { pose.x, pose.y }, "actual draw uses native coordinates and live offsets") + T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "doll drawing preserves graphics color state") + end +end +graphics.draw, graphics.newImage = originalDraw, originalNewImage +Assets.flush() +V.set("red") +T.eq(Draw.pose(battle, { isPlayer = true }), nil, "legacy rendering is forwarded") +love.image = previousImage +T.finish("native GBA Substitute art import and placement") diff --git a/tests/substitute_art/fixture.lua b/tests/substitute_art/fixture.lua new file mode 100644 index 000000000..be9666963 --- /dev/null +++ b/tests/substitute_art/fixture.lua @@ -0,0 +1,41 @@ +-- CPU image storage: actual importer pixels, without a graphics application. +local M = {} +function M.image(width, height) + local image = { width = width, height = height, pixels = {} } + function image:getWidth() + return self.width + end + function image:getHeight() + return self.height + end + function image:getDimensions() + return self.width, self.height + end + function image:setPixel(x, y, r, g, b, a) + self.pixels[y * self.width + x] = { r, g, b, a } + end + function image:getPixel(x, y) + return unpack(self.pixels[y * self.width + x] or { 0, 0, 0, 0 }) + end + function image:mapPixel(fn) + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) + end + end + end + function image:rgba8() + local bytes = {} + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + local pixel = { self:getPixel(x, y) } + for i = 1, 4 do + bytes[#bytes + 1] = string.char(math.floor(pixel[i] * 255 + 0.5)) + end + end + end + return table.concat(bytes) + end + return image +end +return M diff --git a/tests/tools/test_gba_substitute_art.py b/tests/tools/test_gba_substitute_art.py new file mode 100644 index 000000000..bdf324291 --- /dev/null +++ b/tests/tools/test_gba_substitute_art.py @@ -0,0 +1,93 @@ +"""Reject ambiguous/invalid loader candidates before publishing import symbols.""" +from pathlib import Path +import struct +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) +from gba_substitute_art import discover +from gen3_discover import Rom, lz77_at + + +def literal(raw): + out = bytearray((len(raw) << 8 | 16).to_bytes(4, "little")) + for i in range(0, len(raw), 8): + out.append(0) + out.extend(raw[i:i + 8]) + return out + + +def loader(data, at=0x100): + for delta, value in ((0, 0x040000D4), (4, 0x84000200), + (-0x5C, 0x08001000), (-12, 0x08002000), + (-4, 0x08004000), (-0x58, 0x0202401C), (-8, 0x0202401C)): + struct.pack_into(" Date: Sun, 25 Oct 2026 21:25:10 +0100 Subject: [PATCH 113/122] Record full regression baseline for native Substitute art --- docs/native-gen3-substitute-art.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/native-gen3-substitute-art.md b/docs/native-gen3-substitute-art.md index da315dbd8..d27167b9f 100644 --- a/docs/native-gen3-substitute-art.md +++ b/docs/native-gen3-substitute-art.md @@ -36,6 +36,10 @@ included in this branch. - The structural locator uniquely resolves complete art in five existing cartridges: BPRE, BPEE, BPRD, BPED and BPGE, revision zero. The extra three locator checks do not claim support for additional game profiles. +- Frozen runtime `bff8ec57`: 159/168 engine suites, SDK 3/3 and B30. The exact + anchored FAIL-line Counter matches fresh #258: the same 25 inherited lines, + with no additions or removals. The failed broader animation matrix remains + retained separately and is not reported as passing. These checks establish decoded artwork and draw commands. They are not final scene screenshots or whole-battle graphical acceptance. General/status playback From 816346bc24a9e747757b2eb8c10b3fa8515c7d59 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:36:28 +0100 Subject: [PATCH 114/122] Execute native Poison presentation with numbered stereo and mono sound controls --- .../native-gen3-battle-effect-presentation.md | 67 ++++++++ src/battle/BattleState.lua | 13 +- src/battle/Gen3EffectPresentation.lua | 128 ++++++++++++++ src/battle/StatusRegistry.lua | 1 + src/core/ChipAudio.lua | 3 + src/core/NativeSoundPan.lua | 31 ++++ src/core/Sound.lua | 34 +++- .../battle_effect_presentation.lua | 161 ++++++++++++++++++ tests/enemy_actions/native_sound_pan.lua | 50 ++++++ tests/engine/gen3_effect_presentation.lua | 34 ++++ tests/engine/gen3_native_sound.lua | 115 +++++++++++++ tests/native_sound/fixture.lua | 135 +++++++++++++++ tools/enemy-action-contract-check/check.py | 2 + tools/enemy-action-contract-check/worker.lua | 6 +- 14 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 docs/native-gen3-battle-effect-presentation.md create mode 100644 src/battle/Gen3EffectPresentation.lua create mode 100644 src/core/NativeSoundPan.lua create mode 100644 tests/enemy_actions/battle_effect_presentation.lua create mode 100644 tests/enemy_actions/native_sound_pan.lua create mode 100644 tests/engine/gen3_effect_presentation.lua create mode 100644 tests/engine/gen3_native_sound.lua create mode 100644 tests/native_sound/fixture.lua diff --git a/docs/native-gen3-battle-effect-presentation.md b/docs/native-gen3-battle-effect-presentation.md new file mode 100644 index 000000000..190c2b2ed --- /dev/null +++ b/docs/native-gen3-battle-effect-presentation.md @@ -0,0 +1,67 @@ +# Native GBA battle-effect presentation work in progress + +This branch extends native Substitute art at `bff8ec57`. It is development +work, not an accepted PR or a replacement for the playable testing branch. +Gen1 remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented native Poison path + +Successful native poison status changes queue their status-animation command. +The actual battle queue executes the native shake, two palette cycles and six +edition-specific sounds. Presentation does not change HP, status, PP or battle +RNG. Hidden/protected actors and disabled animations follow native skip rules. +Mono/stereo uses the existing game sound option. Live sprite offsets and tint +reach the battle renderer through its normal presentation methods. + +Native numbered sounds also resolve through cartridge songs when no named +role exists. Explicit panorama and mono controls preserve the original mix; +each control combination owns a separate cached source. Existing named-role, +native-name file/string overrides and disabled sounds retain their behavior. +Cache invalidation refreshes the native number lookup after a mod audio edit. + +## Evidence + +- Unchanged native sequencer/VM versus actual host queue: **144 paired Poison + inputs / 1,848 ticks / 144 actual sound controls**, all equal. Inputs cover + all six single/double positions, animation options, stereo/mono and native + hidden/Substitute skips. Every actor's captured display state, HP, status, + protection, and RNG count participates in comparison. +- A common owned PCM carrier runs through the native mixer and host pan + processor: **56 paired cases**, all samples and format metadata equal. + The initially mismatched 32768-Hz carrier fixture is retained; the qualified + fixture uses the native default 44100-Hz input, without rewriting results. +- Actual host M4A sequencing, source creation and dispatch: **165,455 checks**, + including source separation/reuse, all PCM samples, mono, retained defaults, + file/string overrides, silence and legacy forwarding. Existing mod audio: + **116/116**. Native status presentation unit checks: **3,074/3,074**. +- The broader complete command regression retains **11,520 multi-hit rows**, + **1,920 Mirror Move rows** and **360 Substitute-controller cases**. The 192 + poison-command differences are resolved. The complete comparison remains + **failed** on **1,152 animation observations**, all Substitute fade or + Focus Band. No observation, event, RNG field or animation was removed. + +Audio controls are measured at the output source. The Source carrier uses +owned cached PCM; the host carrier runs through its actual M4A sequencer. +The separate mixer comparison covers the same input samples on both engines. +This does not establish whole-synthesizer waveform equality, all public audio +event parity, or final scene pixel equality. CPU hardware doubles launch no +game, audio device or GPU preview and modify no normal cache/save. + +## Remaining gates + +Implement and qualify actual Substitute-fade and Focus Band playback, not +ignored animation rows. Retain the failed broader matrix until every captured +field agrees. Full regression for this runtime, native command revision/link +compatibility review, broader Mirror Move history, real linked roles and actual +public-submission scenarios also remain required before publication or stable +testing-branch integration. + +Raw successes, failed/superseded inputs and complete observations remain in +`tmp/battle-effect-qa/`. Reproduce the focused contracts with: + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode battle_effect_presentation --mode native_sound_pan \ + --report /path/to/new-report.json +``` diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1392d4c38..cb2cc9363 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2587,6 +2587,8 @@ function BattleState:updateQueue() end if self._gbaSubstituteTransition and require("src.battle.Gen3SubstitutePresentation").update(self) then return true end + if self._gbaEffectPresentation + and require("src.battle.Gen3EffectPresentation").update(self) then return true end -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2706,8 +2708,9 @@ function BattleState:updateQueue() end if not self.current then local item = table.remove(self.queue, 1) - while item and item.gbaSubstituteSpecial do - if require("src.battle.Gen3SubstitutePresentation").start(self, item) then return true end + while item and (item.gbaSubstituteSpecial or item.gbaNativeEffect) do + local controller = item.gbaSubstituteSpecial and "Gen3SubstitutePresentation" or "Gen3EffectPresentation" + if require("src.battle." .. controller).start(self, item) then return true end item = table.remove(self.queue, 1) end if not item then @@ -11413,6 +11416,8 @@ end function BattleState:gen3AnimShake(battler) local substituteX = GameVersion.isGen3() and battler and require("src.battle.Gen3SubstitutePresentation").offset(self, battler) or 0 + local effect = self._gbaEffectPresentation + if effect and effect.user == battler then return substituteX + effect.x, effect.y end -- SINNOH'S ANIMATOR SPEAKS THE SAME THREE METHODS, which is why -- it plugs in here rather than bringing a second draw path: a -- second one would have none of the grow-in scale, hit shake or @@ -11457,6 +11462,10 @@ end -- draws reproduce exactly: the sprite dimmed by (1 - c), and the colour added -- back at c through the sprite's own alpha. function BattleState:gen3AnimTint(battler) + local effect = self._gbaEffectPresentation + if effect and effect.user == battler and effect.coeff > 0 then + return effect.color[1], effect.color[2], effect.color[3], effect.coeff + end -- SINNOH'S ANIMATOR SPEAKS THE SAME THREE METHODS, which is why -- it plugs in here rather than bringing a second draw path: a -- second one would have none of the grow-in scale, hit shake or diff --git a/src/battle/Gen3EffectPresentation.lua b/src/battle/Gen3EffectPresentation.lua new file mode 100644 index 000000000..241e92b53 --- /dev/null +++ b/src/battle/Gen3EffectPresentation.lua @@ -0,0 +1,128 @@ +-- Native status-animation controllers. Presentation never changes battle HP, +-- status, PP or RNG; the battle queue owns its lifetime and native frame clock. +local M = {} +local V = require("src.core.GameVersion") + +function M.queue(battle, user, name) + if not V.isGen3() or type(battle.queue) ~= "table" then + return + end + battle.nextInsert = (battle.nextInsert or 0) + 1 + table.insert(battle.queue, battle.nextInsert, { + anim = name, + gbaNativeEffect = true, + attackerIsPlayer = user.isPlayer, + attackerPosition = user.position, + targetPosition = user.position, + }) +end + +local function startFade(from, target) + -- The initialization callback performs the first background half-step. + return { y = from, target = target, toggle = 1, delay = 0, active = true } +end + +local function fade(state) + local f = state.fade + if not f.active then + return + end + if f.finishing ~= nil then + if f.finishing == 4 then + f.active = false + else + f.finishing = f.finishing + 1 + end + return + end + if f.toggle == 0 then + if f.delay < 2 then + f.delay = f.delay + 1 + return + end + f.delay = 0 + f.toggle = 1 + return + end + state.coeff = f.y / 16 + local k = state.coeff + state.color = k > 0 and { (30 / 31 * k) / k, 0, (1 * k) / k } or { 0, 0, 0 } + f.toggle = 0 + if f.y == f.target then + f.finishing = 0 + else + f.y = f.y + (f.target > f.y and 2 or -2) + end +end + +function M.start(battle, row) + if not V.isGen3() or row.anim ~= "POISON" then + return false + end + local user = row.attackerPosition ~= nil and battle:battlerAt(row.attackerPosition) + or (row.attackerIsPlayer and battle.player or battle.enemy) + if not user or not battle:animationsOn() or user.invulnerable or (user.substituteHP or 0) > 0 then + return false + end + battle._gbaEffectPresentation = { + user = user, + frame = 0, + x = 0, + y = 0, + coeff = 0, + color = { 0, 0, 0 }, + sound = V.get() == "firered" and 141 or 148, + soundLeft = 6, + soundWait = 0, + } + return true +end + +function M.update(battle) + local state = battle._gbaEffectPresentation + if not state then + return false + end + state.frame = state.frame + 1 + -- The native sound task precedes shake and palette callbacks. Its + -- thirteen-frame counter plays again on the following callback. + if state.soundWait > 0 then + state.soundWait = state.soundWait - 1 + elseif state.soundLeft > 0 then + local options = battle.game and battle.game.save and battle.game.save.options or {} + require("src.core.Sound").playId( + battle.data, + state.sound, + { pan = state.user.isPlayer and -64 or 63, mono = options.stereo == false } + ) + state.soundLeft, state.soundWait = state.soundLeft - 1, 13 + end + if state.frame == 1 then + state.x, state.shakes, state.shakeWait = 1, 18, 1 + state.fade, state.cycles = startFade(0, 12), 1 + else + if state.shakes > 0 then + if state.shakeWait > 0 then + state.shakeWait = state.shakeWait - 1 + else + state.shakes, state.shakeWait = state.shakes - 1, 2 + state.x = state.shakes == 0 and 0 or -state.x + end + end + if not state.fade.active and state.cycles > 0 then + state.fade, state.cycles = startFade(12, 0), state.cycles - 1 + end + fade(state) + end + if state.soundLeft == 0 and state.shakes == 0 and not state.fade.active and state.cycles == 0 then + battle._gbaEffectPresentation = nil + return false + end + return true +end + +function M.view(battle, user) + local state = battle._gbaEffectPresentation + return state and state.user == user and state or nil +end +return M diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index b7f998554..c94e57007 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -81,6 +81,7 @@ function StatusRegistry.inflict(battle, target, status, opts) Runtime.emit("battle.status_inflicted", { battle = battle, target = target, status = opts.nativeGbaSecondary and opts.toxic and "TOX" or status, source = opts.source, }) + if status == "PSN" then require("src.battle.Gen3EffectPresentation").queue(battle, target, "POISON") end -- Gen II status berries resolve immediately after the condition lands. -- Keep the infliction text first, then append the held-item cure line. for _, msg in ipairs(HeldItems.onStatus(battle, target)) do diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 288901d82..92cc9c874 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -674,6 +674,9 @@ function ChipAudio.newM4AEffect(data, def) if not song then return nil end local sd = synthFor("m4a").renderEffect(data, song) if not sd then return nil end + if def.nativePan ~= nil or def.nativeMono ~= nil then + sd = require("src.core.NativeSoundPan").apply(sd, def.nativePan, def.nativeMono) + end return love.audio.newSource(sd, "static") end diff --git a/src/core/NativeSoundPan.lua b/src/core/NativeSoundPan.lua new file mode 100644 index 000000000..658ad5bbf --- /dev/null +++ b/src/core/NativeSoundPan.lua @@ -0,0 +1,31 @@ +-- GBA PlaySE12PanpotControl: trim the opposite channel without recentering +-- the track's original stereo mix. Mono folds the trimmed channels together. +local M = {} +function M.value(pan) + pan = tonumber(pan) or 0 + if pan ~= pan or pan == math.huge or pan == -math.huge then + return 0 + end + return math.max(-64, math.min(63, pan)) +end +function M.apply(source, pan, mono) + pan = M.value(pan) / 64 + local left, right = 1 - math.max(0, pan), 1 - math.max(0, -pan) + local count, channels = source:getSampleCount(), source:getChannelCount() + local result = love.sound.newSoundData(count, source:getSampleRate(), source:getBitDepth(), mono and 1 or 2) + local function bounded(value) + return math.max(-1, math.min(1, value)) + end + for i = 0, count - 1 do + local l = bounded(source:getSample(i, 1) * left) + local r = bounded(source:getSample(i, channels > 1 and 2 or 1) * right) + if mono then + result:setSample(i, (l + r) / 2) + else + result:setSample(i, 1, l) + result:setSample(i, 2, r) + end + end + return result +end +return M diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..dd2ce9be8 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -299,10 +299,11 @@ end -- still right, it is just quieter, and inventing a substitute sound would be -- worse than silence. local byNumber = nil -function Sound.playId(data, id) +function Sound.playId(data, id, options) id = tonumber(id) local sfx = data and data.audio and data.audio.sfx - if not (id and sfx) then return end + if not id then return end + sfx = sfx or {} if byNumber == nil or byNumber.__sfx ~= sfx then byNumber = { __sfx = sfx } for name, def in pairs(sfx) do @@ -311,6 +312,34 @@ function Sound.playId(data, id) end end local name = byNumber[id] + if require("src.core.GameVersion").isGen3() then + if id ~= id or id < 0 or id > 65535 or id % 1 ~= 0 then return end + local songKey = ("SONG_%03X"):format(id) + local song = data and data.audio and data.audio.songs and data.audio.songs[songKey] + if song and (not name or type(options) == "table") then + name = name or song.sfxName or songKey + local def = {} + local selected = sfx[name] + if selected == false then return end + if type(selected) == "string" then + def.file = selected + else + for key, value in pairs(selected or { m4a = id }) do def[key] = value end + end + options = type(options) == "table" and options or {} + if options.pan ~= nil or options.mono ~= nil then + def.nativePan = require("src.core.NativeSoundPan").value(options.pan) + def.nativeMono = options.mono and true or false + end + local key = name .. "@native:" .. tostring(def.nativePan) .. ":" .. tostring(def.nativeMono) + local src = playPath(data, key, def) + if src then + if ducks(data, name, def) then require("src.core.Music").duckForFanfare(src) end + played("sfx", name) + end + return src + end + end if not name then return end return Sound.play(data, name) end @@ -781,6 +810,7 @@ end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) + byNumber = nil local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end diff --git a/tests/enemy_actions/battle_effect_presentation.lua b/tests/enemy_actions/battle_effect_presentation.lua new file mode 100644 index 000000000..52c6f73e3 --- /dev/null +++ b/tests/enemy_actions/battle_effect_presentation.lua @@ -0,0 +1,161 @@ +-- Real native VM / host queue, complete tick states and measured sound controls. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) + local Anim, Seq, Runtime + if native then + Anim, Seq = require("src.core.game3.battle.anim"), require("src.core.game3.battle.anim_seq") + Runtime = require("src.core.game3.runtime") + Anim.loadPack({ + version = 5, + tags = {}, + statusNames = { [0] = "STATUS_PSN" }, + status = { + [0] = { + { op = "loopsewithpan", pan = 63, se = version == "firered" and 141 or 148, times = 6, wait = 13 }, + { op = "createvisualtask", task = "ShakeMon2", priority = 2, args = { 0, 1, 0, 18, 2 } }, + { + op = "createvisualtask", + task = "BlendColorCycle", + priority = 2, + args = { 2, 2, 2, 0, 12, 31774 }, + }, + { op = "end" }, + }, + }, + }) + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, scene in ipairs({ false, true }) do + for _, stereo in ipairs({ false, true }) do + for _, kind in ipairs({ "ordinary", "substitute", "hidden" }) do + local b = f.field(double) + local user, reached, draws = f.actor(actor), false, 0 + user.substituteHP = kind == "substitute" and 100 or nil + user.mon.status = "PSN" + b.rng = function() + draws = draws + 1 + error("presentation cannot draw battle RNG") + end + hardware.reset() + hardware.stereo(stereo) + if native then + require("src.core.game3.battle")._st = b + user.status = "PSN" + user.semiInvulnerable = kind == "hidden" and "ON_AIR" or nil + Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } + Anim.reset({ headless = false, double = double }) + for id = 0, double and 3 or 1 do + Anim.present(id).visible = true + end + Anim.setSubstitute(actor, kind == "substitute") + if kind == "hidden" then + Anim.present(actor).visible = false + end + Seq.beginEvents( + { { kind = "anim", anim = "status", name = "POISON", attackerId = actor, targetId = actor } }, + function() end + ) + else + f.game.save.options.animations = scene + user.invulnerable = kind == "hidden" or nil + b.picFx = kind == "hidden" and { [user] = { hidden = true } } or nil + b.current, b.waitFrames, b.nextInsert = nil, nil, 0 + b._gbaEffectPresentation = nil + b.queue = { + { + anim = "POISON", + gbaNativeEffect = true, + attackerPosition = actor, + targetPosition = actor, + attackerIsPlayer = user.isPlayer, + }, + { + fn = function() + reached = true + end, + }, + } + end + local frames, done = {}, false + for tick = 0, 200 do + hardware.tick(tick) + if native then + if tick > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + done = Seq.update() + end + else + b:updateQueue() + done = reached + end + local states = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local state + if native then + local p = Anim.present(id) + state = { + x = p.ox or 0, + y = p.oy or 0, + alpha = p.alpha or 1, + visible = p.visible ~= false, + substitute = p.substitute or false, + monbg = p.monbg or false, + coeff = p.blendCoeff or 0, + color = p.blendColor or { 0, 0, 0 }, + } + else + local x, y = b:gen3AnimShake(a) + local r, g, blue, coeff = b:gen3AnimTint(a) + state = { + x = x, + y = y, + alpha = 1, + visible = not (b.picFx and b.picFx[a] and b.picFx[a].hidden), + substitute = b:substituteVisible(a), + monbg = false, + coeff = coeff or 0, + color = { r or 0, g or 0, blue or 0 }, + } + end + state.hp, state.substituteHP = a.mon.hp, a.substituteHP or 0 + state.status = a.mon.status + states[#states + 1] = state + end + frames[#frames + 1] = { tick = tick, done = done, states = states, rng = draws } + if done then + break + end + end + assert(done, "native status animation must finish") + f.T.eq( + #hardware.trace(), + scene and kind == "ordinary" and 6 or 0, + "actual native poison sound sequence" + ) + rows[#rows + 1] = { + double = double, + actor = actor, + scene = scene, + stereo = stereo, + case = kind, + frames = frames, + sounds = hardware.trace(), + } + end + end + end + end + end + hardware.close() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_sound_pan.lua b/tests/enemy_actions/native_sound_pan.lua new file mode 100644 index 000000000..b4b47a50d --- /dev/null +++ b/tests/enemy_actions/native_sound_pan.lua @@ -0,0 +1,50 @@ +-- Common PCM carrier isolates native stereo/mono control from synthesis. +-- Every resulting sample and all format metadata participate in comparison. +local M = {} +function M.run(root, backend) + local T = require("tests.modkit") + local native = backend == "gen1" + local apply = native and require("src.core.game3.audio")._buildSeSoundData + or require("src.core.NativeSoundPan").apply + local left = { -1, -0.75, -0.25, 0, 0.125, 0.5, 0.75, 1 } + local other = { 1, 0.25, -0.5, 0.75, -0.125, 0, -1, 0.5 } + local rows = {} + for _, channels in ipairs({ 1, 2 }) do + local right = channels == 1 and left or other + for _, mono in ipairs({ false, true }) do + for _, pan in ipairs({ -100, -64, -32, 0, 32, 63, 100 }) do + local source = love.sound.newSoundData(#left, 44100, 16, channels) + for i = 1, #left do + source:setSample(i - 1, 1, left[i]) + if channels == 2 then + source:setSample(i - 1, 2, right[i]) + end + end + local result = native and apply(left, right, 1, pan, mono) or apply(source, pan, mono) + local samples = {} + for i = 0, result:getSampleCount() - 1 do + local frame = {} + for c = 1, result:getChannelCount() do + frame[c] = result:getSample(i, c) + end + samples[#samples + 1] = frame + T.eq(source:getSample(i, 1), left[i + 1], "panning retains original carrier samples") + if channels == 2 then + T.eq(source:getSample(i, 2), right[i + 1], "panning retains original right channel") + end + end + rows[#rows + 1] = { + inputChannels = channels, + mono = mono, + pan = pan, + sampleRate = result:getSampleRate(), + bitDepth = result:getBitDepth(), + channels = result:getChannelCount(), + samples = samples, + } + end + end + end + return rows +end +return M diff --git a/tests/engine/gen3_effect_presentation.lua b/tests/engine/gen3_effect_presentation.lua new file mode 100644 index 000000000..b1b1eb4f4 --- /dev/null +++ b/tests/engine/gen3_effect_presentation.lua @@ -0,0 +1,34 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/battle_effect_presentation.lua").run(".", "gen2", version) + T.eq(#rows, 72, "all native actor/options/protection inputs execute") + for _, row in ipairs(rows) do + local last = row.frames[#row.frames] + T.check(last.done, "actual native effect queue completes") + for _, frame in ipairs(row.frames) do + T.eq(frame.rng, 0, "presentation never consumes battle RNG") + end + if row.scene and row.case == "ordinary" then + T.eq(#row.frames, 72, "native poison waits for all six sounds and visual tasks") + T.eq(row.frames[2].states[row.actor + 1].x, 1, "native initial shake starts on the actual actor") + T.eq( + row.frames[27].states[row.actor + 1].coeff, + 0.75, + "native first color cycle reaches twelve of sixteen" + ) + T.eq(last.states[row.actor + 1].x, 0, "native shake restores its original coordinate") + T.eq(last.states[row.actor + 1].coeff, 0, "native palette cycle releases its tint") + for i, sound in ipairs(row.sounds) do + T.eq(sound.tick, 1 + (i - 1) * 14, "native loop counter owns the sound timing") + T.eq(sound.id, version == "firered" and 141 or 148, "native sound index follows the actual edition") + T.eq(sound.pan, row.actor % 2 == 0 and -64 or 63, "actual output source uses native side panorama") + T.eq(sound.mono, not row.stereo, "actual output source respects the native sound option") + end + else + T.eq(#row.frames, 1, "native skipped status animation adds no waiting tick") + T.eq(#row.sounds, 0, "native skipped status animation is silent") + end + end +end +T.finish("native GBA status presentation and actual sound controls") diff --git a/tests/engine/gen3_native_sound.lua b/tests/engine/gen3_native_sound.lua new file mode 100644 index 000000000..f51fd3a37 --- /dev/null +++ b/tests/engine/gen3_native_sound.lua @@ -0,0 +1,115 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Sound = require("src.core.GameVersion"), require("src.core.Sound") +local previousAudio, sources = love.audio, {} +local Source = {} +Source.__index = Source +function Source:play() + self.plays = self.plays + 1 +end +function Source:stop() end +function Source:setVolume(value) + self.volume = value +end +function Source:setLooping() end +love.audio = { + newSource = function(input) + local source = setmetatable({ input = input, plays = 0 }, Source) + sources[#sources + 1] = source + return source + end, +} +V.set("firered") +-- An owned native M4A square-wave instrument and one finite note. Exercise +-- the real sequencer, sound construction, native-ID dispatch and pan cache. +local bytes = {} +for i = 1, 64 do + bytes[i] = 0 +end +bytes[1], bytes[2], bytes[11], bytes[12] = 1, 60, 15, 1 +local program = { 0xBD, 0, 0xBE, 127, 0xBF, 64, 0xD0, 60, 127, 0x81, 0xB1 } +for i, byte in ipairs(program) do + bytes[32 + i] = byte +end +local chars = {} +for i, byte in ipairs(bytes) do + chars[i] = string.char(byte) +end +love.filesystem.write("owned-native-effect.bin", table.concat(chars)) +local data = { + audio = { + sfx = {}, + songs = { + SONG_08D = { index = 141, tracks = { 32 }, voicegroup = 0, sfxName = "OWNED_NATIVE_EFFECT" }, + }, + gen3 = { base = 0, file = "owned-native-effect.bin" }, + }, +} +Sound.invalidate() +local plain = assert(Sound.playId(data, 141), "native effect without a role must play") +T.eq(plain.input:getChannelCount(), 2, "actual M4A carrier is stereo") +T.check(plain.input:getSampleCount() >= 64, "actual native effect is synthesized") +local audible = false +for i = 0, plain.input:getSampleCount() - 1 do + if math.abs(plain.input:getSample(i, 1)) > 0.01 then + audible = true + break + end +end +T.check(audible, "the carrier contains a real synthesized note") +for _, mono in ipairs({ false, true }) do + for _, pan in ipairs({ -64, -32, 0, 32, 63 }) do + local played = assert(Sound.playId(data, 141, { pan = pan, mono = mono })) + T.eq( + played, + Sound.playId(data, 141, { pan = pan, mono = mono }), + "identical controls reuse only their own source" + ) + T.neq(played, plain, "panning leaves the unmodified carrier source independent") + local pcm = played.input + T.eq(pcm:getSampleCount(), plain.input:getSampleCount(), "pan preserves the actual effect length") + T.eq(pcm:getSampleRate(), plain.input:getSampleRate(), "pan preserves the native carrier rate") + T.eq(pcm:getChannelCount(), mono and 1 or 2, "mono folds after native panorama gains") + for i = 0, pcm:getSampleCount() - 1 do + local l = plain.input:getSample(i, 1) * (1 - math.max(0, pan / 64)) + local r = plain.input:getSample(i, 2) * (1 - math.max(0, -pan / 64)) + T.eq(pcm:getSample(i, 1), mono and (l + r) / 2 or l, "actual source retains native left/mono sample") + if not mono then + T.eq(pcm:getSample(i, 2), r, "actual source retains native right sample") + end + end + end +end +T.eq(Sound.playId(data, 141), plain, "subsequent default playback retains its original cache") +for _, id in ipairs({ -1, 0.5, 65536, math.huge }) do + T.eq(Sound.playId(data, id), nil, "invalid native sound numbers are refused") +end +local Pan = require("src.core.NativeSoundPan") +T.eq(Pan.value(0 / 0), 0, "invalid panorama cannot enter PCM output") +T.eq(Pan.value("63"), 63, "numeric control strings resolve consistently") +data.audio.sfx.ROLE = { m4a = 141 } +Sound.invalidate() +T.eq(Sound.playId(data, 141), Sound.play(data, "ROLE"), "existing named-role playback retains its path") +data.audio.sfx = { OWNED_NATIVE_EFFECT = { file = "owned-override.wav" } } +Sound.invalidate() +T.eq( + Sound.playId(data, 141, { pan = 63 }).input, + "owned-override.wav", + "native-name file override retains priority" +) +data.audio.sfx.OWNED_NATIVE_EFFECT = "owned-string.wav" +Sound.invalidate() +T.eq( + Sound.playId(data, 141, { pan = -64 }).input, + "owned-string.wav", + "native-name string file retains priority" +) +data.audio.sfx.OWNED_NATIVE_EFFECT = false +Sound.invalidate() +T.eq(Sound.playId(data, 141, { pan = -64 }), nil, "disabled native-name sound remains silent") +V.set("red") +data.audio.sfx = {} +T.eq(Sound.playId(data, 141), nil, "native song fallback does not alter legacy sound dispatch") +Sound.invalidate() +love.audio = previousAudio +T.finish("native GBA sound number dispatch and stereo control") diff --git a/tests/native_sound/fixture.lua b/tests/native_sound/fixture.lua new file mode 100644 index 000000000..8fe7da524 --- /dev/null +++ b/tests/native_sound/fixture.lua @@ -0,0 +1,135 @@ +-- Owned waveform hardware and native M4A carrier. This does not replace +-- either engine's sound functions or assert equality of their synthesizers. +local M = {} +function M.install(root, native, f) + local previousAudio, currentTick = love.audio, 0 + local trace, carrier = {}, nil + local Audio = native and require("src.core.game3.audio") + local Source = {} + Source.__index = Source + function Source:stop() + self.playing = false + end + function Source:isPlaying() + return self.playing + end + function Source:setVolume() end + function Source:setLooping() end + function Source:clone() + return setmetatable({ input = self.input }, Source) + end + function Source:play() + self.playing = true + local id, pan, mono + if native then + local meta = assert(Audio._seMeta[self], "native source retains its actual sound metadata") + id, pan, mono = meta.id, meta.pan, meta.mono and true or false + else + -- Measure the panorama at the actual output source. The owned M4A + -- carrier has equal channels; every emitted sample must obey the gain. + mono = self.input:getChannelCount() == 1 + for i = 0, self.input:getSampleCount() - 1 do + local l, r = self.input:getSample(i, 1), self.input:getSample(i, 2) + if mono then + local original = carrier:getSample(i, 1) + if math.abs(original) > 0.0001 then + if l == original / 2 then + pan = -64 + else + assert(l == (original / 64 + original) / 2, "native panorama folds before mono output") + pan = 63 + end + end + elseif math.abs(l) + math.abs(r) > 0.0001 then + if r == 0 then + assert(l ~= 0) + pan = -64 + else + assert(l == r / 64, "opponent sound must retain its native stereo gain") + pan = 63 + end + end + end + assert(pan ~= nil, "effect sound must be audible") + end + trace[#trace + 1] = { tick = currentTick, id = id, pan = pan, mono = mono } + end + love.audio = { + newSource = function(input) + return setmetatable({ input = input }, Source) + end, + } + local off, saved + if native then + saved = { ready = Audio._ready, pack = Audio._pack, mono = Audio._mono } + Audio._ready, Audio._pack = true, { index = { songs = {} } } + for _, id in ipairs({ 141, 148, 164, 171 }) do + Audio._pack.index.songs[id] = { kind = "sfx", player = 1 } + Audio._seRawPut(id, false, { 0.25 }, { 0.25 }) + end + else + local bytes = {} + for i = 1, 64 do + bytes[i] = 0 + end + bytes[1], bytes[2], bytes[11], bytes[12] = 1, 60, 15, 1 + for i, byte in ipairs({ 0xBD, 0, 0xBE, 127, 0xBF, 64, 0xD0, 60, 127, 0x81, 0xB1 }) do + bytes[32 + i] = byte + end + local chars = {} + for i, byte in ipairs(bytes) do + chars[i] = string.char(byte) + end + love.filesystem.write("owned-effect-carrier.bin", table.concat(chars)) + f.data.audio = { sfx = {}, songs = {}, gen3 = { base = 0, file = "owned-effect-carrier.bin" } } + for _, id in ipairs({ 141, 148, 164, 171 }) do + f.data.audio.songs[("SONG_%03X"):format(id)] = { + index = id, + tracks = { 32 }, + voicegroup = 0, + sfxName = "OWNED_NATIVE_" .. id, + } + end + require("src.core.Sound").invalidate() + carrier = assert(require("src.core.M4ASynth").renderEffect(f.data, f.data.audio.songs.SONG_08D)) + off = f.e.api.events:on("sound.played", function(event) + local id = tonumber(event.name:match("^OWNED_NATIVE_(%d+)$")) + if id then + assert(trace[#trace]).id = id + end + end) + end + return { + stereo = function(value) + if native then + Audio._mono = not value + else + f.game.save.options.stereo = value + end + end, + tick = function(value) + currentTick = value + end, + reset = function() + trace = {} + currentTick = 0 + end, + trace = function() + return trace + end, + close = function() + if off then + off() + end + if native then + Audio._ready, Audio._pack, Audio._mono = saved.ready, saved.pack, saved.mono + Audio._seRawClear() + else + require("src.core.Sound").invalidate() + require("src.core.M4ASynth").invalidate() + end + love.audio = previousAudio + end, + } +end +return M diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index da712522c..321e5a616 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,8 @@ MODES = [ + (3, 'battle_effect_presentation'), + (3, 'native_sound_pan'), (3, 'substitute_multi_hit'), (3, 'substitute_presentation'), (3, 'mirror_move'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 63a69af6e..01a59b6ab 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 == "substitute_multi_hit" then +if mode == "battle_effect_presentation" then + rows = dofile(root .. "/tests/enemy_actions/battle_effect_presentation.lua").run(root, backend, version) +elseif mode == "native_sound_pan" then + rows = dofile(root .. "/tests/enemy_actions/native_sound_pan.lua").run(root, backend, version) +elseif mode == "substitute_multi_hit" then rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version, { substitute = true }) elseif mode == "substitute_presentation" then rows = dofile(root .. "/tests/enemy_actions/substitute_presentation.lua").run(root, backend, version) From f4ef88f05139e383b8b4e85e82532fb7c5cd257f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:56:34 +0100 Subject: [PATCH 115/122] Implement native GBA Focus Band presentation and qualify VM callbacks --- docs/native-gen3-focus-band-presentation.md | 29 ++ src/battle/BattleState.lua | 2 + src/battle/Gen3EffectPresentation.lua | 111 ++++++- .../battle_effect_presentation.lua | 301 +++++++++++------- tests/engine/gen3_effect_presentation.lua | 34 +- tools/enemy-action-contract-check/check.py | 9 +- 6 files changed, 363 insertions(+), 123 deletions(-) create mode 100644 docs/native-gen3-focus-band-presentation.md diff --git a/docs/native-gen3-focus-band-presentation.md b/docs/native-gen3-focus-band-presentation.md new file mode 100644 index 000000000..6a950aed5 --- /dev/null +++ b/docs/native-gen3-focus-band-presentation.md @@ -0,0 +1,29 @@ +# Native GBA Focus Band presentation — development checkpoint + +This branch extends `816346bc` and queues the native Focus Band general +animation before the held-on message in both native damage paths. The real +battle queue owns its frame clock, sound, red palette fades and signed +fixed-point movement. Animation options retain the native general-animation +pause. Visible Substitute and hidden actors skip playback; a temporarily +lowered Substitute permits playback without changing its logical HP. + +The complete native sequencer/VM comparison currently matches **384 paired +inputs / 11,112 tick states / 192 sound controls** across FireRed and Emerald. +It covers Poison and Focus Band, all six single/double actor positions, +animation options, mono/stereo, ordinary, hidden, Substitute and lowered +Substitute states. The expanded host unit covers **13,778 checks**. + +The first controller and incomplete Emerald fixture failures are retained in +`tmp/focus-band-qa/`. Emerald requires native picture coordinates to execute +its return task. The owned test dataset supplies those coordinates through +the existing virtual cache filesystem; no reference functions or files are +patched. The comparison checker now rejects native VM error/wait-cap +diagnostics even if the worker exits zero, and fingerprints the sound fixture. + +The reference remains read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The source-bound frozen rerun, +complete damage-command matrix and full regression are still pending at this +checkpoint. Substitute fade, broader Mirror Move/link/public-submit gates +and native command revision review remain prerequisites for final acceptance. +There is no claim of final GPU pixel or complete synthesizer waveform parity. +No PR or playable-branch update has been made for this checkpoint. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index cb2cc9363..743eb4d8e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8595,6 +8595,7 @@ function BattleState:applyDamage(target, dmg, fromMove, adjustment) if prepared and adjustment.endured then self:sayNext(Strings("%s endured\nthe hit!", displayName(target))) elseif prepared and adjustment.hung then + require("src.battle.Gen3EffectPresentation").queue(self, target, "FOCUS_BAND") self:sayNext(Strings("%s hung on\nusing its FOCUS BAND!", displayName(target))) end return gba and absorbed or dmg @@ -8624,6 +8625,7 @@ function BattleState:applyDamage(target, dmg, fromMove, adjustment) if endured then self:sayNext(Strings("%s endured\nthe hit!", displayName(target))) elseif band then + require("src.battle.Gen3EffectPresentation").queue(self, target, "FOCUS_BAND") self:sayNext(require("src.battle.Gen5HoldItems").hungOnLine(self, target) or Strings("%s hung on\nusing its FOCUS BAND!", displayName(target))) end diff --git a/src/battle/Gen3EffectPresentation.lua b/src/battle/Gen3EffectPresentation.lua index 241e92b53..a2dd363a6 100644 --- a/src/battle/Gen3EffectPresentation.lua +++ b/src/battle/Gen3EffectPresentation.lua @@ -1,4 +1,4 @@ --- Native status-animation controllers. Presentation never changes battle HP, +-- Native battle-effect controllers. Presentation never changes battle HP, -- status, PP or RNG; the battle queue owns its lifetime and native frame clock. local M = {} local V = require("src.core.GameVersion") @@ -17,9 +17,17 @@ function M.queue(battle, user, name) }) end -local function startFade(from, target) +local function startFade(from, target, delay, color) -- The initialization callback performs the first background half-step. - return { y = from, target = target, toggle = 1, delay = 0, active = true } + return { + y = from, + target = target, + toggle = 1, + delay = 0, + active = true, + interval = delay or 2, + color = color or { 30 / 31, 0, 1 }, + } end local function fade(state) @@ -36,7 +44,7 @@ local function fade(state) return end if f.toggle == 0 then - if f.delay < 2 then + if f.delay < f.interval then f.delay = f.delay + 1 return end @@ -46,44 +54,119 @@ local function fade(state) end state.coeff = f.y / 16 local k = state.coeff - state.color = k > 0 and { (30 / 31 * k) / k, 0, (1 * k) / k } or { 0, 0, 0 } + state.color = k > 0 and { (f.color[1] * k) / k, (f.color[2] * k) / k, (f.color[3] * k) / k } or { 0, 0, 0 } f.toggle = 0 if f.y == f.target then f.finishing = 0 else - f.y = f.y + (f.target > f.y and 2 or -2) + f.y = f.target > f.y and math.min(f.target, f.y + 2) or math.max(f.target, f.y - 2) end end function M.start(battle, row) - if not V.isGen3() or row.anim ~= "POISON" then + if not V.isGen3() or row.anim ~= "POISON" and row.anim ~= "FOCUS_BAND" then return false end local user = row.attackerPosition ~= nil and battle:battlerAt(row.attackerPosition) or (row.attackerIsPlayer and battle.player or battle.enemy) - if not user or not battle:animationsOn() or user.invulnerable or (user.substituteHP or 0) > 0 then + if not user then + return false + end + local focus = row.anim == "FOCUS_BAND" + local pause = focus and not battle:animationsOn() + local protected = focus and battle:substituteVisible(user) or not focus and (user.substituteHP or 0) > 0 + if not pause and (not battle:animationsOn() or user.invulnerable or protected) then return false end battle._gbaEffectPresentation = { user = user, + kind = row.anim, + pause = pause and 32 or nil, frame = 0, x = 0, y = 0, coeff = 0, color = { 0, 0, 0 }, - sound = V.get() == "firered" and 141 or 148, - soundLeft = 6, + sound = focus and (V.get() == "firered" and 164 or 171) or (V.get() == "firered" and 141 or 148), + soundLeft = focus and 1 or 6, soundWait = 0, } return true end +local function focusStep(state) + if state.frame == 1 then + state.fade = startFade(0, 9, 7, { 1, 0, 0 }) + state.phase, state.remaining = "ramp", 30 + state.jitterWait, state.jitterPeriod, state.jitter, state.amplitude = 0, 1, 0, 2 + state.distance = 0 + elseif state.phase == "ramp" or state.phase == "hold" then + fade(state) + state.jitterWait = state.jitterWait - 1 + if state.jitterWait < 0 then + if state.jitter == 0 then + state.jitter, state.amplitude = state.amplitude, -state.amplitude + else + state.jitter = 0 + end + state.jitterWait = state.jitterPeriod + end + if state.phase == "ramp" then + state.distance = state.distance + 128 + end + state.x = state.jitter + (state.user.isPlayer and -1 or 1) * math.floor(state.distance / 256) + state.remaining = state.remaining - 1 + if state.remaining == 0 then + if state.phase == "ramp" then + state.phase, state.remaining, state.jitterPeriod = "hold", 30, 0 + else + state.phase = "second-fade" + state.fade = startFade(9, 0, 4, { 1, 0, 0 }) + end + end + elseif state.phase == "second-fade" then + -- Sprite callbacks observe fade completion before that tick's tasks. + if not state.fade.active then + state.phase, state.delay = "delay", 6 + else + fade(state) + end + elseif state.phase == "delay" then + if state.delay > 0 then + state.delay = state.delay - 1 + elseif not state.waitReleased then + state.waitReleased = true + else + state.phase, state.remaining, state.origin = "return", 15, state.x + local velocity = -state.x * 256 / 15 + state.velocity = velocity < 0 and math.ceil(velocity) or math.floor(velocity) + state.distance = 0 + end + else + if state.remaining == 0 then + state.x = 0 + return false + end + state.remaining, state.distance = state.remaining - 1, state.distance + state.velocity + state.x = state.origin + math.floor(state.distance / 256) + end + return true +end + function M.update(battle) local state = battle._gbaEffectPresentation if not state then return false end state.frame = state.frame + 1 + if state.pause ~= nil then + if state.pause > 0 then + state.pause = state.pause - 1 + return true + end + battle._gbaEffectPresentation = nil + return false + end -- The native sound task precedes shake and palette callbacks. Its -- thirteen-frame counter plays again on the following callback. if state.soundWait > 0 then @@ -97,7 +180,13 @@ function M.update(battle) ) state.soundLeft, state.soundWait = state.soundLeft - 1, 13 end - if state.frame == 1 then + if state.kind == "FOCUS_BAND" then + if focusStep(state) then + return true + end + battle._gbaEffectPresentation = nil + return false + elseif state.frame == 1 then state.x, state.shakes, state.shakeWait = 1, 18, 1 state.fade, state.cycles = startFade(0, 12), 1 else diff --git a/tests/enemy_actions/battle_effect_presentation.lua b/tests/enemy_actions/battle_effect_presentation.lua index 52c6f73e3..876d4d221 100644 --- a/tests/enemy_actions/battle_effect_presentation.lua +++ b/tests/enemy_actions/battle_effect_presentation.lua @@ -4,13 +4,75 @@ function M.run(root, backend, version) local native = backend == "gen1" local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) - local Anim, Seq, Runtime + local Anim, Seq, Runtime, picPath, savedPic if native then Anim, Seq = require("src.core.game3.battle.anim"), require("src.core.game3.battle.anim_seq") Runtime = require("src.core.game3.runtime") + Runtime.session = { version = version } + local Pic = require("src.core.game3.battle.pic_coords") + picPath = require("src.core.GameVersion").cachePrefix() .. Pic.PACK + savedPic = love.filesystem.read(picPath) + love.filesystem.write(picPath, "return {front={[4]={y=13}},back={[4]={y=8}},elevation={[4]=0}}") + Pic.reset() + assert(Pic.active().front[4] == 13, "owned native pic coordinates must be readable") + assert(Pic.active().back[4] == 8, "owned native back coordinates must be readable") + local generalNames = {} + for i = 0, 8 do + generalNames[i] = "UNUSED_" .. i + end + generalNames[9] = "FOCUS_BAND" Anim.loadPack({ version = 5, tags = {}, + generalNames = generalNames, + general = { + [9] = { + { + op = "createsprite", + template = "gSimplePaletteBlendSpriteTemplate", + callback = "SimplePaletteBlend", + animBattler = "attacker", + args = { 2, 7, 0, 9, 31 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "playsewithpan", pan = -64, se = version == "firered" and 164 or 171 }, + { + op = "createvisualtask", + task = "SlideMonForFocusBand", + priority = 5, + args = { 30, 128, 0, 1, 2, 0, 1 }, + }, + { op = "waitforvisualfinish" }, + { + op = "createsprite", + template = "gSimplePaletteBlendSpriteTemplate", + callback = "SimplePaletteBlend", + animBattler = "attacker", + args = { 2, 4, 9, 0, 31 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "waitforvisualfinish" }, + { op = "delay", frames = 6 }, + { + op = "createsprite", + template = "gSlideMonToOriginalPosSpriteTemplate", + callback = "SlideMonToOriginalPos", + animBattler = "attacker", + args = { 0, 0, 15 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "end" }, + }, + }, statusNames = { [0] = "STATUS_PSN" }, status = { [0] = { @@ -32,129 +94,154 @@ function M.run(root, backend, version) for actor = 0, double and 3 or 1 do for _, scene in ipairs({ false, true }) do for _, stereo in ipairs({ false, true }) do - for _, kind in ipairs({ "ordinary", "substitute", "hidden" }) do - local b = f.field(double) - local user, reached, draws = f.actor(actor), false, 0 - user.substituteHP = kind == "substitute" and 100 or nil - user.mon.status = "PSN" - b.rng = function() - draws = draws + 1 - error("presentation cannot draw battle RNG") - end - hardware.reset() - hardware.stereo(stereo) - if native then - require("src.core.game3.battle")._st = b - user.status = "PSN" - user.semiInvulnerable = kind == "hidden" and "ON_AIR" or nil - Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } - Anim.reset({ headless = false, double = double }) - for id = 0, double and 3 or 1 do - Anim.present(id).visible = true + for _, name in ipairs({ "POISON", "FOCUS_BAND" }) do + for _, kind in ipairs({ "ordinary", "substitute", "lowered-substitute", "hidden" }) do + local b = f.field(double) + local user, draws = f.actor(actor), 0 + user.substituteHP = (kind == "substitute" or kind == "lowered-substitute") and 100 or nil + user.mon.status = "PSN" + b.rng = function() + draws = draws + 1 + error("presentation cannot draw battle RNG") end - Anim.setSubstitute(actor, kind == "substitute") - if kind == "hidden" then - Anim.present(actor).visible = false - end - Seq.beginEvents( - { { kind = "anim", anim = "status", name = "POISON", attackerId = actor, targetId = actor } }, - function() end - ) - else - f.game.save.options.animations = scene - user.invulnerable = kind == "hidden" or nil - b.picFx = kind == "hidden" and { [user] = { hidden = true } } or nil - b.current, b.waitFrames, b.nextInsert = nil, nil, 0 - b._gbaEffectPresentation = nil - b.queue = { - { - anim = "POISON", - gbaNativeEffect = true, - attackerPosition = actor, - targetPosition = actor, - attackerIsPlayer = user.isPlayer, - }, - { - fn = function() - reached = true - end, - }, - } - end - local frames, done = {}, false - for tick = 0, 200 do - hardware.tick(tick) + hardware.reset() + hardware.stereo(stereo) if native then - if tick > 0 then - Anim.update(1 / 60) + require("src.core.game3.battle")._st = b + user.status = "PSN" + user.semiInvulnerable = kind == "hidden" and "ON_AIR" or nil + Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } + Anim.reset({ headless = false, double = double }) + for id = 0, double and 3 or 1 do + Anim.present(id).visible = true end - if not Anim.busy() then - done = Seq.update() + Anim.setSubstitute(actor, kind == "substitute") + if kind == "hidden" then + Anim.present(actor).visible = false end + Seq.beginEvents({ + { + kind = "anim", + anim = name == "POISON" and "status" or "general", + name = name, + attackerId = actor, + targetId = actor, + }, + }, function() end) else - b:updateQueue() - done = reached + f.game.save.options.animations = scene + user.invulnerable = kind == "hidden" or nil + b.picFx = kind == "hidden" and { [user] = { hidden = true } } or nil + b.current, b.waitFrames, b.nextInsert = nil, nil, 0 + b._gbaEffectPresentation = nil + b._gbaSubstitutePresentation = kind == "lowered-substitute" + and { [user] = { substitute = false, lowered = true, x = 0 } } + or nil + b.queue = { + { + anim = name, + gbaNativeEffect = true, + attackerPosition = actor, + targetPosition = actor, + attackerIsPlayer = user.isPlayer, + }, + } end - local states = {} - for id = 0, double and 3 or 1 do - local a = f.actor(id) - local state + local frames, done = {}, false + for tick = 0, 200 do + hardware.tick(tick) if native then - local p = Anim.present(id) - state = { - x = p.ox or 0, - y = p.oy or 0, - alpha = p.alpha or 1, - visible = p.visible ~= false, - substitute = p.substitute or false, - monbg = p.monbg or false, - coeff = p.blendCoeff or 0, - color = p.blendColor or { 0, 0, 0 }, - } + if tick > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + done = Seq.update() + end else - local x, y = b:gen3AnimShake(a) - local r, g, blue, coeff = b:gen3AnimTint(a) - state = { - x = x, - y = y, - alpha = 1, - visible = not (b.picFx and b.picFx[a] and b.picFx[a].hidden), - substitute = b:substituteVisible(a), - monbg = false, - coeff = coeff or 0, - color = { r or 0, g or 0, blue or 0 }, - } + done = not b:updateQueue() + end + local states = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local state + if native then + local p = Anim.present(id) + state = { + x = p.ox or 0, + y = p.oy or 0, + alpha = p.alpha or 1, + visible = p.visible ~= false, + substitute = p.substitute or false, + monbg = p.monbg or false, + coeff = p.blendCoeff or 0, + color = p.blendColor or { 0, 0, 0 }, + } + else + local x, y = b:gen3AnimShake(a) + local r, g, blue, coeff = b:gen3AnimTint(a) + state = { + x = x, + y = y, + alpha = 1, + visible = not (b.picFx and b.picFx[a] and b.picFx[a].hidden), + substitute = b:substituteVisible(a), + monbg = false, + coeff = coeff or 0, + color = { r or 0, g or 0, blue or 0 }, + } + end + state.hp, state.substituteHP = a.mon.hp, a.substituteHP or 0 + state.status = a.mon.status + states[#states + 1] = state + end + frames[#frames + 1] = { tick = tick, done = done, states = states, rng = draws } + if done then + break end - state.hp, state.substituteHP = a.mon.hp, a.substituteHP or 0 - state.status = a.mon.status - states[#states + 1] = state - end - frames[#frames + 1] = { tick = tick, done = done, states = states, rng = draws } - if done then - break end + assert( + done, + ("native %s must finish: actor %d, scene %s, stereo %s, %s"):format( + name, + actor, + tostring(scene), + tostring(stereo), + kind + ) + ) + f.T.eq( + #hardware.trace(), + scene + and (kind == "ordinary" or name == "FOCUS_BAND" and kind == "lowered-substitute") + and (name == "POISON" and 6 or 1) + or 0, + "actual native effect sound sequence" + ) + rows[#rows + 1] = { + double = double, + actor = actor, + scene = scene, + stereo = stereo, + case = kind, + name = name, + frames = frames, + sounds = hardware.trace(), + } end - assert(done, "native status animation must finish") - f.T.eq( - #hardware.trace(), - scene and kind == "ordinary" and 6 or 0, - "actual native poison sound sequence" - ) - rows[#rows + 1] = { - double = double, - actor = actor, - scene = scene, - stereo = stereo, - case = kind, - frames = frames, - sounds = hardware.trace(), - } end end end end end hardware.close() + if native then + if savedPic then + love.filesystem.write(picPath, savedPic) + else + love.filesystem.remove(picPath) + end + require("src.core.game3.battle.pic_coords").reset() + end f.close() return rows end diff --git a/tests/engine/gen3_effect_presentation.lua b/tests/engine/gen3_effect_presentation.lua index b1b1eb4f4..cd07a93d7 100644 --- a/tests/engine/gen3_effect_presentation.lua +++ b/tests/engine/gen3_effect_presentation.lua @@ -2,14 +2,16 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") for _, version in ipairs({ "firered", "emerald" }) do local rows = dofile("tests/enemy_actions/battle_effect_presentation.lua").run(".", "gen2", version) - T.eq(#rows, 72, "all native actor/options/protection inputs execute") + T.eq(#rows, 192, "all native effects/actors/options/protection inputs execute") for _, row in ipairs(rows) do local last = row.frames[#row.frames] T.check(last.done, "actual native effect queue completes") for _, frame in ipairs(row.frames) do T.eq(frame.rng, 0, "presentation never consumes battle RNG") end - if row.scene and row.case == "ordinary" then + local positive = row.scene + and (row.case == "ordinary" or row.name == "FOCUS_BAND" and row.case == "lowered-substitute") + if positive and row.name == "POISON" then T.eq(#row.frames, 72, "native poison waits for all six sounds and visual tasks") T.eq(row.frames[2].states[row.actor + 1].x, 1, "native initial shake starts on the actual actor") T.eq( @@ -25,10 +27,36 @@ for _, version in ipairs({ "firered", "emerald" }) do T.eq(sound.pan, row.actor % 2 == 0 and -64 or 63, "actual output source uses native side panorama") T.eq(sound.mono, not row.stereo, "actual output source respects the native sound option") end + elseif positive then + T.eq(#row.frames, 123, "native Focus Band waits for both fades, delay and signed fixed-point return") + T.eq(#row.sounds, 1, "native Focus Band plays exactly one sound") + local index = row.actor + 1 + T.eq(row.frames[2].states[index].x, 0, "motion initialization does not advance its ramp") + T.eq(row.frames[48].states[index].coeff, 9 / 16, "odd fade target is clamped to nine of sixteen") + T.eq( + row.frames[62].states[index].x, + row.actor % 2 == 0 and -13 or 17, + "native side-specific ramp and jitter" + ) + T.eq( + row.frames[122].states[index].x, + row.actor % 2 == 0 and -1 or 0, + "signed return truncates then shifts" + ) + T.eq(last.states[index].x, 0, "completed Focus Band restores actor position") + T.eq(last.states[index].coeff, 0, "completed Focus Band releases palette ownership") + local sound = row.sounds[1] + T.eq(sound.tick, 1, "native Focus Band sound starts on its first update") + T.eq(sound.id, version == "firered" and 164 or 171, "native Focus Band sound follows edition") + T.eq(sound.pan, row.actor % 2 == 0 and -64 or 63, "Focus Band sound uses the actual actor's panorama") + T.eq(sound.mono, not row.stereo, "Focus Band sound respects native mono/stereo") + elseif row.name == "FOCUS_BAND" and not row.scene then + T.eq(#row.frames, 34, "disabled general animations retain their native 32-frame pause") + T.eq(#row.sounds, 0, "disabled general animations are silent") else T.eq(#row.frames, 1, "native skipped status animation adds no waiting tick") T.eq(#row.sounds, 0, "native skipped status animation is silent") end end end -T.finish("native GBA status presentation and actual sound controls") +T.finish("native GBA status and Focus Band presentation with actual sound controls") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 321e5a616..6bbd00aec 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -88,6 +88,7 @@ def main(): paths = [*sorted((host / "examples/portable_enemy_action").glob("*")), *sorted((host / "tests/post_hit_items").glob("*.json")), *sorted((host / "tests/enemy_actions").glob("*.lua")), Path(__file__).with_name("worker.lua")] + paths.extend(sorted((host / "tests/native_sound").glob("*.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() @@ -117,10 +118,14 @@ def captured(value): captured(error.stderr) + "\nWorker timed out and was terminated.\n") rows = [json.loads(line.removeprefix("ENEMY_ACTION_OBSERVATIONS ")) for line in run.stdout.splitlines() if line.startswith("ENEMY_ACTION_OBSERVATIONS ")] + # The native VM catches task errors and wait caps internally. + # A zero worker exit must not turn an aborted animation into an oracle. + diagnostics = [line for line in run.stdout.splitlines() + if line.startswith("[battle.anim]")] report["runs"].append({"backend": backend, "version": version, "api": api, "mode": mode, "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr, - "timeout": timed_out}) - if run.returncode or len(rows) != 1: + "timeout": timed_out, "native_vm_diagnostics": diagnostics}) + if run.returncode or len(rows) != 1 or diagnostics: failed = True else: values[backend] = rows[0] From 458a65046bcf6e1554ba5234dd5c8647e74eb02b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:04:59 +0100 Subject: [PATCH 116/122] Implement native GBA Substitute fade with shared sprite exchange --- docs/native-gen3-substitute-fade.md | 44 +++++++++++ src/battle/BattleState.lua | 6 ++ src/battle/Gen3EffectPresentation.lua | 74 ++++++++++++++++++- src/battle/Gen3SubstituteArt.lua | 19 ++++- src/battle/Gen3SubstitutePresentation.lua | 56 ++++++++++---- .../battle_effect_presentation.lua | 71 +++++++++++++++--- tests/enemy_actions/substitute_fade.lua | 8 ++ tests/engine/gen3_substitute_art.lua | 35 +++++++++ tests/engine/gen3_substitute_fade.lua | 52 +++++++++++++ tools/enemy-action-contract-check/check.py | 1 + tools/enemy-action-contract-check/worker.lua | 7 +- 11 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 docs/native-gen3-substitute-fade.md create mode 100644 tests/enemy_actions/substitute_fade.lua create mode 100644 tests/engine/gen3_substitute_fade.lua diff --git a/docs/native-gen3-substitute-fade.md b/docs/native-gen3-substitute-fade.md new file mode 100644 index 000000000..3b3194821 --- /dev/null +++ b/docs/native-gen3-substitute-fade.md @@ -0,0 +1,44 @@ +# Native GBA Substitute fade — development checkpoint + +This branch extends Focus Band presentation at `f4ef88f0`. Native damage +queues `SUBSTITUTE_FADE` before the break message and preserves the visible +doll while logical protection is removed. Actual playback executes the native +three-callback alpha decrement, white palette ramp, copied-background lifetime, +script delays and outward/exchange/return swap. The swap shares the previously +qualified controller; it does not maintain a second movement implementation. + +The animation runs with battle animations disabled, following the native +general-animation exception. An already hidden doll exchanges immediately. +The native end callback and end-of-sequence visibility reconciliation remain +separate. Same-side double-battle partners retain their copied-background +state while only the acting battler fades. No HP, status, protection or RNG +is changed by playback. + +The existing native doll renderer now applies the live palette contribution; +the complete battler wrapper applies its live alpha. Color and blend mode are +restored after drawing. CPU graphics doubles exercise actual drawing methods; +no GPU application or normal save/cache is opened. + +Evidence before the frozen regression run: + +- **288 paired fade inputs / 23,488 tick states**, every captured field equal + through the unchanged reference sequencer/VM and actual host battle queue. + All six single/double positions, animation options, mono/stereo, visible, + hidden, lowered and absent Substitute states are included. +- Existing Poison/Focus Band **384 inputs / 11,112 ticks**, and existing native + swap **360 inputs / 5,240 ticks**, remain equal in the same run. All twelve + workers exit zero without native VM diagnostics or timeouts. +- **104,512 checks** cover the full host fade and real damage producer; + **70 checks** cover native art import and drawing, including complete-wrapper + alpha and palette composition. +- Initial incomplete-coordinate and visibility/partner-background failures + remain retained in `tmp/substitute-fade-qa/`. FireRed uses its native built-in + picture coordinates; Emerald uses owned data through the virtual cache. + The corrected fixture explicitly validates each edition's expected input. + +The reference is read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Full command-matrix and complete +regression gates remain pending at this checkpoint. Broader Mirror Move +history/link/import/public-submit gates and native protocol review still +precede final publication and playable-branch integration. This is neither a +GPU scene-parity claim nor an accepted PR. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 743eb4d8e..c1024f07b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2714,6 +2714,9 @@ function BattleState:updateQueue() item = table.remove(self.queue, 1) end if not item then + if self._gbaEffectReconcile then + require("src.battle.Gen3EffectPresentation").reconcile(self) + end if self._gbaSubstitutePresentation then require("src.battle.Gen3SubstitutePresentation").reconcile(self) end @@ -8587,6 +8590,7 @@ function BattleState:applyDamage(target, dmg, fromMove, adjustment) local absorbed = math.min(math.max(0, dmg), target.substituteHP) target.substituteHP = target.substituteHP - dmg if target.substituteHP <= 0 then + require("src.battle.Gen3SubstitutePresentation").broken(self, target) target.substituteHP = nil self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target))) else @@ -11496,6 +11500,8 @@ function BattleState:gen3AnimSpecialMonState(battler) tint = 0, r = 1, g = 1, b = 1, alpha = 1, brightness = 1, mosaic = 1, } + local effect = self._gbaEffectPresentation + if effect and effect.user == battler and effect.alpha then state.alpha = effect.alpha end -- Sinnoh: alpha / mosaic / brightness / white from Gen4MoveAnimExtras. if self.gen4AnimPlaying and self.gen4Anim and self.gen4Anim.monSpecial then local ok4, sp = pcall(self.gen4Anim.monSpecial, self.gen4Anim, battler == self.player, battler) diff --git a/src/battle/Gen3EffectPresentation.lua b/src/battle/Gen3EffectPresentation.lua index a2dd363a6..4ed3439e3 100644 --- a/src/battle/Gen3EffectPresentation.lua +++ b/src/battle/Gen3EffectPresentation.lua @@ -64,7 +64,9 @@ local function fade(state) end function M.start(battle, row) - if not V.isGen3() or row.anim ~= "POISON" and row.anim ~= "FOCUS_BAND" then + if + not V.isGen3() or row.anim ~= "POISON" and row.anim ~= "FOCUS_BAND" and row.anim ~= "SUBSTITUTE_FADE" + then return false end local user = row.attackerPosition ~= nil and battle:battlerAt(row.attackerPosition) @@ -72,6 +74,27 @@ function M.start(battle, row) if not user then return false end + if row.anim == "SUBSTITUTE_FADE" then + local Sub = require("src.battle.Gen3SubstitutePresentation") + local hidden = battle.picFx and battle.picFx[user] and battle.picFx[user].hidden + if battle:substituteVisible(user) and hidden then + local swap = Sub.beginSwap(battle, user, false) + swap.state.substitute = false + return false + end + battle._gbaEffectPresentation = { + user = user, + kind = row.anim, + frame = 0, + x = 0, + y = 0, + alpha = 1, + monbg = false, + coeff = 0, + color = { 0, 0, 0 }, + } + return true + end local focus = row.anim == "FOCUS_BAND" local pause = focus and not battle:animationsOn() local protected = focus and battle:substituteVisible(user) or not focus and (user.substituteHP or 0) > 0 @@ -159,6 +182,38 @@ function M.update(battle) return false end state.frame = state.frame + 1 + if state.kind == "SUBSTITUTE_FADE" then + local frame = state.frame + -- Sixteen alpha decrements every third callback finish at tick 49; + -- task release, waitforvisualfinish and script delays clear monbg at 55. + -- The palette reset and ordinary native swap begin together at tick 57. + if frame <= 56 then + state.monbg = frame < 55 + state.alpha = 1 - math.min(16, math.floor((frame - 1) / 3)) / 16 + state.coeff = math.min(16, frame - 1) / 16 + state.color = state.coeff > 0 and { 1, 1, 1 } or { 0, 0, 0 } + return true + end + local Sub = require("src.battle.Gen3SubstitutePresentation") + if not state.swap then + state.coeff, state.color = 0, { 0, 0, 0 } + state.swap = Sub.beginSwap(battle, state.user, false) + end + local busy = Sub.stepSwap(state.swap) + if state.swap.phase == "in" then + state.alpha = 1 + end + if busy then + return true + end + if battle.picFx and battle.picFx[state.user] then + battle.picFx[state.user].hidden = nil + end + battle._gbaEffectReconcile = battle._gbaEffectReconcile or {} + battle._gbaEffectReconcile[state.user] = true + battle._gbaEffectPresentation = nil + return false + end if state.pause ~= nil then if state.pause > 0 then state.pause = state.pause - 1 @@ -214,4 +269,21 @@ function M.view(battle, user) local state = battle._gbaEffectPresentation return state and state.user == user and state or nil end + +function M.monBg(battle, user) + local state = battle._gbaEffectPresentation + local hidden = battle.picFx and battle.picFx[user] and battle.picFx[user].hidden + return state ~= nil and state.monbg == true and user.isPlayer == state.user.isPlayer and not hidden +end + +function M.reconcile(battle) + for user in pairs(battle._gbaEffectReconcile or {}) do + if user.invulnerable and battle:animationsOn() and user.mon and user.mon.hp > 0 then + battle.picFx = battle.picFx or {} + battle.picFx[user] = battle.picFx[user] or {} + battle.picFx[user].hidden = true + end + end + battle._gbaEffectReconcile = nil +end return M diff --git a/src/battle/Gen3SubstituteArt.lua b/src/battle/Gen3SubstituteArt.lua index 3e6f98589..dfefcb83a 100644 --- a/src/battle/Gen3SubstituteArt.lua +++ b/src/battle/Gen3SubstituteArt.lua @@ -36,7 +36,24 @@ function M.draw(battle, battler) return false end local image = Assets.image(pose.image) - love.graphics.draw(image, pose.x, pose.y) + local g = love.graphics + local r, green, blue, coeff + if battle.gen3AnimTint then + r, green, blue, coeff = battle:gen3AnimTint(battler) + end + if coeff and coeff > 0 then + local cr, cg, cb, ca = g.getColor() + local blend, alpha = g.getBlendMode() + g.setColor(cr * (1 - coeff), cg * (1 - coeff), cb * (1 - coeff), ca) + g.draw(image, pose.x, pose.y) + g.setBlendMode("add") + g.setColor(r * coeff, green * coeff, blue * coeff, ca) + g.draw(image, pose.x, pose.y) + g.setBlendMode(blend, alpha) + g.setColor(cr, cg, cb, ca) + else + g.draw(image, pose.x, pose.y) + end return true end diff --git a/src/battle/Gen3SubstitutePresentation.lua b/src/battle/Gen3SubstitutePresentation.lua index cfc66465a..47645a42f 100644 --- a/src/battle/Gen3SubstitutePresentation.lua +++ b/src/battle/Gen3SubstitutePresentation.lua @@ -96,6 +96,36 @@ local function actor(battle, row) return row.attackerIsPlayer and battle.player or battle.enemy end +function M.beginSwap(battle, user, substitute) + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + states[user] = states[user] or { substitute = protected(user), x = 0 } + local pos = tonumber(user.position) or (user.isPlayer and 0 or 1) + local coords = (battle.data.constants or {}).gen3BattlerCoords + local layout = coords and (battle:isDouble() and coords.doubles or coords.singles) + local point = layout and layout[pos + 1] + return { + user = user, + state = states[user], + phase = "out", + baseX = tonumber(point and point.x) or (user.isPlayer and 72 or 176), + direction = user.isPlayer and -1 or 1, + substitute = substitute, + } +end + +-- Damage calculation can remove the logical protection before its queued +-- break animation runs. Preserve the displayed doll until that command swaps it. +function M.broken(battle, user) + if not V.isGen3() then + return + end + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + states[user] = states[user] or { substitute = true, x = 0 } + require("src.battle.Gen3EffectPresentation").queue(battle, user, "SUBSTITUTE_FADE") +end + function M.start(battle, row) if not V.isGen3() then return false @@ -131,26 +161,13 @@ function M.start(battle, row) return false end end - local pos = tonumber(user.position) or (user.isPlayer and 0 or 1) - local coords = (battle.data.constants or {}).gen3BattlerCoords - local layout = coords and (battle:isDouble() and coords.doubles or coords.singles) - local point = layout and layout[pos + 1] - local baseX = tonumber(point and point.x) or (user.isPlayer and 72 or 176) - battle._gbaSubstituteTransition = { - user = user, - state = state, - phase = "out", - baseX = baseX, - direction = user.isPlayer and -1 or 1, - substitute = not lower, - } + battle._gbaSubstituteTransition = M.beginSwap(battle, user, not lower) return true end -- Native SwapMonSpriteToFromSubstitute moves five pixels per controller tick. -- The exchange happens offscreen, on its own tick, before the return trip. -function M.update(battle) - local transition = battle._gbaSubstituteTransition +function M.stepSwap(transition) if not transition then return false end @@ -169,13 +186,20 @@ function M.update(battle) if state.x * transition.direction <= 0 then state.x = 0 state.substitute = transition.substitute - battle._gbaSubstituteTransition = nil return false end end return true end +function M.update(battle) + if M.stepSwap(battle._gbaSubstituteTransition) then + return true + end + battle._gbaSubstituteTransition = nil + return false +end + function M.reconcile(battle) battle._gbaSubstituteTransition, battle._gbaSubstitutePresentation = nil, nil end diff --git a/tests/enemy_actions/battle_effect_presentation.lua b/tests/enemy_actions/battle_effect_presentation.lua index 876d4d221..60eaa6549 100644 --- a/tests/enemy_actions/battle_effect_presentation.lua +++ b/tests/enemy_actions/battle_effect_presentation.lua @@ -1,6 +1,7 @@ -- Real native VM / host queue, complete tick states and measured sound controls. local M = {} -function M.run(root, backend, version) +function M.run(root, backend, version, options) + options = options or {} local native = backend == "gen1" local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) @@ -15,17 +16,44 @@ function M.run(root, backend, version) love.filesystem.write(picPath, "return {front={[4]={y=13}},back={[4]={y=8}},elevation={[4]=0}}") Pic.reset() assert(Pic.active().front[4] == 13, "owned native pic coordinates must be readable") - assert(Pic.active().back[4] == 8, "owned native back coordinates must be readable") + assert( + Pic.active().back[4] == (version == "firered" and 10 or 8), + "native back coordinates must be readable" + ) local generalNames = {} for i = 0, 8 do generalNames[i] = "UNUSED_" .. i end generalNames[9] = "FOCUS_BAND" + generalNames[2] = "SUBSTITUTE_FADE" Anim.loadPack({ version = 5, tags = {}, generalNames = generalNames, general = { + [2] = { + { op = "monbg", battler = "attacker" }, + { op = "createvisualtask", task = "SubstituteFadeToInvisible", priority = 5, args = {} }, + { + op = "createvisualtask", + task = "BlendBattleAnimPal", + priority = 10, + args = { 2, 0, 0, 16, 32767 }, + }, + { op = "waitforvisualfinish" }, + { op = "delay", frames = 1 }, + { op = "clearmonbg", battler = "attacker" }, + { op = "delay", frames = 2 }, + { op = "blendoff" }, + { + op = "createvisualtask", + task = "BlendBattleAnimPal", + priority = 10, + args = { 2, 0, 0, 0, 32767 }, + }, + { op = "createvisualtask", task = "SwapMonSpriteToFromSubstitute", priority = 2, args = { 1 } }, + { op = "end" }, + }, [9] = { { op = "createsprite", @@ -94,12 +122,17 @@ function M.run(root, backend, version) for actor = 0, double and 3 or 1 do for _, scene in ipairs({ false, true }) do for _, stereo in ipairs({ false, true }) do - for _, name in ipairs({ "POISON", "FOCUS_BAND" }) do - for _, kind in ipairs({ "ordinary", "substitute", "lowered-substitute", "hidden" }) do + for _, name in ipairs(options.names or { "POISON", "FOCUS_BAND" }) do + for _, kind in + ipairs(options.kinds or { "ordinary", "substitute", "lowered-substitute", "hidden" }) + do local b = f.field(double) local user, draws = f.actor(actor), 0 user.substituteHP = (kind == "substitute" or kind == "lowered-substitute") and 100 or nil user.mon.status = "PSN" + local fading = name == "SUBSTITUTE_FADE" + local showing = kind == "substitute" or fading and (kind == "ordinary" or kind == "hidden") + local hidden = kind == "hidden" or kind == "hidden-mon" b.rng = function() draws = draws + 1 error("presentation cannot draw battle RNG") @@ -109,14 +142,14 @@ function M.run(root, backend, version) if native then require("src.core.game3.battle")._st = b user.status = "PSN" - user.semiInvulnerable = kind == "hidden" and "ON_AIR" or nil + user.semiInvulnerable = hidden and "ON_AIR" or nil Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } Anim.reset({ headless = false, double = double }) for id = 0, double and 3 or 1 do Anim.present(id).visible = true end - Anim.setSubstitute(actor, kind == "substitute") - if kind == "hidden" then + Anim.setSubstitute(actor, showing) + if hidden then Anim.present(actor).visible = false end Seq.beginEvents({ @@ -130,13 +163,25 @@ function M.run(root, backend, version) }, function() end) else f.game.save.options.animations = scene - user.invulnerable = kind == "hidden" or nil - b.picFx = kind == "hidden" and { [user] = { hidden = true } } or nil + user.invulnerable = hidden or nil + b.picFx = hidden and { [user] = { hidden = true } } or nil + f.data.constants.gen3BattlerCoords = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { + { x = 32, y = 80 }, + { x = 200, y = 40 }, + { x = 90, y = 88 }, + { x = 152, y = 32 }, + }, + } b.current, b.waitFrames, b.nextInsert = nil, nil, 0 b._gbaEffectPresentation = nil b._gbaSubstitutePresentation = kind == "lowered-substitute" and { [user] = { substitute = false, lowered = true, x = 0 } } or nil + if fading then + b._gbaSubstitutePresentation = { [user] = { substitute = showing, x = 0 } } + end b.queue = { { anim = name, @@ -179,13 +224,14 @@ function M.run(root, backend, version) else local x, y = b:gen3AnimShake(a) local r, g, blue, coeff = b:gen3AnimTint(a) + local special = b:gen3AnimSpecialMonState(a) state = { x = x, y = y, - alpha = 1, + alpha = special and special.alpha or 1, visible = not (b.picFx and b.picFx[a] and b.picFx[a].hidden), substitute = b:substituteVisible(a), - monbg = false, + monbg = require("src.battle.Gen3EffectPresentation").monBg(b, a), coeff = coeff or 0, color = { r or 0, g or 0, blue or 0 }, } @@ -211,7 +257,8 @@ function M.run(root, backend, version) ) f.T.eq( #hardware.trace(), - scene + name ~= "SUBSTITUTE_FADE" + and scene and (kind == "ordinary" or name == "FOCUS_BAND" and kind == "lowered-substitute") and (name == "POISON" and 6 or 1) or 0, diff --git a/tests/enemy_actions/substitute_fade.lua b/tests/enemy_actions/substitute_fade.lua new file mode 100644 index 000000000..97861a773 --- /dev/null +++ b/tests/enemy_actions/substitute_fade.lua @@ -0,0 +1,8 @@ +local M = {} +function M.run(root, backend, version) + return dofile(root .. "/tests/enemy_actions/battle_effect_presentation.lua").run(root, backend, version, { + names = { "SUBSTITUTE_FADE" }, + kinds = { "ordinary", "substitute", "lowered-substitute", "hidden", "hidden-mon", "no-substitute" }, + }) +end +return M diff --git a/tests/engine/gen3_substitute_art.lua b/tests/engine/gen3_substitute_art.lua index bff93cca4..12aaa46b8 100644 --- a/tests/engine/gen3_substitute_art.lua +++ b/tests/engine/gen3_substitute_art.lua @@ -132,6 +132,41 @@ for _, double in ipairs({ false, true }) do T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "doll drawing preserves graphics color state") end end +local paints = {} +battle.gen3AnimTint = function() + return 1, 1, 1, 0.5 +end +graphics.draw = function(...) + paints[#paints + 1] = { args = { ... }, color = { graphics.getColor() }, blend = graphics.getBlendMode() } +end +graphics.setBlendMode("alpha") +T.check(Draw.draw(battle, { position = 0, isPlayer = true }), "native doll accepts live palette fade") +T.eq(#paints, 2, "actual renderer composites both native palette contributions") +T.same(paints[1].color, { 0.1, 0.2, 0.3, 0.7 }, "base draw retains incoming alpha and untinted contribution") +T.same(paints[2].color, { 0.5, 0.5, 0.5, 0.7 }, "white contribution retains incoming alpha") +T.eq(paints[2].blend, "add", "palette contribution adds through sprite coverage") +T.eq(graphics.getBlendMode(), "alpha", "native palette draw restores blend state") +T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "native palette draw restores incoming color") +local B, actor = require("src.battle.BattleState"), { position = 0, isPlayer = true } +battle.gen3AnimMonBgHidden = function() + return false +end +battle.gen3AnimRotate = function() end +battle.gen3AnimSquash = function() end +battle.gen3AnimSpecialMonState = B.gen3AnimSpecialMonState +battle.battlerPic = function() + return images["battle/substitute_back.png"] +end +battle.drawBattlerPicAt = function(self, user) + B.drawSubstituteDoll(self, user) +end +battle._gbaEffectPresentation = { user = actor, alpha = 0.5 } +paints = {} +B.drawBattlerPic(battle, actor, 0, 0, 1) +T.eq(#paints, 2, "complete battler wrapper retains native doll palette draws") +T.eq(paints[1].color[4], 0.35, "real battler wrapper applies native fade alpha to the doll") +T.eq(paints[2].color[4], 0.35, "palette contribution shares native fade alpha") +T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "complete battler wrapper restores incoming alpha") graphics.draw, graphics.newImage = originalDraw, originalNewImage Assets.flush() V.set("red") diff --git a/tests/engine/gen3_substitute_fade.lua b/tests/engine/gen3_substitute_fade.lua new file mode 100644 index 000000000..767c9e347 --- /dev/null +++ b/tests/engine/gen3_substitute_fade.lua @@ -0,0 +1,52 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/substitute_fade.lua").run(".", "gen2", version) + T.eq(#rows, 144, "all native fade actors/options/presentation states execute") + for _, row in ipairs(rows) do + local last, index = row.frames[#row.frames], row.actor + 1 + T.check(last.done, "native fade returns to the actual queue") + T.eq(#row.sounds, 0, "native Substitute fade remains silent") + for _, frame in ipairs(row.frames) do + T.eq(frame.rng, 0, "fade never draws battle RNG") + for _, state in ipairs(frame.states) do + T.eq(state.hp, 400, "fade never changes actual Pokemon HP") + end + end + if row.case == "hidden" then + T.eq(#row.frames, 1, "hidden Substitute exchanges without animation or pause") + else + local x = row.double and ({ 32, 200, 90, 152 })[index] or (row.actor == 0 and 72 or 176) + local outward = row.actor % 2 == 0 and math.floor((x + 32) / 5) + 1 + or math.floor((304 - x - 32) / 5) + 1 + T.eq(#row.frames, 58 + outward * 2, "fade tasks, script delays and swap retain native ticks") + T.eq(row.frames[5].states[index].alpha, 15 / 16, "first alpha decrement follows three callbacks") + T.eq(row.frames[50].states[index].alpha, 0, "sixteen alpha steps finish before sprite exchange") + T.eq(row.frames[18].states[index].coeff, 1, "white palette ramp reaches sixteen of sixteen") + T.eq(row.frames[58].states[index].coeff, 0, "native palette reset precedes outward swap") + T.eq(last.states[index].alpha, 1, "exchange restores Pokemon alpha") + end + T.eq(last.states[index].x, 0, "completed fade releases sprite offset") + T.eq( + last.states[index].substitute, + row.case == "substitute" or row.case == "lowered-substitute", + "queue completion reconciles logical protection after the temporary sprite exchange" + ) + end + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local b, target = f.field(false), f.actor(1) + target.substituteHP = 5 + T.eq( + b:applyDamage(target, 8, true, { bandRolled = true }), + 5, + "real damage absorbs only remaining protection" + ) + T.eq(target.substituteHP, nil, "logical protection breaks immediately") + T.eq(target.mon.hp, 400, "native break does not spill damage into Pokemon HP") + T.eq(b:substituteVisible(target), true, "queued break retains the doll until presentation executes") + T.eq(b.queue[1].anim, "SUBSTITUTE_FADE", "actual damage path queues native general animation") + T.eq(b.queue[1].attackerPosition, 1, "broken recipient owns the animation") + T.check(type(b.queue[2].text) == "string", "native animation precedes the break message") + f.close() +end +T.finish("native GBA Substitute break producer and full fade controller") diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 6bbd00aec..282c8a4aa 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -12,6 +12,7 @@ MODES = [ + (3, 'substitute_fade'), (3, 'battle_effect_presentation'), (3, 'native_sound_pan'), (3, 'substitute_multi_hit'), diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua index 01a59b6ab..5bfd89e6b 100644 --- a/tools/enemy-action-contract-check/worker.lua +++ b/tools/enemy-action-contract-check/worker.lua @@ -2,12 +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 == "battle_effect_presentation" then +if mode == "substitute_fade" then + rows = dofile(root .. "/tests/enemy_actions/substitute_fade.lua").run(root, backend, version) +elseif mode == "battle_effect_presentation" then rows = dofile(root .. "/tests/enemy_actions/battle_effect_presentation.lua").run(root, backend, version) elseif mode == "native_sound_pan" then rows = dofile(root .. "/tests/enemy_actions/native_sound_pan.lua").run(root, backend, version) elseif mode == "substitute_multi_hit" then - rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version, { substitute = true }) + rows = + dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version, { substitute = true }) elseif mode == "substitute_presentation" then rows = dofile(root .. "/tests/enemy_actions/substitute_presentation.lua").run(root, backend, version) elseif mode == "mirror_move_content" then From f9016f1c23d0d97fd21b761d2f76d15c3f38884d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:12:16 +0100 Subject: [PATCH 117/122] Qualify native Mirror Move in linked roles and public imported turns --- docs/native-gen3-mirror-move-contracts.md | 56 +++++++ src/link/Fingerprint.lua | 2 +- tests/engine/gen3_battle_protocol.lua | 6 +- tests/engine/gen3_mirror_link.lua | 139 ++++++++++++++++ .../imported-mirror-smoke.lua | 154 ++++++++++++++++++ 5 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 docs/native-gen3-mirror-move-contracts.md create mode 100644 tests/engine/gen3_mirror_link.lua create mode 100644 tools/enemy-action-contract-check/imported-mirror-smoke.lua diff --git a/docs/native-gen3-mirror-move-contracts.md b/docs/native-gen3-mirror-move-contracts.md new file mode 100644 index 000000000..0a01c0a40 --- /dev/null +++ b/docs/native-gen3-mirror-move-contracts.md @@ -0,0 +1,56 @@ +# Native Mirror Move integration gates — development checkpoint + +This branch extends native Substitute fade at `458a6504`. Its runtime change +advances the native GBA command revision from 16 to 17: additional queued +animation commands and sound callbacks change runtime behavior even with the +same imported records. Party codec revision remains 1. Older native peers +stop before party exchange; non-GBA fingerprints remain unchanged. + +An independent frozen-parent serializer audit changes only the revision token. +It yields native fingerprint `351a5becf7f236a0`, replacing +`b251a4d1054d6555`; legacy fingerprint remains `e33d251961f2a41d` for Red, +Gold, Diamond and Black. The updated protocol suite passes **154 checks**. + +The new actual host/guest/spectator test passes **108 checks** for FireRed and +Emerald. Each role runs two real menu turns: Mirror first fails without +received history, an incoming Tackle records the recipient's history, then +Mirror calls Tackle. All public events, parent/child PP, completed turn hashes, +state components, history and spectator HP agree. This is not a graphical or +full-playthrough test. + +The separate existing-import smoke test executes **24 scenarios / 48 turns / +120 complete move-used events** through public battle snapshots and submitted +fight/move intents. It reads existing FireRed/Emerald imports, registers an +unchanged API-3 Mirror Move where requested, and tests Tackle, Scratch and Growl +with Choice Band or Shell Bell. It checks real received history, failed first +calls, copied public identities, callback errors and parent PP ownership. No +normal cache or save is written. The same helper on frozen #258 fails at the +received-history assertion, establishing a runtime negative control. + +The first imported fixture used the wrong public effect spelling; that failed +log is retained. The shared API-3 effect is `MIRROR_MOVE_EFFECT`, as specified +by the existing contract and used by the earlier cross-engine tests. + +The parent frozen complete command matrix is now fully equal: **32 workers / +14,528 paired cases**, no timeout or native VM diagnostic. It retains every +captured state, event, held read, RNG draw and animation row. The earlier +Substitute/Focus Band/Poison differences have been implemented and qualified, +not removed from comparison. + +Full regression of this exact runtime, broader received-history command +conditions and remaining native profile coverage still require qualification +before final publication or playable-branch integration. The unchanged Gen1 +reference remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +No Gen1 PR or write is involved. GPU scene parity and whole-synthesizer waveform +parity are not established by these CPU contracts. + +Reproduce the existing-import smoke test with: + +```sh +nice -n 19 /path/to/luajit \ + tools/enemy-action-contract-check/imported-mirror-smoke.lua \ + /path/to/existing/Gen2Recomp-cache +``` + +Raw reports and independent digest inputs are retained in +`tmp/mirror-contracts-qa/`. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 22d2607d7..d220de70f 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 16 or nil + return require("src.core.GameVersion").isGen3() and 17 or nil end Fingerprint.nativeBattleRevision = nativeBattleRevision function Fingerprint.nativePartyRevision() diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua index c578ed076..f7f5dc52e 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 15 -> 16 only. +-- Legacy hash retained; native hash independently audits revision 16 -> 17 only. local LEGACY = "e33d251961f2a41d" -local NATIVE = "b251a4d1054d6555" +local NATIVE = "351a5becf7f236a0" 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 16 or nil, "only GBA advertises the new command revision") + T.eq(F.nativeBattleRevision(), gba and 17 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_mirror_link.lua b/tests/engine/gen3_mirror_link.lua new file mode 100644 index 000000000..4dad6fa7e --- /dev/null +++ b/tests/engine/gen3_mirror_link.lua @@ -0,0 +1,139 @@ +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/assist.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 16 + T.check(current.nativeBattleRevision == 17, "native presentation commands advertise revision seventeen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + 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 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = i == 1 and "MOVE_119" or "MOVE_033", pp = 10 } } + game.save.party[1].item = nil + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + 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 got = inbox + inbox = {} + return got + 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 role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.mon.stats.speed = actor.mon.nickname == "HOST" and 200 or 20 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 5, "two turns include failed Mirror, incoming hits and one copied move") + local history = seen[battle] + T.eq(history[1].move, "MIRROR_MOVE", "first turn attempts Mirror before any received move") + T.eq(history[1].called, false, "parent uses public ordinary event") + T.eq(history[3].move, "MIRROR_MOVE", "second turn announces the parent caller") + T.eq(history[4].move, "TACKLE", "second turn copies the received move") + T.eq(history[4].called, true, "copied move is a native child") + T.eq(history[3].pp, 9, "parent event precedes its own PP command") + T.eq(history[4].pp, 8, "copied event follows the parent PP command") + for _, actor in ipairs({ battle.player, battle.enemy }) do + if actor.mon.nickname == "HOST" then + T.eq(actor.gbaLastTakenMove, "MOVE_033", "received history survives actual linked turns") + end + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Mirror Move received history, events and PP in actual linked roles") diff --git a/tools/enemy-action-contract-check/imported-mirror-smoke.lua b/tools/enemy-action-contract-check/imported-mirror-smoke.lua new file mode 100644 index 000000000..8c7ee6b5a --- /dev/null +++ b/tools/enemy-action-contract-check/imported-mirror-smoke.lua @@ -0,0 +1,154 @@ +-- Read existing local imports; submit actual public menu intents for two turns. +-- CPU only, with no writes to normal generated data or saves. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for _, incoming in ipairs({ 33, 10, 45 }) do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[return function(mod) + mod.hooks:wrap("battle.enemy_action",function() + return {move="INCOMING"} + end) + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " mod.exports.api", + [=[ + assert(mod.content.moves:register("PORTABLE_MIRROR",{id="PORTABLE_MIRROR",name="PORTABLE_MIRROR",type="NORMAL",power=0,accuracy=100,pp=10,effect="MIRROR_MOVE_EFFECT"})) + mod.exports.api]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. 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 + entry = entry:gsub( + '"INCOMING"', + ('"%s"'):format(({ [33] = "TACKLE", [10] = "SCRATCH", [45] = "GROWL" })[incoming]) + ) + local f = + require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_MIRROR" or id(119) + local public = assert(f.e.api.content.moves:get(parent)) + assert(public.effect == "MIRROR_MOVE_EFFECT") + b.player.mon.moves = { { id = parent, pp = 10 } } + b.enemy.mon.moves = { { id = id(incoming), pp = 10 } } + for i, actor in ipairs({ b.player, b.enemy }) do + actor.mon.nickname = i == 1 and "PLAYER" or "FOE" + actor.mon.stats.hp, actor.mon.hp = 4000, 4000 + actor.curMoves, actor.abilityOverride = actor.mon.moves, false + end + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + local used, damage, draws = {}, {}, {} + f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { + user = e.user.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + } + end) + f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { user = e.user.mon.nickname, move = e.move.id, damage = e.damage } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + local game, Input = b.game, require("src.core.Input") + game.input, game.stack.states = Input, { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "menu", {}, nil, 0, 1, 1 + local intentId = 0 + local function submit(intent) + local snapshot = assert(f.e.api.battle:snapshot()) + intentId = intentId + 1 + intent.id, intent.revision = intentId, snapshot.revision + local ok, why = f.e.api.battle:submit(intent) + assert(ok, why) + end + for turn = 1, 2 do + submit({ kind = "menu", choice = "fight" }) + for _ = 1, 10 do + if b.phase == "moveSelect" then + break + end + Input.pressed = {} + game.stack:update(1 / 60) + end + assert(b.phase == "moveSelect", "public fight intent opens the actual move menu") + submit({ kind = "move", slot = 1 }) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == turn, "actual submitted imported turn must finish") + assert( + b.player.gbaLastTakenMove == id(incoming), + "native incoming command records real recipient history" + ) + end + assert( + #used == 5 and used[1].move == public.id and not used[1].called, + "first Mirror fails without received history" + ) + assert(used[3].move == public.id and not used[3].called, "second turn announces public caller") + assert( + used[4].move == f.e.api.content.moves:get(id(incoming)).id and used[4].called, + "second turn executes copied public move" + ) + assert(used[3].pp == 9 and used[4].pp == 8, "only parent command spends PP before copied event") + assert(b.player.mon.moves[1].pp == 8, "two actual selected commands spend exactly two PP") + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration stay error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + incoming = incoming, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + received = b.player.gbaLastTakenMove, + pp = b.player.mon.moves[1].pp, + } + f.close() + end + end + end +end +print("IMPORTED_MIRROR_SMOKE " .. require("src.link.Json").encode(rows)) From 74602df7a96205fbc92d563e4466ada399842756 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:19:05 +0100 Subject: [PATCH 118/122] Cover native Mirror Move KO and self-target receipt histories --- docs/native-gen3-mirror-history-gates.md | 28 ++++++++++++++++++++++++ tests/enemy_actions/mirror_move.lua | 13 ++++++++++- tests/engine/gen3_mirror_move.lua | 20 +++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen3-mirror-history-gates.md create mode 100644 tests/engine/gen3_mirror_move.lua diff --git a/docs/native-gen3-mirror-history-gates.md b/docs/native-gen3-mirror-history-gates.md new file mode 100644 index 000000000..fc200d75a --- /dev/null +++ b/docs/native-gen3-mirror-history-gates.md @@ -0,0 +1,28 @@ +# Native Mirror Move received-history gates + +This test-only branch extends `f9016f1c`; runtime files are unchanged. The +native, API-3 registered and Metronome-to-registered-Mirror matrices now include +a second incoming move that KOs the recipient, plus an opponent self-target. +The KO sequence ends without attempting to act with the fainted recipient. +Every previously captured state, event, held read, RNG draw and animation row +remains in comparison. + +The expanded comparison executes **12 workers / 2,208 paired observations** +across FireRed and Emerald, all equal without timeouts or native VM diagnostics. +The actual-host regression adds **994 checks**. A KO preserves the recipient's +earlier successful received-move history; another actor's self-target cannot +overwrite it. The existing miss, immunity, protection, flags, callback mutation, +zero-PP, Pressure, different target, item and Substitute cases remain covered. + +This qualification applies to the registered FireRed/Emerald profiles. A +separate exploratory Ruby/Sapphire run at `f9016f1c` executes the unchanged +reference but the feature-stack host rejects those unregistered versions. +Their separate support PRs must be integrated and tested before extending the +claim; the fixture does not invent version registration or substitute profiles. + +The full runtime regression is tied to the byte-identical parent and remains +pending until its running process completes. Final combined-fork regression, +workflow coverage, source-bound QA receipt and publication remain subsequent +gates. No Gen1 files, upstream branch or normal game data are modified. +Raw expanded observations remain in `tmp/mirror-history-qa/`; the exploratory +profile reports remain in the workspace's `tmp/mirror-rs-profile-qa/`. diff --git a/tests/enemy_actions/mirror_move.lua b/tests/enemy_actions/mirror_move.lua index 37ed7e1f9..a87921b3d 100644 --- a/tests/enemy_actions/mirror_move.lua +++ b/tests/enemy_actions/mirror_move.lua @@ -66,6 +66,8 @@ function M.run(root, backend, version, options) "zero-pp", "pressure", "custom-incoming", + "faint-target", + "self-target", }) do if (kind ~= "other-target" or double) and (kind ~= "custom-incoming" or options.public) then for _, item in ipairs({ 0, 186, 187, 219 }) do @@ -251,6 +253,13 @@ function M.run(root, backend, version, options) end elseif kind == "other-target" then act(foeId, actor < 2 and actor + 2 or actor - 2, 3, "other-target") + elseif kind == "faint-target" then + -- A second received move KOs the recipient. Its earlier valid + -- history must survive; a fainted Pokemon cannot then copy it. + user.mon.hp = 1 + act(foeId, actor, 3, "faint-target") + elseif kind == "self-target" then + act(foeId, foeId, 3, "self-target") end if kind == "zero-pp" then if native then @@ -259,7 +268,9 @@ function M.run(root, backend, version, options) user.curMoves[1].pp = 0 end end - act(actor, foeId, 1, "copy") + if kind ~= "faint-target" then + act(actor, foeId, 1, "copy") + end offHeld() offCrit() offMove() diff --git a/tests/engine/gen3_mirror_move.lua b/tests/engine/gen3_mirror_move.lua new file mode 100644 index 000000000..28a4d9e73 --- /dev/null +++ b/tests/engine/gen3_mirror_move.lua @@ -0,0 +1,20 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/mirror_move.lua").run(".", "gen2", version) + T.eq(#rows, 352, "every native received-history command input executes") + for _, row in ipairs(rows) do + local actor = row.state[row.actor + 1] + if row.case == "faint-target" then + T.eq(actor.hp, 0, "the real second incoming command KOs its recipient") + T.eq(actor.received, 33, "a KO does not replace earlier successful received history") + T.eq(actor.pp[1], 10, "a fainted recipient never attempts Mirror Move") + elseif row.case == "self-target" then + T.eq(actor.received, 33, "another actor's self-target cannot overwrite this recipient's history") + local foe = row.state[row.actor % 2 == 0 and row.actor + 2 or row.actor] + T.eq(foe.last, 10, "the self-targeting actor still records its selected move") + T.eq(actor.pp[1], 9, "Mirror still spends its own selected PP command") + end + end +end +T.finish("native Mirror Move received-history conditions and real action sequences") From f77cb744f9341f400d970aedd37d810624d90cb6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:22:27 +0100 Subject: [PATCH 119/122] Run native presentation and Substitute importer contracts in CI --- .github/workflows/enemy-action-contract.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml index 5ea5832e8..9b62e6baf 100644 --- a/.github/workflows/enemy-action-contract.yml +++ b/.github/workflows/enemy-action-contract.yml @@ -36,6 +36,17 @@ on: - 'tests/engine/gen3_post_hit*.lua' - 'tests/engine/gen3_nature_power*.lua' - 'tests/engine/gen3_assist*.lua' + - 'tests/engine/gen3_mirror*.lua' + - 'tests/engine/gen3_substitute*.lua' + - 'tests/engine/gen3_effect_presentation.lua' + - 'tests/engine/gen3_native_sound.lua' + - 'tests/native_sound/**' + - 'tests/substitute_art/**' + - 'tests/tools/test_gba_substitute_art.py' + - 'tools/gba_substitute_art.py' + - 'tools/gen3_discover.py' + - 'tools/rom_manifest_firered.json' + - 'tools/rom_manifest_emerald.json' - 'tests/post_hit_items/**' - 'tests/held_item/**' - 'tests/charge_required/**' @@ -68,6 +79,8 @@ jobs: path: luajit-runtime - name: Build pinned LuaJIT run: make -C luajit-runtime -j2 + - name: Validate owned Substitute import locator inputs + run: nice -n 19 python3 -m unittest discover -s gen2/tests/tools -p test_gba_substitute_art.py - name: Actual public hook and native move execution run: >- python3 gen2/tools/enemy-action-contract-check/check.py From a215dc3db1432989c8daedef0d09056096806c43 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:30:34 +0100 Subject: [PATCH 120/122] Document qualified native battle contracts and retained failure evidence --- .../native-gen3-battle-effect-presentation.md | 23 ++- docs/native-gen3-focus-band-presentation.md | 21 ++- docs/native-gen3-mirror-history-gates.md | 12 +- docs/native-gen3-mirror-move-contracts.md | 13 +- docs/native-gen3-mirror-move.md | 161 +++++++++++++----- docs/native-gen3-substitute-art.md | 18 +- docs/native-gen3-substitute-fade.md | 19 ++- docs/native-gen3-substitute-presentation.md | 21 ++- 8 files changed, 185 insertions(+), 103 deletions(-) diff --git a/docs/native-gen3-battle-effect-presentation.md b/docs/native-gen3-battle-effect-presentation.md index 190c2b2ed..5ac32802a 100644 --- a/docs/native-gen3-battle-effect-presentation.md +++ b/docs/native-gen3-battle-effect-presentation.md @@ -1,7 +1,6 @@ -# Native GBA battle-effect presentation work in progress +# Native GBA Poison presentation and native sound controls -This branch extends native Substitute art at `bff8ec57`. It is development -work, not an accepted PR or a replacement for the playable testing branch. +This path extends native Substitute art and the existing battle queue. Gen1 remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. ## Implemented native Poison path @@ -37,8 +36,8 @@ Cache invalidation refreshes the native number lookup after a mod audio edit. - The broader complete command regression retains **11,520 multi-hit rows**, **1,920 Mirror Move rows** and **360 Substitute-controller cases**. The 192 poison-command differences are resolved. The complete comparison remains - **failed** on **1,152 animation observations**, all Substitute fade or - Focus Band. No observation, event, RNG field or animation was removed. + **failed at that checkpoint** on **1,152 animation observations**, all + Substitute fade or Focus Band. No observation, event, RNG field or animation was removed. Audio controls are measured at the output source. The Source carrier uses owned cached PCM; the host carrier runs through its actual M4A sequencer. @@ -47,14 +46,14 @@ This does not establish whole-synthesizer waveform equality, all public audio event parity, or final scene pixel equality. CPU hardware doubles launch no game, audio device or GPU preview and modify no normal cache/save. -## Remaining gates +## Combined qualification -Implement and qualify actual Substitute-fade and Focus Band playback, not -ignored animation rows. Retain the failed broader matrix until every captured -field agrees. Full regression for this runtime, native command revision/link -compatibility review, broader Mirror Move history, real linked roles and actual -public-submission scenarios also remain required before publication or stable -testing-branch integration. +Actual Substitute-fade and Focus Band playback are now implemented. The final +32-worker / 14,816-observation matrix passes with all captured fields retained. +The original Poison runtime completes 161/170 engine suites, SDK 3/3 and B30, +with exactly the same 25 inherited FAIL lines as fresh #258. Native revision +17, real linked roles, received-history and public imported-submission evidence +are recorded in `native-gen3-mirror-move.md`. Earlier failures remain retained. Raw successes, failed/superseded inputs and complete observations remain in `tmp/battle-effect-qa/`. Reproduce the focused contracts with: diff --git a/docs/native-gen3-focus-band-presentation.md b/docs/native-gen3-focus-band-presentation.md index 6a950aed5..4a0acf519 100644 --- a/docs/native-gen3-focus-band-presentation.md +++ b/docs/native-gen3-focus-band-presentation.md @@ -1,6 +1,6 @@ -# Native GBA Focus Band presentation — development checkpoint +# Native GBA Focus Band presentation -This branch extends `816346bc` and queues the native Focus Band general +This path queues the native Focus Band general animation before the held-on message in both native damage paths. The real battle queue owns its frame clock, sound, red palette fades and signed fixed-point movement. Animation options retain the native general-animation @@ -20,10 +20,13 @@ the existing virtual cache filesystem; no reference functions or files are patched. The comparison checker now rejects native VM error/wait-cap diagnostics even if the worker exits zero, and fingerprints the sound fixture. -The reference remains read-only at -`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The source-bound frozen rerun, -complete damage-command matrix and full regression are still pending at this -checkpoint. Substitute fade, broader Mirror Move/link/public-submit gates -and native command revision review remain prerequisites for final acceptance. -There is no claim of final GPU pixel or complete synthesizer waveform parity. -No PR or playable-branch update has been made for this checkpoint. +The frozen runtime completes 161/170 engine suites, SDK 3/3 and B30, with +exactly the same 25 inherited FAIL lines as fresh #258. The corrected frozen +controller rerun passes all eight Poison/Focus Band and PCM workers at +`d470eeee`. FireRed retains its built-in back offset 10; the owned Emerald +input uses 8. The earlier incorrect shared-offset assertion remains retained +as a failed fixture. The final 32-worker matrix also passes on the combined +fork. Native revision 17, Substitute fade and linked/imported qualification +are documented in `native-gen3-mirror-move.md`. The reference remains read-only +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; final GPU pixels and whole +synthesizer waveforms are outside these CPU results. diff --git a/docs/native-gen3-mirror-history-gates.md b/docs/native-gen3-mirror-history-gates.md index fc200d75a..eb226c010 100644 --- a/docs/native-gen3-mirror-history-gates.md +++ b/docs/native-gen3-mirror-history-gates.md @@ -1,6 +1,6 @@ # Native Mirror Move received-history gates -This test-only branch extends `f9016f1c`; runtime files are unchanged. The +These tests extend `f9016f1c`; runtime files are unchanged. The native, API-3 registered and Metronome-to-registered-Mirror matrices now include a second incoming move that KOs the recipient, plus an opponent self-target. The KO sequence ends without attempting to act with the fainted recipient. @@ -20,9 +20,11 @@ reference but the feature-stack host rejects those unregistered versions. Their separate support PRs must be integrated and tested before extending the claim; the fixture does not invent version registration or substitute profiles. -The full runtime regression is tied to the byte-identical parent and remains -pending until its running process completes. Final combined-fork regression, -workflow coverage, source-bound QA receipt and publication remain subsequent -gates. No Gen1 files, upstream branch or normal game data are modified. +The byte-identical parent runtime completes 163/172 engine suites, SDK 3/3 +and B30, with exactly the same 25 inherited FAIL lines as fresh #258. The +frozen expanded matrix passes 32 workers / 14,816 paired observations. CI +path coverage includes the new controllers, shared fixtures and importer; +actionlint and all eight importer tests pass. Combined-fork qualification is +recorded in `native-gen3-mirror-move.md`. No Gen1 files, upstream branch or normal game data are modified. Raw expanded observations remain in `tmp/mirror-history-qa/`; the exploratory profile reports remain in the workspace's `tmp/mirror-rs-profile-qa/`. diff --git a/docs/native-gen3-mirror-move-contracts.md b/docs/native-gen3-mirror-move-contracts.md index 0a01c0a40..b0e36af59 100644 --- a/docs/native-gen3-mirror-move-contracts.md +++ b/docs/native-gen3-mirror-move-contracts.md @@ -1,6 +1,6 @@ -# Native Mirror Move integration gates — development checkpoint +# Native Mirror Move integration contracts -This branch extends native Substitute fade at `458a6504`. Its runtime change +This contract extends native Substitute fade. Its runtime change advances the native GBA command revision from 16 to 17: additional queued animation commands and sound callbacks change runtime behavior even with the same imported records. Party codec revision remains 1. Older native peers @@ -37,9 +37,12 @@ captured state, event, held read, RNG draw and animation row. The earlier Substitute/Focus Band/Poison differences have been implemented and qualified, not removed from comparison. -Full regression of this exact runtime, broader received-history command -conditions and remaining native profile coverage still require qualification -before final publication or playable-branch integration. The unchanged Gen1 +The exact runtime at `f9016f1c` completes 163/172 engine suites, SDK 3/3 +and B30, with exactly the same 25 inherited FAIL lines as fresh #258. The +additional KO/self-target history cases pass 994 host checks and extend the +complete matrix to 14,816 equal paired observations. Combined-fork qualification +and the limits of native profile coverage are recorded in +`native-gen3-mirror-move.md`. The unchanged Gen1 reference remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. No Gen1 PR or write is involved. GPU scene parity and whole-synthesizer waveform parity are not established by these CPU contracts. diff --git a/docs/native-gen3-mirror-move.md b/docs/native-gen3-mirror-move.md index 721c4d44e..797a54818 100644 --- a/docs/native-gen3-mirror-move.md +++ b/docs/native-gen3-mirror-move.md @@ -1,44 +1,117 @@ -# Native GBA Mirror Move work in progress - -This development branch builds on Gen2Recomped #258. It is not yet an accepted -PR or a replacement for the combined testing branch. The unchanged Gen1 -reference is read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. - -## Implemented gameplay path - -GBA Mirror Move reads the recipient's received-move history at its native -command, instead of the current target's last selected action. Native move-end -updates this history using the chosen caller's mirror flag after item commands. -Misses, protection, immunities and fainted recipients do not replace it. -Called moves use the existing native child targeting and PP handling. A -registered Mirror Move has no caller animation of its own. The new volatile -history participates in native link signatures; command revision 16 rejects -older semantics. Party codec revision stays 1. The frozen parent serializer -with only the revision token changed independently yields `b251a4d1054d6555`; -legacy game digests remain `e33d251961f2a41d`. - -## Evidence and remaining work - -The original 80 command sequences had 68 differing full observations on #258. -The first implementation matches all 80, retaining complete captured events, -held reads, RNG, PP, damage, actor state and move-animation metadata. - -The expanded native, API-3 registered and Metronome -> registered Mirror Move -matrices execute 1,920 paired observations. All workers exit zero. The complete -comparison is still **failed**: 144 observations differ in Substitute animation -commands. These differences are retained without removing rows or fields. -Actual default animation playback and Substitute transitions must be addressed -before accepting the wider matrix or publishing this PR. Broader history -command conditions, linked-role tests, actual-import scenarios and full -regression checks also remain required. - -Both public APIs reject raw `flags` fields and new writable `native` diagnostic -envelopes. The fixture's initially invalid registrations are retained as failed -observations; the valid registration uses only public fields. Imported/native -moves retain their flags. Newly registered incoming moves with default metadata -are tested separately; this branch does not invent a public flag-writing API. - -Raw successes, negative controls, fixture corrections and failed expanded -comparisons are kept in the ignored local `tmp/mirror-move-qa/` directory. -The fresh #258 baseline remains 157/166 engine suites with the same nine inherited -failing suites; this branch has not yet completed its full regression gate. +# Native GBA Mirror Move and battle presentation + +This change builds on Gen2Recomped #258. One unchanged API-3 move registration +using `MIRROR_MOVE_EFFECT` now executes native GBA received-move semantics. +FireRed and Emerald are the qualified native profiles. The reference remains +read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Gameplay contract + +Mirror Move reads the user's received-move history at its native command, +instead of the opponent's last selected action. Move-end records a successful, +mirror-eligible chosen caller after held-item commands. Misses, protection, +immunities, self-targets and KOs do not overwrite earlier valid history. Native +and registered callers share targeting, child events and parent PP ownership. +Mirror Move has no caller animation of its own. Public registrations use the +existing shared fields; neither raw `flags` nor writable `native` envelopes +are added to the API. + +The history participates in native linked-state signatures. Command revision +17 also covers the added presentation commands and sound callbacks, rejecting +older native peers before battle setup. Party codec revision remains 1. An +independent frozen-parent serializer audit changes only revision 16 -> 17: +GBA digest `351a5becf7f236a0`; legacy digest `e33d251961f2a41d` is unchanged. + +## Native presentation prerequisites + +The expanded complete command comparison exposed existing missing Substitute, +Focus Band and Poison animation paths. These are implemented in the actual +battle queue rather than omitted from observation: + +- Each called move owns its Substitute lowering/restoration group. Multi-hit + calls restore only after the native last-hit command. Sparse queue rows and + holes are preserved. Logical protection is independent of the displayed doll. +- The shared swap controller slides five pixels per update, exchanges offscreen + on its own update and returns. Broken Substitute runs the native alpha fade, + white palette ramp, copied-background lifetime, delays and exchange. +- The importer locates and validates both native doll sheets and palette before + publishing versioned art metadata. Native OAM positions, all double-battle + slots and live controller offsets reach the existing renderer. Existing + caches need a reimport for this art; missing metadata retains the prior path. +- Focus Band runs both red fades, native motion, signed fixed-point return and + edition-specific sound. Poison runs its shake, palette cycles and six sounds. + Animation options, hidden/protected actors and temporarily lowered dolls + follow their separate native rules. +- Numbered native sounds resolve without a named role. Explicit panorama/mono + settings preserve the original cached mix and isolate source variants. + Existing named roles, file/string overrides, silence and cache invalidation + retain their contracts. + +## Validation + +The frozen complete comparison at `74602df7` executes **32 workers / 14,816 +paired observations**, all equal, with every worker exiting zero, no timeout +and no native VM diagnostic. It retains all captured events, held reads, RNG, +PP, damage, actor/display state and animation commands. The combined fork at +`01315149` repeats that comparison successfully and passes the same actual +public-submission smoke test. + +The matrix includes 11,520 multi-hit observations, 2,208 native/registered/ +Metronome-to-registered Mirror observations, 360 swap-controller inputs, +384 Poison/Focus Band inputs, 288 fade inputs and 56 common-PCM inputs. +Controller observation includes every tick: 5,240 swap ticks, 11,112 +Poison/Focus Band ticks and 23,488 fade ticks. Host checks include 994 received- +history, 13,778 effect-controller, 104,512 fade/producer, 70 art/draw and 165,455 +actual M4A sound checks. Eight structural importer tests and actionlint pass. + +Actual host/guest/spectator menus pass 108 checks over two turns per profile, +including events, PP, history and paired completed hashes/state components. +Existing local FireRed/Emerald imports pass 24 scenarios / 48 actual public +fight/move-submitted turns / 120 move-used events, with native or registered +Mirror, Tackle/Scratch/Growl, Choice Band and Shell Bell. + +The original 80-sequence negative control differs on #258 in 68 observations. +The same existing-import helper fails on #258 at the received-history +assertion. Earlier invalid registrations, incomplete coordinates, controller +errors and all differing complete animation reports remain retained privately. +The checker rejects caught native VM task errors and wait caps even when its +process exits zero, and fingerprints the shared sound fixture. + +The runtime at `f9016f1c` completes 163/172 engine suites, SDK 3/3 and B30. +Its exact anchored FAIL-line Counter equals fresh #258 (157/166): the same +25 lines in nine inherited suites, with no additions or removals. The later +history/CI commits change tests and workflow only; their added host suite +passes 994 checks. Fresh combined-fork and stable-baseline full runs are being +qualified separately before the playable branch is advanced. + +## Scope and reproduction + +CPU hardware doubles run unchanged native functions and the actual host queue, +M4A sequencer and drawing methods. Four actual decoded doll sheets match the +read-only reference imports in every RGBA channel: 16,384 pixels, zero changes. +No ROM, cartridge image or private generated pack is committed. Tests write no +normal cache/save and launch no game, audio device or GPU preview. + +This evidence does not establish final scene pixels, whole-synthesizer waveform +identity, all public audio-event timing, arbitrary full-mod compatibility or a +complete playthrough. Ruby/Sapphire are unregistered in this feature stack; +their separate support prerequisites require integration before qualification. + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode substitute_fade --mode battle_effect_presentation \ + --mode native_sound_pan --mode substitute_multi_hit \ + --mode substitute_presentation --mode mirror_move \ + --mode mirror_move_content --mode mirror_move_chained \ + --report /path/to/new-report.json +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-mirror-smoke.lua \ + /path/to/existing/Gen2Recomp-cache +``` + +Component contracts and their earlier failure evidence are documented in +`native-gen3-substitute-presentation.md`, `native-gen3-substitute-art.md`, +`native-gen3-battle-effect-presentation.md`, `native-gen3-focus-band-presentation.md`, +`native-gen3-substitute-fade.md`, `native-gen3-mirror-move-contracts.md` and +`native-gen3-mirror-history-gates.md`. diff --git a/docs/native-gen3-substitute-art.md b/docs/native-gen3-substitute-art.md index d27167b9f..a7b03e29d 100644 --- a/docs/native-gen3-substitute-art.md +++ b/docs/native-gen3-substitute-art.md @@ -1,6 +1,6 @@ -# Native GBA Substitute doll art work in progress +# Native GBA Substitute doll art -This development branch extends the native Substitute command/controller work. +This importer and renderer extend the native Substitute command/controller work. It imports the doll from the player's cartridge and uses its native 64x64 OBJ geometry, including each double-battle slot and the controller's live offsets. Existing caches need a reimport to populate the new art metadata. The previous @@ -39,13 +39,15 @@ included in this branch. - Frozen runtime `bff8ec57`: 159/168 engine suites, SDK 3/3 and B30. The exact anchored FAIL-line Counter matches fresh #258: the same 25 inherited lines, with no additions or removals. The failed broader animation matrix remains - retained separately and is not reported as passing. + retained separately as historical failure evidence. -These checks establish decoded artwork and draw commands. They are not final -scene screenshots or whole-battle graphical acceptance. General/status playback -for Substitute fade, Focus Band and poison remains open in the broader command -matrix; its 1,344 differing observations are retained. Mirror Move history, -linked-role and actual public-submission gates also remain open before a PR. +The extended actual-draw suite now passes 70 checks, including palette +composition, full-wrapper fade alpha and graphics-state restoration. The final +32-worker / 14,816-observation matrix includes implemented Substitute fade, +Focus Band and Poison; all captured fields agree. Historical differing reports +remain retained. History, linked-role and actual public-submission evidence is +recorded in `native-gen3-mirror-move.md`. These CPU checks establish decoded +artwork and draw commands, not final scene screenshots or whole-battle pixels. Raw local QA is retained in `tmp/substitute-art-qa/`. Run the owned checks with: diff --git a/docs/native-gen3-substitute-fade.md b/docs/native-gen3-substitute-fade.md index 3b3194821..2efa14759 100644 --- a/docs/native-gen3-substitute-fade.md +++ b/docs/native-gen3-substitute-fade.md @@ -1,6 +1,6 @@ -# Native GBA Substitute fade — development checkpoint +# Native GBA Substitute fade -This branch extends Focus Band presentation at `f4ef88f0`. Native damage +This controller extends Focus Band presentation. Native damage queues `SUBSTITUTE_FADE` before the break message and preserves the visible doll while logical protection is removed. Actual playback executes the native three-callback alpha decrement, white palette ramp, copied-background lifetime, @@ -19,7 +19,7 @@ the complete battler wrapper applies its live alpha. Color and blend mode are restored after drawing. CPU graphics doubles exercise actual drawing methods; no GPU application or normal save/cache is opened. -Evidence before the frozen regression run: +Controller evidence: - **288 paired fade inputs / 23,488 tick states**, every captured field equal through the unchanged reference sequencer/VM and actual host battle queue. @@ -36,9 +36,10 @@ Evidence before the frozen regression run: picture coordinates; Emerald uses owned data through the virtual cache. The corrected fixture explicitly validates each edition's expected input. -The reference is read-only at -`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Full command-matrix and complete -regression gates remain pending at this checkpoint. Broader Mirror Move -history/link/import/public-submit gates and native protocol review still -precede final publication and playable-branch integration. This is neither a -GPU scene-parity claim nor an accepted PR. +The frozen fade runtime completes 162/171 engine suites, SDK 3/3 and B30, +with exactly the same 25 inherited FAIL lines as fresh #258. Its complete +32-worker matrix passes 14,528 paired cases; the later history extension +passes 14,816. Native revision 17 and linked/imported qualification are +documented in `native-gen3-mirror-move.md`. The reference is read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. CPU state/draw results are not +a final GPU scene-parity claim. diff --git a/docs/native-gen3-substitute-presentation.md b/docs/native-gen3-substitute-presentation.md index 6ccba4cc5..dafb97769 100644 --- a/docs/native-gen3-substitute-presentation.md +++ b/docs/native-gen3-substitute-presentation.md @@ -1,8 +1,7 @@ -# Native GBA Substitute presentation work in progress +# Native GBA Substitute presentation -This branch builds on the unpublished Mirror Move development at -`2f6ae7888d8250ddb7d61f484c003d7ad52d5824`. It is not an accepted PR or the -combined playable testing branch. Gen1 remains a clean read-only reference at +This controller accompanies native Mirror Move received-history support. +Gen1 remains a clean read-only reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. ## Implemented @@ -19,7 +18,7 @@ the Transform/Substitute exceptions. Skipped commands add no waiting tick. The renderer's sprite selection and native OAM Y consult that presentation; queue completion clears its transient state. Legacy battles retain their path. -## Evidence and open gates +## Controller evidence and retained failures - The first 1,920 complete Mirror Move observations now match unchanged Gen1, including all previously differing 144 animation command streams. @@ -33,15 +32,15 @@ queue completion clears its transient state. Legacy battles retain their path. functions are replaced. CPU graphics doubles launch no game or GPU preview. - The expanded multi-hit contract retains all existing state, events and RNG, and adds the complete move/general animation stream. All 11,520 actions - execute, but **1,344 observations still differ** in animation commands: + execute; at this earlier checkpoint **1,344 observations differed** in animation commands: Substitute fade, Focus Band and poison presentation. Those differences and every failed/superseded fixture report remain in local ignored QA output. -The native GBA Substitute doll artwork/import path still needs implementation. -The current legacy doll renderer is not graphical acceptance. Full native -artwork/positioning, general/status animation playback, broader regression and -Mirror Move history/link/actual-import gates remain open. No PR is published -and the stable testing branch is unchanged at this checkpoint. +Native doll import/drawing and all three general/status controllers are now +implemented. The final complete matrix passes 32 workers / 14,816 paired +observations, including every earlier differing animation stream. Full evidence +and scope are recorded in `native-gen3-mirror-move.md`. CPU draw/state checks +are not final GPU scene acceptance. Reproduce the complete paired CPU contracts with: From eab96604b54ee06a9a3dc17ffd34af6443c8403f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:10:05 +0100 Subject: [PATCH 121/122] Bound exhaustive Assist workers and record native contract timings --- src/core/Sound.lua | 6 +++--- tools/enemy-action-contract-check/check.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index dd2ce9be8..42e06d31f 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -295,9 +295,9 @@ end -- anywhere in the ROM. So this answers a number, by looking for a role that -- already plays it. -- --- A number no role covers plays nothing and says nothing: the animation is --- still right, it is just quieter, and inventing a substitute sound would be --- worse than silence. +-- Native GBA numbers without a named role resolve through their imported +-- song metadata. Explicit pan/mono controls own separate source variants; +-- other generations retain their existing named-role lookup. local byNumber = nil function Sound.playId(data, id, options) id = tonumber(id) diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py index 282c8a4aa..96279a536 100644 --- a/tools/enemy-action-contract-check/check.py +++ b/tools/enemy-action-contract-check/check.py @@ -7,6 +7,7 @@ from pathlib import Path import subprocess import sys +import time GEN1_REF = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" @@ -105,8 +106,12 @@ def main(): str(host), backend, version, str(api), mode] # This matrix executes thousands of complete scripts per worker. # Retain a real failure on timeout, including partial raw output. - timeout = 180 if mode == "post_hit_items_execution" else 60 + # These exhaustive matrices resolve thousands of complete scripts. + # Keep a bounded three-minute budget for their native work, + # including low-priority local runs, rather than a 60s cutoff. + timeout = 180 if mode in {"post_hit_items_execution", "assist", "assist_content", "assist_held"} else 60 timed_out = None + started = time.monotonic() try: run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=timeout) except subprocess.TimeoutExpired as error: @@ -117,6 +122,7 @@ def captured(value): "stderr_hex": error.stderr.hex() if isinstance(error.stderr, bytes) else None} run = subprocess.CompletedProcess(cmd, 124, captured(error.stdout), captured(error.stderr) + "\nWorker timed out and was terminated.\n") + elapsed = time.monotonic() - started rows = [json.loads(line.removeprefix("ENEMY_ACTION_OBSERVATIONS ")) for line in run.stdout.splitlines() if line.startswith("ENEMY_ACTION_OBSERVATIONS ")] # The native VM catches task errors and wait caps internally. @@ -125,7 +131,8 @@ def captured(value): if line.startswith("[battle.anim]")] report["runs"].append({"backend": backend, "version": version, "api": api, "mode": mode, "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr, - "timeout": timed_out, "native_vm_diagnostics": diagnostics}) + "timeout": timed_out, "wall_seconds": round(elapsed, 4), + "native_vm_diagnostics": diagnostics}) if run.returncode or len(rows) != 1 or diagnostics: failed = True else: From 62d1e451fad9dd992b6efbd912c09b6e873efba9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:18:27 +0100 Subject: [PATCH 122/122] Record full native battle qualification and source-bound QA receipt --- docs/behavior-porting-notes.md | 10 + docs/native-gen3-mirror-move.md | 18 +- docs/qa/native-gen3-mirror-move.json | 338 +++++++++++++++++++++++++++ 3 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 docs/qa/native-gen3-mirror-move.json diff --git a/docs/behavior-porting-notes.md b/docs/behavior-porting-notes.md index 8693bcef2..cbd88e060 100644 --- a/docs/behavior-porting-notes.md +++ b/docs/behavior-porting-notes.md @@ -513,3 +513,13 @@ What was ported from pokered's engine code and where it came from. - The autopilot run reproduces the original's early flow on real map data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by walking off the mat, connection into Route 1 at matching x. + +## Native GBA received-move history and presentation + +FireRed/Emerald Mirror Move and API-3 `MIRROR_MOVE_EFFECT` registrations use +the recipient's native received-move history, caller eligibility and parent PP +commands. The actual queue executes Substitute lowering/restoration/fade, +Focus Band and Poison controllers with native sound controls; native doll +art comes from the user's cartridge. [Contract and validation](native-gen3-mirror-move.md) +describe the scope, revision 17 compatibility, retained failure evidence and +remaining audio/profile/graphics qualification. diff --git a/docs/native-gen3-mirror-move.md b/docs/native-gen3-mirror-move.md index 797a54818..e4cc86adf 100644 --- a/docs/native-gen3-mirror-move.md +++ b/docs/native-gen3-mirror-move.md @@ -49,7 +49,7 @@ battle queue rather than omitted from observation: ## Validation -The frozen complete comparison at `74602df7` executes **32 workers / 14,816 +The focused frozen comparison at `74602df7` executes **32 workers / 14,816 paired observations**, all equal, with every worker exiting zero, no timeout and no native VM diagnostic. It retains all captured events, held reads, RNG, PP, damage, actor/display state and animation commands. The combined fork at @@ -81,8 +81,15 @@ The runtime at `f9016f1c` completes 163/172 engine suites, SDK 3/3 and B30. Its exact anchored FAIL-line Counter equals fresh #258 (157/166): the same 25 lines in nine inherited suites, with no additions or removals. The later history/CI commits change tests and workflow only; their added host suite -passes 994 checks. Fresh combined-fork and stable-baseline full runs are being -qualified separately before the playable branch is advanced. +passes 994 checks. The combined fork completes **282/282** engine suites, SDK **3/3** and B30; +fresh stable baseline completes **275/275**, SDK **3/3** and B30. Their exact +anchored FAIL-line Counters are both empty. Each runtime also qualifies all +**220 workers / 175,540 paired observations** across every existing enemy-action +mode. Two original combined Assist workers terminated at 60 seconds. Those +failed reports remain retained; eight fresh workers with a bounded 180-second +budget complete in at most 36.2911 seconds and retain every earlier completed +observation unchanged. The source-bound evidence is in +`qa/native-gen3-mirror-move.json`. ## Scope and reproduction @@ -94,7 +101,10 @@ normal cache/save and launch no game, audio device or GPU preview. This evidence does not establish final scene pixels, whole-synthesizer waveform identity, all public audio-event timing, arbitrary full-mod compatibility or a -complete playthrough. Ruby/Sapphire are unregistered in this feature stack; +complete playthrough. Native player replacement across cached pan/mono/sound +variants is reproduced separately in 16/20 lifecycle cases and remains a +follow-up; these controls do not claim that player lifecycle is fixed. +Ruby/Sapphire are unregistered in this feature stack; their separate support prerequisites require integration before qualification. ```sh diff --git a/docs/qa/native-gen3-mirror-move.json b/docs/qa/native-gen3-mirror-move.json new file mode 100644 index 000000000..27cb9b7a1 --- /dev/null +++ b/docs/qa/native-gen3-mirror-move.json @@ -0,0 +1,338 @@ +{ + "schema_version": 1, + "reference_commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_read_only": true, + "lua_jit_commit": "a4c064539ea931274d407e1249b4bb575424a0f4", + "feature_qualified_head": "eab96604b54ee06a9a3dc17ffd34af6443c8403f", + "feature_runtime_regression_commit": "f9016f1c23d0d97fd21b761d2f76d15c3f38884d", + "feature_runtime_src_tree": "d44e9c6082e15d606664ea22936fd5464c09c09c", + "feature_tests_tree": "c7b3779282cc4cc529ddee33a37f23552736a995", + "combined_qualified_head": "513bb62e3d14cf3525b8c96a5a826f760125fce1", + "combined_full_regression_commit": "01315149fd67cb7262c9ac7d786294409033fcbb", + "combined_src_tree": "4bcf6c817e867f54596b259d2c8d9a088d6519d2", + "combined_tests_tree": "6f4e04d1ba79f0a318bab3723f397ad551cdae03", + "native_command_revision": 17, + "party_codec_revision": 1, + "native_digest": "351a5becf7f236a0", + "legacy_digest": "e33d251961f2a41d", + "full_regression": { + "candidate_head": "01315149fd67cb7262c9ac7d786294409033fcbb", + "baseline_head": "66ec5c141f0d49a9d4c163f842837d295668139d", + "exact_counter_equal": true, + "baseline_fail_lines": 0, + "candidate_fail_lines": 0, + "new": {}, + "removed": {}, + "engine": [ + "engine: 282/282 suites passed" + ], + "baseline_engine": [ + "engine: 275/275 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "baseline_sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": [ + "PASS Dramatic Shape B30 profile" + ], + "baseline_b30": [ + "PASS Dramatic Shape B30 profile" + ], + "candidate_log_sha256": "8bf366cde1ba96343a7a941a2e1d6488e3b0ddcb3987f66f8213d18b23a7c605", + "baseline_log_sha256": "9f1170c2bf359898924836adc67743c587387541e64fa8baef61f4df3634573b" + }, + "feature_regression": { + "engine": "163/172", + "sdk": "3/3", + "b30": true, + "baseline_engine": "157/166", + "exact_anchored_FAIL_Counter_equal": true, + "inherited_fail_lines": 25, + "inherited_failing_suites": 9, + "later_added_received_history_checks": 994 + }, + "contracts": [ + { + "host": "feature", + "qualified_workers": 220, + "qualified_comparisons": 110, + "paired_rows": 175540, + "reports": [ + { + "path": "tmp/mirror-history-qa/frozen-command-matrix.json", + "sha256": "cd5979084c65525e7d0b1dcf1a7fc1154d89cf474d20872fe4ca4fd915c0a2c1", + "host_ref": "74602df7a96205fbc92d563e4466ada399842756" + }, + { + "path": "tmp/mirror-broad-qa/batch-01.json", + "sha256": "f3fba3e360addddf9f1e4c29a10fc20fbcc58fbb93ef19b4445c3a79ac0b9de7", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-02.json", + "sha256": "4b0c2fe2b1f005f823f992145173db2c768a6a44802fce6996561f2fb6b84ce3", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-03.json", + "sha256": "3e6c169305488ffe4123a5eb8f6d3029a3c456fa69c8a73dd15159cf799f6bfb", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-04.json", + "sha256": "90245b4dbb94de41cd3057d894d0055c8bbf596e2c0dcc6601a8da30a8c5bd75", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-05.json", + "sha256": "b943e5daff0eac373952467c66a5b18e2b7f605f532024539f24a6160fe1650c", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-06.json", + "sha256": "5c9d2c3ccb80c290b1178749e1df313dc63310992073b13fc18e950301dcf5f5", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-07.json", + "sha256": "624b530df5b65052b342ead8f48482b488c78e552f994a2a0c9cb4e244752744", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-08.json", + "sha256": "5439a87845f82ee54da0962c6ea10b4faa257f3624910edb6d1b1a65572a98a2", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + } + ], + "retained_failed_attempts": [], + "rerun_timings": [] + }, + { + "host": "combined", + "qualified_workers": 220, + "qualified_comparisons": 110, + "paired_rows": 175540, + "reports": [ + { + "path": "tmp/mirror-integration-qa/frozen-command-matrix.json", + "sha256": "d26ae49f0446937926fe8a6b91ec3d4fb472a3088508c239e61fb6edc1c5fdc6", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-01.json", + "sha256": "ec11710dd39d13ee430b48c199f1260cd3ad0c184f1a5983b7b057b8b4a77ca3", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-02.json", + "sha256": "0d6304503132d055d2ebf839ee9a3a2ec21cc05cd311f041c76f505c3357676d", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-03.json", + "sha256": "7704e2bcc58c8a018f6aa097db10910c40f3b6744d8af248d3b11d3344b8e949", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-04.json", + "sha256": "ef443c280bb1996ad43128068f98000f72e256dccd5892345ef1aba52a8f2a2c", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-05.json", + "sha256": "41ee847562998b9f9e26537aec9e212a5f8b50fe81e59373ddbedd3588450294", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-06.json", + "sha256": "4c6f6f1eaaff09bbb7457a6d70f58f1816572ac3441b6a994b19fb2fd97a08af", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-07.json", + "sha256": "735963641e138bf781679c54434a7c1df8dc9b9a93c548aad32dc20344fe5c6c", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-08.json", + "sha256": "a4eb32eb688c705891c2f505d8d718d73e2be04996f605cb6a3668c623d12886", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/bounded-assist-rerun.json", + "sha256": "0e18b4084e836f0973a353434b0a4a1c9f42d063501a4af5c3b2bff88ce1f25b", + "host_ref": "513bb62e3d14cf3525b8c96a5a826f760125fce1" + } + ], + "retained_failed_attempts": [ + { + "key": [ + "gen2", + "firered", + 3, + "assist" + ], + "exit": 124, + "timeout": { + "seconds": 60, + "stdout_hex": null, + "stderr_hex": null + }, + "vm": [] + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist_held" + ], + "exit": 124, + "timeout": { + "seconds": 60, + "stdout_hex": null, + "stderr_hex": null + }, + "vm": [] + } + ], + "rerun_timings": [ + { + "key": [ + "gen1", + "firered", + 3, + "assist" + ], + "seconds": 14.1076 + }, + { + "key": [ + "gen2", + "firered", + 3, + "assist" + ], + "seconds": 34.8894 + }, + { + "key": [ + "gen1", + "firered", + 3, + "assist_held" + ], + "seconds": 12.1253 + }, + { + "key": [ + "gen2", + "firered", + 3, + "assist_held" + ], + "seconds": 36.2911 + }, + { + "key": [ + "gen1", + "emerald", + 3, + "assist" + ], + "seconds": 10.2617 + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist" + ], + "seconds": 30.6749 + }, + { + "key": [ + "gen1", + "emerald", + 3, + "assist_held" + ], + "seconds": 11.3661 + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist_held" + ], + "seconds": 30.4373 + } + ] + } + ], + "controller_ticks": { + "swap_inputs": 360, + "swap_ticks": 5240, + "poison_focus_inputs": 384, + "poison_focus_ticks": 11112, + "substitute_fade_inputs": 288, + "substitute_fade_ticks": 23488, + "common_pcm_inputs": 56 + }, + "units": { + "received_history": 994, + "linked_roles": 108, + "protocol": 154, + "effect_controller": 13778, + "fade_producer_controller": 104512, + "art_import_draw": 70, + "native_M4A_sound": 165455, + "import_locator": 8 + }, + "real_imports": { + "per_runtime_scenarios": 24, + "turns": 48, + "move_used_events": 120, + "profiles": [ + "firered", + "emerald" + ], + "native_and_registered": true, + "incoming_moves": [ + "TACKLE", + "SCRATCH", + "GROWL" + ], + "held_items": [ + "CHOICE_BAND", + "SHELL_BELL" + ], + "ordinary_cache_save_writes": false + }, + "actual_doll_imports": { + "compared_RGBA_pixels": 16384, + "different_pixels": 0, + "transparent_RGB_compared": true, + "cartridge_assets_committed": false + }, + "late_nonbehavior_changes": { + "Sound.lua_comment_edit": "Identical stripped LuaJIT bytecode before/after; feature13949 bytes, combined14383 bytes. All other runtime sources unchanged from their full-test commits.", + "checker_budget": "Exhaustive Assist/Assist-content/Assist-held use180s bounded workers; all othersretain60s exceptexisting180spost-hit execution. wall_seconds records actual worker elapsed time before observation parsing.", + "retained_timeouts": "Two original candidate workers terminated at60s(exit124). Eight fresh Assist/Assist-held workers all exit0 under180s; every earlier completed observation is unchanged. Largest fresh worker36.2911s." + }, + "limitations": [ + "FireRed/Emerald qualified; Ruby/Sapphire/LeafGreen support prerequisites are separate", + "CPU native state/audio controls/draw commands; final GPU scene pixels not established", + "Common-carrier PCM equality is not full-synthesizer waveform parity", + "All public audio-event timing and native M4A player lifecycle are not covered by this foundation", + "Separate lifecycle negative control:20pairedcases,16differences;follow-up branch feature/native-sound-player-lifecycle", + "Arbitrary complete mods and a full playthrough not established" + ], + "raw_failed_and_superseded_reports_retained": true, + "ordinary_game_gpu_or_audio_device_launched": false +}