From a70c0a48cf6eb3861346cdbe519b43e43714e921 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:30:34 +0100 Subject: [PATCH 01/34] Connect a distinct Gen2 model to native reads and battle completion --- docs/native-gen2-model-boundary.md | 38 +++++ src/battle/BattleState.lua | 69 ++++---- src/battle/Damage.lua | 23 +-- src/battle/Gen2BattleRead.lua | 47 ++++++ src/battle/Gen2BattleView.lua | 61 +++++++ src/battle/MoveEffects.lua | 18 +- src/battle/Status.lua | 3 +- src/battle/StatusRegistry.lua | 3 +- src/battle/TurnOrder.lua | 3 +- src/battle/Weather.lua | 3 +- src/battle/gen2/Battle.lua | 67 ++++++++ src/core/ModCompat.lua | 15 +- tests/engine/gen2_battle_model_contract.lua | 178 ++++++++++++++++++++ 13 files changed, 464 insertions(+), 64 deletions(-) create mode 100644 docs/native-gen2-model-boundary.md create mode 100644 src/battle/Gen2BattleRead.lua create mode 100644 src/battle/Gen2BattleView.lua create mode 100644 src/battle/gen2/Battle.lua create mode 100644 tests/engine/gen2_battle_model_contract.lua diff --git a/docs/native-gen2-model-boundary.md b/docs/native-gen2-model-boundary.md new file mode 100644 index 000000000..4abcd7bd4 --- /dev/null +++ b/docs/native-gen2-model-boundary.md @@ -0,0 +1,38 @@ +# Native Gen2 model boundary + +`src.battle.gen2.Battle` is a distinct model class attached to the shared +native battle controller by `Gen2BattleView`. It is not a name alias for +`BattleState`, a second controller, or a detached copy of the saved party. +Methods resolve through the class so an SDK-owned decoration also reaches +already-created models and is restored by loader retirement. + +The boundary supplies live `moveDef`, `speciesDef`, `sideOf`, raw `battleStat` +and `endBattle` operations. Damage, speed, status immunity, weather and shared +move effects consult the native bound reads. Stages, badge/status/item +modifiers, RNG and PP stay with their existing native owners. With no +decoration the read returns the original native table, without a copy. +Temporary stat/type answers do not replace a species registry row, party +statistics, wrapper identity or checkpoint data. Explicit native +Conversion/Transform types keep their priority over a species decoration. + +The native finish path invokes the model's end operation, including the +skipped battle path. It raises one finish notification and permits transient +cleanup before subscribers observe it. `completeBattle` runs the existing +screen finish/return path, with repeated completion prevented from popping +another screen. Existing native consumers retain the screen in the event's +`battle`; the distinct model is available in `model`. + +This is an implemented boundary, not complete source-model parity. The split +Gen2 model contract was inspected at Gen1Recomp `535a5ec0` and its current +`e46d1eb2` counterpart. That model additionally has constructors and a pure +turn/event API, which are not guessed from screen methods here. Its inspected +class has no `battleStat` method; the raw-stat operation above is an explicit +native extension, not a claim that all source versions expose it. Source +event payload normalization and the remaining model operations still require +separate implementation and acceptance. Existing project and third-party +credits remain applicable. + +ROM-free coverage uses an SDK-loaded generic decorator and actual native +damage, speed, status, switch, Transform and completion calls. It also checks +restoration and direct table identity in other generations. It does not prove +an unchanged client, GPU presentation, a full story run or a release package. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..9b4ef71b4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,8 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local Gen2BattleView = require('src.battle.Gen2BattleView') +local Gen2Read = require('src.battle.Gen2BattleRead') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -981,7 +983,7 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) +local function makeBattler(data, mon, isPlayer, save, battle) local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil @@ -993,7 +995,7 @@ local function makeBattler(data, mon, isPlayer, save) if save.inventory[row.badge] then badges[row.badge] = true end end end - return { + local battler = { mon = mon, def = def, name = mon.nickname or def.name, @@ -1050,6 +1052,7 @@ local function makeBattler(data, mon, isPlayer, save) monAnim = (not isPlayer) and require("src.pokemon.MonAnim").new(data, mon.species) or nil, } + return Gen2Read.bind(battler, battle) end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1164,12 +1167,9 @@ local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game self.data = game.data - -- On this engine the battle screen IS the battle logic object: there is no - -- separate model that a screen points at. Publishing the self-reference makes - -- that explicit so anything holding "a thing with a .battle" (the party menu - -- during a switch, mods walking the state stack) can treat the battle screen - -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + -- Gen2 exposes its actual party through a distinct live model; native + -- decisions, wrappers and presentation remain on this controller. + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1371,7 +1371,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1428,7 +1428,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1552,9 +1552,9 @@ function BattleState.newPokedudeDemo(game, lesson) local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) + self.player = makeBattler(game.data, party[1], true, game.save, self) self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1742,9 +1742,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1836,7 +1836,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1847,7 +1847,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(game.save.party, lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) elseif GameVersion.isGen4() then -- ...EXCEPT ON PLATINUM, where an escort makes it reachable: the -- partner flag forces hasTwoAliveMons, so a same-id double spots a @@ -1874,7 +1874,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1932,7 +1932,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -2167,7 +2167,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -3616,7 +3616,8 @@ function BattleState:enter() self.result = "lose" self.game.stack:pop() require("src.core.Music").restoreMap(self.data) - Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) + if GameVersion.isGen2() then self.battle:endBattle('lose', {skipped=true}) + else Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) end local onFinish = self.onFinish local function blackedOut() if onFinish then onFinish("lose") end @@ -5904,7 +5905,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -6019,7 +6020,7 @@ function BattleState:abilitySwitchIn(battler) local side = self.sideOf and self:sideOf(battler) if side and side.spikes then local grounded = Abilities.of(battler) ~= "LEVITATE" - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == "FLYING" then grounded = false end end -- PLATINUM / HGSS: subscript_hazards_check decides -- Magic Guard spares @@ -6232,7 +6233,7 @@ function BattleState:abilityForecast(battler) if not (battler and battler.mon and battler.mon.hp > 0) then return false end local want = Abilities.forecastType(battler, Weather.current(self)) if not want then return false end - local types = battler.curTypes + local types = Gen2Read.types(battler) if type(types) == "table" and #types == 1 and types[1] == want then return false end @@ -7785,7 +7786,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8120,7 +8121,7 @@ function BattleState:applyGen3TransformVisual(user, target, whiteCoeff) end local function battlerHasType(battler, wanted) - for _, t in ipairs(battler and battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false @@ -8786,7 +8787,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8828,7 +8829,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -9125,7 +9126,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -9163,7 +9164,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9536,7 +9537,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9764,7 +9765,7 @@ function BattleState:safariEnemyTurn() self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name)) end self:act(function() - local speed = self.enemy.curStats.speed % 256 + local speed = Gen2Read.stats(self.enemy).speed % 256 local fled = speed > 127 local b = (speed * 2) % 256 if not fled then @@ -10800,6 +10801,7 @@ function BattleState:playVictoryMusic() end function BattleState:finish() + if self._gen2Complete then return end local pd = self.pokedudeDemo if pd and not pd.finalVoiceQueued then pd.finalVoiceQueued = true @@ -10860,8 +10862,10 @@ function BattleState:finish() -- like the overworld reload's PlayDefaultMusicFadeOutCurrent -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) + if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12963,4 +12967,5 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c287f54a9..60cd86214 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -6,6 +6,7 @@ -- Battlers built by makeBattler also carry the merged badgeBoosts rows and -- statuses records; hand-built battlers fall back to the vanilla tables. +local Gen2Read = require('src.battle.Gen2BattleRead') local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") @@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) local function shl(x) return math.min(255, x * 2) end local speed if ruleset.critUsesBaseSpeed == false then - speed = Stats.applyStage(attacker.curStats.speed, + speed = Stats.applyStage(Gen2Read.stats(attacker).speed, attacker.stages and attacker.stages.speed or 0) else speed = attacker.def.baseStats.speed @@ -231,6 +232,8 @@ end -- self-hit reads the opponent's screens). -- Returns damage, {crit=bool, typeMult=x10}. function Damage.compute(ruleset, attacker, defender, move, opts) + local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender) + local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender) opts = opts or {} local rng = opts.rng or love.math.random -- ...and the same field again, because a STATUS move must not roll damage. @@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. - local split = special and attacker.curStats.spatk and defender.curStats.spdef + local split = special and attackStats.spatk and defendStats.spdef local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" local atk, dfn if crit and ruleset.critIgnoresStages then - atk = attacker.curStats[atkStat] - dfn = defender.curStats[defStat] + atk = attackStats[atkStat] + dfn = defendStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], + atk = Stats.applyStage(attackStats[atkStat], stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], + dfn = Stats.applyStage(defendStats[defStat], stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), @@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then -- STAB local stab = false - for _, t in ipairs(attacker.curTypes) do + for _, t in ipairs(attackTypes) do if t == move.type then stab = true break end end if stab then @@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- type effectiveness: each TypeEffects row is applied to the -- running damage separately with its own floor (0.5*0.5 lands on -- floor(floor(d/2)/2), not d*0.25) - mult = TypeChart.effectiveness(move.type, defender.curTypes) + mult = TypeChart.effectiveness(move.type, defendTypes) -- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being -- immune to NORMAL and FIGHTING. It does not become weak to them -- -- the immunity simply becomes neutral -- so this reads the chart's @@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if mult == 0 and bwLift then foresight = true mult = 10 - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do if m ~= 0 then mult = math.floor(mult * m / 10) end end end @@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } end - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies if not (foresight and m == 0) then d = math.floor(d * m / 10) end diff --git a/src/battle/Gen2BattleRead.lua b/src/battle/Gen2BattleRead.lua new file mode 100644 index 000000000..f4e044834 --- /dev/null +++ b/src/battle/Gen2BattleRead.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Pure reads at native calculation sites. Wrappers, PP, volatile state and +-- checkpoint tables retain their identity and original serializable fields. +local Model = require('src.battle.gen2.Battle') +local Read = {} +local owners = setmetatable({}, { __mode = 'kv' }) +local nativeStat, nativeDef = Model.battleStat, Model.speciesDef + +function Read.bind(battler, screen) + if battler and screen and screen.battle and screen.battle ~= screen then + owners[battler] = screen + end + return battler +end + +local function modelOf(battler) + local screen = owners[battler] + local model = screen and screen.battle + -- Non-Gen2 screens use themselves as their model and keep direct reads. + return model ~= screen and model or nil +end + +function Read.stats(battler) + local stats = battler and battler.curStats + if Model.battleStat == nativeStat then return stats end + local model = battler and modelOf(battler) + if not model then return stats end + local values = {} + for key in pairs(stats or {}) do + values[key] = model:battleStat(battler.mon, key) + end + return values +end + +function Read.types(battler) + local types = battler and battler.curTypes + if Model.speciesDef == nativeDef then return types end + local model = battler and modelOf(battler) + if not model then return types end + -- Conversion/Transform own an explicit native type list. A species reader + -- must not erase a move's live volatile override, or mutate its source row. + if not battler.def or types ~= battler.def.types then return types end + local def = model:speciesDef(battler.mon) + return def and def.types or types +end + +return Read diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..d5fd55437 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Separate live Gen2 model over the existing shared native controller. +local View = {} +local Model = require('src.battle.gen2.Battle') +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local readers = { + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true end, + outcome = function() return screen.result end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + kind = function() return screen.kind end, + inBattleTowerBattle = function() return screen.battleTower == true end, + } + screen.battle = Model.bind(setmetatable({}, { + __index = function(_, key) + local read = readers[key] + if read then return read() end + return Model[key] + end, + __newindex = function(model, key, value) + if readers[key] or Model[key] ~= nil then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }), screen) +end + +function View.install(Screen) + function Screen:completeBattle() + assert(require('src.core.GameVersion').isGen2(), 'Gen2 completion requires a Gen2 battle') + return self:finish() + end +end +return View diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..d5e4059ae 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -15,6 +15,7 @@ local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") local Weather = require("src.battle.Weather") @@ -274,7 +275,7 @@ MoveEffects.primary = { if target.leechSeeded then return { Strings("But, it failed!") } end - for _, t in ipairs(target.curTypes) do + for _, t in ipairs(Gen2Read.types(target)) do if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true @@ -370,7 +371,8 @@ MoveEffects.primary = { if target.invulnerable then return { Strings("But, it failed!") } end - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- _ConvertedTypeText return { Strings("Converted type to\n%s's!", displayName(target)) } end, @@ -394,13 +396,15 @@ MoveEffects.primary = { local okT, T = pcall(require, "src.battle.Gen5Transform") if okT then T.apply(battle, user, target) end end + local stats = Gen2Read.stats(target) user.curStats = { hp = user.mon.stats.hp, -- HP is kept - attack = target.curStats.attack, defense = target.curStats.defense, - speed = target.curStats.speed, special = target.curStats.special, - spatk = target.curStats.spatk, spdef = target.curStats.spdef, + attack = stats.attack, defense = stats.defense, + speed = stats.speed, special = stats.special, + spatk = stats.spatk, spdef = stats.spdef, } - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- transform.asm:130-132 copies the target's stat MODS into the user -- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them; -- deep copy so later stage changes on either mon stay independent @@ -566,7 +570,7 @@ end -- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and -- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616). local function immuneMsg(ctx) - if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then + if TypeChart.effectiveness(ctx.move.type, Gen2Read.types(ctx.target)) == 0 then return Strings("It doesn't affect\n%s!", displayName(ctx.target)) end return nil diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..d81ec5c1b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -5,6 +5,7 @@ -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") @@ -24,7 +25,7 @@ end local VOLATILE_PRIORITY = 20 local function hasType(battler, wanted) - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..b0b44f619 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -3,6 +3,7 @@ -- (canInflict) and so does the landing text (onInflict), so a mod status -- inflicts through the same path as the vanilla five. +local Gen2Read = require('src.battle.Gen2BattleRead') local Runtime = require("src.mods.Runtime") local Status = require("src.battle.Status") local Strings = require("src.core.Strings") @@ -32,7 +33,7 @@ function StatusRegistry.inflict(battle, target, status, opts) -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) if opts.secondary and status ~= "PSN" then - for _, t in ipairs(target.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(target) or {}) do if opts.moveType == t then return {} end end end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..1ebd0c231 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -3,6 +3,7 @@ -- record's priority field; the id table below covers pre-existing -- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last). +local Gen2Read = require('src.battle.Gen2BattleRead') local Damage = require("src.battle.Damage") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") @@ -17,7 +18,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) - local spd = Stats.applyStage(battler.curStats.speed, + local spd = Stats.applyStage(Gen2Read.stats(battler).speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..fc9a18bef 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -14,6 +14,7 @@ -- Weather.start and every other entry point is a nil check on -- battle.field.weather. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local Weather = {} @@ -296,7 +297,7 @@ local function weatherDamage(battle, battler, weather) return end local immune = WEATHER_IMMUNE[weather] or {} - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if immune[t] then return end end battle:animNext(weather == Weather.HAIL and "inHail" or "inSandstorm", diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 000000000..93efa27f9 --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- The live Gen2 model boundary of the shared native controller. This is not +-- an alias to the screen and does not construct or advance a second battle. +local Runtime = require('src.mods.Runtime') +local Model = {} +local screens = setmetatable({}, { __mode = 'kv' }) +local ended = setmetatable({}, { __mode = 'k' }) + +function Model.bind(model, screen) + screens[model] = screen + return model +end + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +local function active(screen, mon) + if screen.player and screen.player.mon == mon then return screen.player end + if screen.enemy and screen.enemy.mon == mon then return screen.enemy end +end + +function Model:moveDef(move) + return owner(self):moveDef(move) +end + +function Model:speciesDef(mon) + return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil +end + +-- Raw native battle statistics, before stages, badges, status or held items. +-- Those modifiers remain in the actual damage/order owners and run once. +-- A caller's temporary shadow mon uses its own stats without changing a party +-- member. Active Transform statistics remain on the existing native wrapper. +function Model:battleStat(mon, key) + local screen = screens[self] + local battler = screen and active(screen, mon) + local stats = battler and battler.curStats or mon and mon.stats + return stats and stats[key] +end + +function Model:sideOf(mon) + local screen = owner(self) + local battler = active(screen, mon) + if battler then return battler.isPlayer and 'player' or 'enemy' end +end + +-- Finish notification is owned by the native result path. Presentation stays +-- on the native queue; completeBattle/finish perform its actual screen return. +-- Existing native event consumers retain the screen in `battle`; the distinct +-- live model is also provided explicitly. No second save/party snapshot exists. +local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } +function Model:endBattle(outcome, details) + local screen = owner(self) + assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + if ended[self] then return false end + screen.result = outcome + screen:restoreMimicked(screen.player) + screen:restoreMimicked(screen.enemy) + ended[self] = true + local event = { battle=screen, model=self, result=outcome } + if details and details.skipped then event.skipped = true end + Runtime.emit('battle.ended', event) + return true +end + +return Model diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..69eece921 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -37,17 +37,10 @@ -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent --- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. --- src.ui.gen2.BattleState is already mapped to --- src.battle.BattleState below; a port that --- spells BOTH names is not spelling one thing --- twice, it is naming the battle CONTROLLER and --- the battle STATE. This engine has one object --- for both, so whichever of the two a mod meant --- it would get -- and the half of the contract --- it did not mean would be missing, somewhere --- far away from the require. It goes in when a --- mod that wants it says what it calls on it. +-- +-- src.battle.gen2.Battle is a real live model module, not an alias. Its native +-- reads/completion are attached by Gen2BattleView and Gen2BattleRead. Unsupported +-- constructors and turn APIs are not guessed from similarly named screens. -- -- Each of those turns one optional feature off in the mod and says so in the -- log, which is the outcome we want. diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..fc535a856 --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,178 @@ +-- Native calculation and completion paths, decorated through the real SDK. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Screen = require('src.battle.BattleState') +local Model = require('src.battle.gen2.Battle') +local Read = require('src.battle.Gen2BattleRead') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Order = require('src.battle.TurnOrder') +local Status = require('src.battle.StatusRegistry') +local Effects = require('src.battle.MoveEffects') +local savedVersion = Version.get() +local nativeStat, nativeDef, nativeEnd = Model.battleStat, Model.speciesDef, Model.endBattle + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh() + data.constants.gen, data.constants.generation = Version.generation(version), Version.generation(version) + data.type_chart.matchups[#data.type_chart.matchups+1] = { + attacker='NORMAL', defender='GHOST', multiplier=0, + } + local run = T.sdk.loadMods({'mods/model_probe'}, { + data=data, fs=T.sdk.memfs({ + ['mods/model_probe/manifest.json']=[[{ + "id":"model_probe","name":"Native model probe","version":"1.0.0", + "api":2,"entry":"main.lua","affects_link":true + }]], + ['mods/model_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + local stat,def,finish=Model.battleStat,Model.speciesDef,Model.endBattle + mod.exports.scale=1 + Model.battleStat=function(model,mon,key) + local value=stat(model,mon,key) + if mon==model.player and (key=='attack' or key=='speed') then + return value*mod.exports.scale + end + return value + end + Model.speciesDef=function(model,mon) + local value=def(model,mon) + if not (value and mon==model.enemy and mod.exports.types) then return value end + local result={};for key,item in pairs(value)do result[key]=item end + result.types=mod.exports.types + return result + end + Model.endBattle=function(model,...) + if model.player then model.player._probeTransient=nil end + mod.exports.endCalls=(mod.exports.endCalls or 0)+1 + return finish(model,...) + end + mod.events:on('battle.ended',function(event) + mod.exports.ended=(mod.exports.ended or 0)+1 + mod.exports.event=event + mod.exports.cleanAtEvent=not(event.model and event.model.player + and event.model.player._probeTransient) + end) + ]], + }), + }) + T.eq(#run.errors,0,version..' real SDK model decorator loads') + require('src.render.Font').load(data) + local save=Save.newGame() + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local game={data=data,save=save} + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + function stack:push(state)self.states[#self.states+1]=state end + game.stack=stack + local battle=Screen.newWild(game,'FIXMON_C',23) + stack.states={{},battle} + return battle,game,run,assert(run.loader.exports.model_probe) +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,run,probe=fixture(version) + local model=b.battle + local supported=type(model.battleStat)=='function' and type(model.speciesDef)=='function' + T.check(supported,version..' actual model resolves the genuine model class') + T.check(model~=b,version..' screen and model stay distinct') + T.eq(model.player,game.save.party[1],'model retains live party identity') + T.eq(model.moveDef,Model.moveDef,'method lookup retains the class decoration seam') + if supported then + T.eq(model:speciesDef(model.player),game.data.pokemon[model.player.species],'actual species catalog row') + T.eq(model:speciesDef(nil),nil,'no invented missing species') + T.eq(model:battleStat(model.player,'attack'),b.player.curStats.attack,'native raw stat authority') + T.eq(model:battleStat({},'attack'),nil,'unknown stat stays absent') + T.eq(model:sideOf(model.player),'player','native player side') + T.eq(model:sideOf(model.enemy),'enemy','native enemy side') + end + T.eq(Read.stats(b.player).attack,b.player.curStats.attack,'default decorated read keeps native values') + local playerStats,enemyStats=b.player.curStats,b.enemy.curStats + local partyStats,types=b.player.mon.stats,b.enemy.def.types + local pp=b.player.curMoves[1].pp + local move=b:moveDef('FIX_TACKLE') + local function damage() + return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) + end + local normal=damage() + local speed=Order.effectiveSpeed(b.player,b) + probe.scale=4 + local boosted=damage() + T.check(boosted>normal,version..' actual native damage consumes decorated model stat') + T.check(Order.effectiveSpeed(b.player,b)>speed,'actual native speed consumes same model stat') + T.eq(b.player.curStats,playerStats,'native stat table identity is retained') + T.eq(b.enemy.curStats,enemyStats,'native opposing stat table is retained') + T.eq(b.player.mon.stats,partyStats,'save stats are not replaced') + T.eq(b.player.curMoves[1].pp,pp,'queries cannot spend PP') + T.eq(Read.stats(b.player).attack,partyStats.attack*4,'live value has no frame cache') + probe.scale=1 + T.eq(damage(),normal,'removing presentation-owned stat override restores native damage') + probe.types={'GHOST'} + T.eq(damage(),0,'actual damage honors model species type immunity') + T.eq(b.enemy.def.types,types,'species registry type list is not replaced') + T.eq(b.enemy.curTypes,types,'native volatile type list is not replaced') + probe.types={'FIRE'} + b.enemy.mon.status=nil + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,nil,'actual native status immunity honors model type') + probe.types={'NORMAL'} + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,'BRN','removing immunity restores native status path') + b.enemy.mon.status=nil + b.enemy.curTypes={'WATER'} + probe.types={'GHOST'} + T.eq(Read.types(b.enemy)[1],'WATER','native Conversion list wins over a species decoration') + T.check(damage()>0,'volatile type override reaches actual damage') + b.enemy.curTypes=types + probe.types=nil + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,game.save.party[2]) + T.eq(model.player,game.save.party[2],'model follows actual native switch') + probe.scale=2 + T.eq(Read.stats(b.player).attack,b.player.curStats.attack*2,'new native battler is bound') + T.neq(b.player,old,'native wrapper really changed') + probe.scale=1 + -- Native Transform keeps temporary stats on its wrapper, never the save. + local source=b.enemy.curStats.attack + Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) + if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end + T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') + model.player._probeTransient=true + b.result='run' + local complete=type(b.completeBattle)=='function' + T.check(complete,'native Gen2 completion seam exists') + if complete then + b:completeBattle() + T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') + T.eq(probe.event.model,model,'notification also exposes actual live model') + T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') + T.eq(game.stack.pops,1,'completion uses native stack return once') + b:completeBattle() + T.eq(game.stack.pops,1,'repeated completion cannot pop another screen') + if type(model.endBattle)=='function'then model:endBattle('run')end + T.eq(probe.ended,1,'repeated model completion cannot re-emit ended event') + end + require('src.mods.Loader').teardown() + run.release() + T.eq(Model.battleStat,nativeStat,'loader retirement restores model stat method') + T.eq(Model.speciesDef,nativeDef,'loader retirement restores model species method') + T.eq(Model.endBattle,nativeEnd,'loader retirement restores model end method') + T.eq(Read.stats(b.player),b.player.curStats,'undecorated read returns original stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'undecorated read returns original type list') +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,run,probe=fixture(version) + T.eq(b.battle,b,version..' keeps its original shared native identity') + probe.scale=9;probe.types={'GHOST'} + T.eq(Read.stats(b.player),b.player.curStats,'other generation retains exact raw stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'other generation retains exact raw type list') + require('src.mods.Loader').teardown() + run.release() +end +Version.set(savedVersion) +T.finish('native Gen2 model calculation and completion boundary') From a1684d351cb0e50d5c5509f46bae0fd1dfd0f810 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:33:05 +0100 Subject: [PATCH 02/34] Keep scalar model queries separate from older native screen signature --- src/battle/gen2/Battle.lua | 3 +++ tests/engine/gen2_battle_model_contract.lua | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 93efa27f9..1b4fb282f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -21,6 +21,9 @@ local function active(screen, mon) end function Model:moveDef(move) + -- Older native screens take a move instance; scalar catalog queries belong + -- to this model and must not pass an id through that older signature. + if type(move) ~= 'table' then return self.data.moves[move] end return owner(self):moveDef(move) end diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index fc535a856..e128523f7 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -94,7 +94,7 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local playerStats,enemyStats=b.player.curStats,b.enemy.curStats local partyStats,types=b.player.mon.stats,b.enemy.def.types local pp=b.player.curMoves[1].pp - local move=b:moveDef('FIX_TACKLE') + local move=b:moveDef(b.player.curMoves[1]) local function damage() return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) end From a0baf99219097428a7fe5e58f71226157af5a4dc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:00 +0100 Subject: [PATCH 03/34] Preserve native link and extension completion labels --- src/battle/gen2/Battle.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 1b4fb282f..044aaa6aa 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -52,10 +52,10 @@ end -- on the native queue; completeBattle/finish perform its actual screen return. -- Existing native event consumers retain the screen in `battle`; the distinct -- live model is also provided explicitly. No second save/party snapshot exists. -local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } function Model:endBattle(outcome, details) local screen = owner(self) - assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + -- Preserve link/spectator and extension-owned native string outcomes. + assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end screen.result = outcome screen:restoreMimicked(screen.player) From ad95984609d8b587eb8e6790432239d01bb6f657 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:34 +0100 Subject: [PATCH 04/34] Cover native link and spectator outcome strings in model fixture --- tests/engine/gen2_battle_model_contract.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index e128523f7..7b6acba6b 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,12 +142,14 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result='run' + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 870dbab1b5d56cfa9fb3a2a29757d823d9554ec1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:39:49 +0100 Subject: [PATCH 05/34] Preserve absent native result while reporting completion fallback --- src/battle/BattleState.lua | 2 +- src/battle/Gen2BattleView.lua | 2 +- src/battle/gen2/Battle.lua | 2 +- tests/engine/gen2_battle_model_contract.lua | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9b4ef71b4..11de665d4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10864,7 +10864,7 @@ function BattleState:finish() require("src.core.Music").restoreMap(self.data) if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run', {nativeNotification=true}) else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index d5fd55437..06605b8e1 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -25,7 +25,7 @@ function View.bind(screen) wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, - over = function() return screen.result ~= nil or screen.dead == true end, + over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, outcome = function() return screen.result end, battleType = function() return screen.battleType end, trainer = function() return screen.trainer end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 044aaa6aa..8f00ffd0f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -57,7 +57,7 @@ function Model:endBattle(outcome, details) -- Preserve link/spectator and extension-owned native string outcomes. assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end - screen.result = outcome + if not (details and details.nativeNotification) then screen.result = outcome end screen:restoreMimicked(screen.player) screen:restoreMimicked(screen.enemy) ended[self] = true diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index 7b6acba6b..dd622c770 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,14 +142,15 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw'})[version] local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') - T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') + T.eq(probe.event.result,outcome or 'run','notification retains the native event fallback') + T.eq(b.result,outcome,'notification does not persist an event-only fallback') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 395c52643f6be50d5501527b9acdd3265d9e60c1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:43:44 +0100 Subject: [PATCH 06/34] Bind external native battlers on placement and side synchronization --- src/battle/BattleState.lua | 8 +++++++- tests/engine/gen2_battle_model_contract.lua | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 11de665d4..9c683585c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3432,7 +3432,10 @@ end function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + Gen2Read.bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3552,6 +3555,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + Gen2Read.bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index dd622c770..b16f8d48c 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -137,6 +137,18 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.neq(b.player,old,'native wrapper really changed') probe.scale=1 -- Native Transform keeps temporary stats on its wrapper, never the save. + -- Link/scaffold owners use the public factory without a screen argument, + -- then place/synchronize their actual wrappers on the native controller. + local placed=Screen.makeBattler(game.data,game.save.party[1],true,nil) + b:placeBattler(Screen.POS.PLAYER_LEFT,placed) + probe.scale=3 + T.eq(Read.stats(placed).attack,placed.curStats.attack*3,'native placement binds a public-factory wrapper') + local rebound=Screen.makeBattler(game.data,game.save.party[2],true,nil) + b.player=rebound + b:syncSides() + T.eq(Read.stats(rebound).attack,rebound.curStats.attack*3,'native side synchronization binds external wrappers') + T.eq(model.player,game.save.party[2],'external native replacement retains live model identity') + probe.scale=1 local source=b.enemy.curStats.attack Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end From 1c6d88c9e11345057e4d3492f728b06b57100c36 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:26:21 +0100 Subject: [PATCH 07/34] Apply native GSC move priority before Speed and expose the same model query --- src/battle/Gen2MovePriority.lua | 23 ++++++++ src/battle/TurnOrder.lua | 3 + src/battle/gen2/Battle.lua | 4 ++ tests/engine/gen2_move_priority.lua | 90 +++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/battle/Gen2MovePriority.lua create mode 100644 tests/engine/gen2_move_priority.lua diff --git a/src/battle/Gen2MovePriority.lua b/src/battle/Gen2MovePriority.lua new file mode 100644 index 000000000..9f9ac43c5 --- /dev/null +++ b/src/battle/Gen2MovePriority.lua @@ -0,0 +1,23 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- pokecrystal GetMovePriority/data/moves/effects_priorities.asm. Older native +-- caches only stamp priority-hit moves; derive the other GSC classes from +-- their native effect rather than treating Protect/Endure as ordinary moves. +local Priority = {} +local effects = { + PROTECT_EFFECT = 3, ENDURE_EFFECT = 3, + COUNTER_EFFECT = -1, MIRROR_COAT_EFFECT = -1, + FORCE_SWITCH_EFFECT = -1, +} +local ids = { + QUICK_ATTACK = 1, COUNTER = -1, VITAL_THROW = -1, + -- The shared native cache merges Roar/Whirlwind and Teleport into + -- SWITCH_AND_TELEPORT_EFFECT. Teleport is not a negative-priority move. + ROAR = -1, WHIRLWIND = -1, +} +function Priority.get(move) + if not move then return 0 end + -- Preserve explicit cartridge/mod data, including an explicit zero. + if move.priority then return move.priority end + return effects[move.effect] or ids[move.id] or 0 +end +return Priority diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 1ebd0c231..1cd1a6cf2 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -66,6 +66,9 @@ end local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 } local function priority(move) + if require('src.core.GameVersion').isGen2() then + return require('src.battle.Gen2MovePriority').get(move) + end if not move then return 0 end if move.priority then return move.priority end return PRIORITY[move.id] or 0 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 8f00ffd0f..69149a57f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -27,6 +27,10 @@ function Model:moveDef(move) return owner(self):moveDef(move) end +function Model:movePriority(id) + return require('src.battle.Gen2MovePriority').get(self:moveDef(id)) +end + function Model:speciesDef(mon) return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil end diff --git a/tests/engine/gen2_move_priority.lua b/tests/engine/gen2_move_priority.lua new file mode 100644 index 000000000..3538a9bcc --- /dev/null +++ b/tests/engine/gen2_move_priority.lua @@ -0,0 +1,90 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC's GetMovePriority precedes Speed. Exercise the shared comparator, the +-- actual native queued turn, and the public live-model query together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Order=require('src.battle.TurnOrder') +local Save=require('src.core.SaveData') +local saved=Version.get() +local cases={ + {id='QA_PROTECT',effect='PROTECT_EFFECT',priority=3}, + {id='QA_ENDURE',effect='ENDURE_EFFECT',priority=3}, + {id='QA_COUNTER',effect='COUNTER_EFFECT',priority=-1}, + {id='QA_MIRROR',effect='MIRROR_COAT_EFFECT',priority=-1}, + {id='VITAL_THROW',effect='ALWAYS_HIT_EFFECT',priority=-1}, + {id='ROAR',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='WHIRLWIND',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='TELEPORT',effect='SWITCH_AND_TELEPORT_EFFECT',priority=0}, +} +local function fixture(version,chosen) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=2 + for _,row in ipairs(cases)do + data.moves[row.id]={id=row.id,name=row.id,effect=row.effect,power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadNone({data=data}) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',25)} + save.party[1].moves={{id=chosen or 'QA_PROTECT',pp=10}} + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_B',25) + b.rng=function(lo)return lo end + return b,run +end +for _,version in ipairs{'gold','silver','crystal'}do + local b,run=fixture(version) + local a,z=b.player,b.enemy + local plain=b.data.moves.FIX_TACKLE + for _,row in ipairs(cases)do + local move=b.data.moves[row.id] + a.curStats.speed=row.priority>0 and 1 or 999 + z.curStats.speed=row.priority>0 and 999 or 1 + local expect=row.priority>=0 + T.eq(Order.firstMover(a,move,z,plain,b.rng,false,b,b.data),expect, + version..' native effect priority for '..row.id) + T.eq(b.battle:movePriority(row.id),row.priority,'public live-model priority agrees: '..row.id) + end + local explicit={id='QA_EXPLICIT',effect='PROTECT_EFFECT',priority=-7} + T.eq(Order.firstMover(a,explicit,z,plain,b.rng,false,b,b.data),false,'explicit native mod priority still wins') + b.data.moves[explicit.id]=explicit + T.eq(b.battle:movePriority(explicit.id),-7,'model uses the same explicit override') + T.eq(b.battle:movePriority('UNKNOWN_NATIVE_MOVE'),0,'missing priority stays neutral') + run.release() + for _,id in ipairs{'QA_PROTECT','QA_ENDURE'}do + local battle,owner=fixture(version,id) + battle.player.curStats.speed=1;battle.enemy.curStats.speed=999 + local selected=battle.player.curMoves[1] + local enemyMove=battle.enemy.curMoves[1] + -- Fix only the legal AI choice; the turn planner, queue closures, move + -- execution, PP and protection handlers below are the genuine owners. + battle.enemyAction=function()return enemyMove end + battle.queue={} + local hp=battle.player.mon.hp + battle:resolveTurn(selected) + local first,second=battle.queue[1],battle.queue[2] + T.check(first and second and first.fn and second.fn,'native turn queued both actual actions') + battle.nextInsert=0;first.fn();battle.nextInsert=nil + local field=id=='QA_PROTECT'and'protecting'or'enduring' + T.eq(battle.player[field],true,'slow '..id..' really executes before the faster enemy') + T.eq(selected.pp,9,'actual first move spends one PP') + if id=='QA_PROTECT'then + battle.nextInsert=0;second.fn();battle.nextInsert=nil + T.eq(battle.player.mon.hp,hp,'faster enemy attack is blocked by the actual protection state') + end + owner.release() + end +end +-- The added effect fallback belongs only to the GSC controller. Preserve +-- other generations' explicit imported priorities and legacy ID behavior. +for _,version in ipairs{'red','firered','emerald','heartgold','black'}do + Version.set(version) + local a={curStats={speed=1},stages={},mon={hp=100,stats={speed=1}}} + local z={curStats={speed=999},stages={},mon={hp=100,stats={speed=999}}} + T.eq(Order.firstMover(a,{id='QA_OTHER',effect='PROTECT_EFFECT'},z,{id='PLAIN'},function(lo)return lo end),false, + version..' retains its own priority contract') +end +Version.set(saved) +T.finish('native GSC priority, live model queries and actual protection turns') From 7cbf55e14eaf54faf71f697eccf5033804f2fc7a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:29:02 +0100 Subject: [PATCH 08/34] Document native GSC priority classes and real queued-action coverage --- docs/native-gen2-move-priority.md | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/native-gen2-move-priority.md diff --git a/docs/native-gen2-move-priority.md b/docs/native-gen2-move-priority.md new file mode 100644 index 000000000..235fd9692 --- /dev/null +++ b/docs/native-gen2-move-priority.md @@ -0,0 +1,32 @@ +# Native GSC move priority + +Older native GSC caches mark priority-hit moves explicitly, but leave Protect, +Endure, Mirror Coat and other priority classes without a `priority` field. +The shared comparator then treated them as ordinary moves. A slow Pokemon +could take damage before its Protect action; the live Gen2 model also reported +neutral priority for these moves. + +`Gen2MovePriority` supplies the cartridge's missing priority classes at the +actual native comparator and live-model query. Protect/Endure use +3; +Counter/Mirror Coat and Vital Throw use -1. Roar/Whirlwind use -1 by their +native IDs because the existing cache combines their effect with Teleport; +Teleport remains neutral. Explicit native cartridge/mod priority values, +including zero, take precedence. Other generations retain their own rules. +This does not require a re-import, change a move record or registry fingerprint, +or replace the native battle engine. It adds no RNG draw. + +The reference is the GSC `GetMovePriority`/effect-priority table also represented +by the read-only Gen1Recomp Gen2 model at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. This is an implementation in the +native shared controller, not an alias to a second combat simulator. + +The exact old-source regression reproduces 57 failed assertions, including +actual HP loss before slow Protect. The corrected 83-check contract exercises +Gold/Silver/Crystal, renamed effect-equivalent moves, explicit overrides, +unknown moves and the public model. It executes the real queued native actions +and verifies Protect/Endure state and PP; the enemy's real attack is blocked +after Protect. Cross-generation comparator checks and existing battle/model, +queue and held-item regressions remain separate checks. These ROM-free fixtures +are not a claim of complete move parity, unchanged-mod story/window behavior, +or release acceptance. Full model-constructor and other Gen1 behavior gaps +remain open. From 0a90e59ad53c228052645acb9ed149bd6e68f895 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:18:45 +0100 Subject: [PATCH 09/34] Expose real native GSC volatile ownership and retire transient model views --- src/battle/BattleState.lua | 7 +- src/battle/gen2/Battle.lua | 29 ++++++ tests/engine/gen2_volatile_state.lua | 133 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_volatile_state.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9c683585c..af74797ef 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8304,9 +8304,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- FURY CUTTER and ROLLOUT double for each consecutive LANDING and reset -- the same way. Both counters live on the user and are cleared here, -- which is the one place every move passes through. - if move.id ~= "PROTECT" and move.id ~= "DETECT" and move.id ~= "ENDURE" then - user.protectRun = nil + local keepsProtection = move.id == "PROTECT" or move.id == "DETECT" or move.id == "ENDURE" + if GameVersion.isGen2() then + -- The GSC counter follows the effect, including renamed source moves. + keepsProtection = move.effect == "PROTECT_EFFECT" or move.effect == "ENDURE_EFFECT" end + if not keepsProtection then user.protectRun = nil end if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 69149a57f..65b8946ab 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -16,10 +16,39 @@ local function owner(model) end local function active(screen, mon) + if screen.player == mon or screen.enemy == mon then return mon end if screen.player and screen.player.mon == mon then return screen.player end if screen.enemy and screen.enemy.mon == mon then return screen.enemy end end +local volatileNames={substitute='substituteHP',protect='protecting',mist='mist', + confusion='confused',confusionTurns='confusionTurns',flinch='flinched', + -- Canonical source GSC fields must drive the actual shared controller. + confuseCount='confusedTurns',flinched='flinched',endure='enduring', + recharge='mustRecharge',protectCount='protectRun', + focusEnergy='focusEnergy',xAccuracy='xAccuracy'} +function Model:volatile(mon) + local screen=owner(self) + local b=assert(active(screen,mon),'volatile state requires an active battler') + -- A global weak-key cache still roots its battlers on LuaJIT when the + -- value's closures retain their key. Keep views on the transient model, + -- never on a serialized battler or party mon, and retire withdrawn owners. + local views=rawget(self,'_nativeVolatileViews') + if not views then views={};rawset(self,'_nativeVolatileViews',views)end + for previous in pairs(views)do + if previous~=screen.player and previous~=screen.enemy then views[previous]=nil end + end + if not views[b] then + local extensions={} + views[b]=setmetatable({}, { + __index=function(_,key)local field=volatileNames[key];if field then return b[field] end;return extensions[key]end, + __newindex=function(_,key,value)local field=volatileNames[key];if field then b[field]=value else extensions[key]=value end end, + }) + end + return views[b] +end + + function Model:moveDef(move) -- Older native screens take a move instance; scalar catalog queries belong -- to this model and must not pass an id through that older signature. diff --git a/tests/engine/gen2_volatile_state.lua b/tests/engine/gen2_volatile_state.lua new file mode 100644 index 000000000..a498fb18b --- /dev/null +++ b/tests/engine/gen2_volatile_state.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source GSC volatile names must reach the actual native status/damage owner, +-- not a disconnected metadata table. All monsters below are ROM-free fixtures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Damage=require('src.battle.Damage') +local oldVersion=V.get() +local function fixture(version,chosen,effect) + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=2;data.constants.generation=2 + for _,id in ipairs{'PROTECT','QA_GUARD'}do + data.moves[id]={id=id,name=id,effect=effect or'PROTECT_EFFECT',power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadMods({'mods/volatile_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/volatile_probe/manifest.json']='{"id":"volatile_probe","name":"Owned state probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/volatile_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.write=function(model,mon,key,value)Model.volatile(model,mon)[key]=value end + mod.exports.read=function(model,mon,key)return Model.volatile(model,mon)[key]end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + if chosen then save.party[1].moves={{id=chosen,pp=10}}end + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_C',30) + local rolls=0;b.rng=function(lo,hi)rolls=rolls+1;return hi or lo end + return b,run,run.loader.exports.volatile_probe,function()return rolls end +end +local fields={ + {'flinched','flinched',true}, {'confuseCount','confusedTurns',3}, + {'endure','enduring',true}, {'recharge','mustRecharge',true}, + {'protectCount','protectRun',2}, {'focusEnergy','focusEnergy',true}, + {'xAccuracy','xAccuracy',true}, {'substitute','substituteHP',17}, + {'protect','protecting',true}, {'mist','mist',true}, +} +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run,probe,rolls=fixture(version) + local model,mon=b.battle,b.player.mon + local view=model:volatile(mon) + T.eq(view,model:volatile(mon),'one stable source view for the same active battler') + T.eq(view,model:volatile(b.player),'party and native-wrapper queries share the owner') + for _,row in ipairs(fields)do + probe.write(model,mon,row[1],row[3]) + T.eq(b.player[row[2]],row[3],version..' source write reaches native '..row[2]) + b.player[row[2]]=nil + T.eq(probe.read(model,mon,row[1]),nil,'source read sees native clear: '..row[1]) + b.player[row[2]]=row[3] + T.eq(probe.read(model,mon,row[1]),row[3],'native state reaches source read: '..row[1]) + probe.write(model,mon,row[1],nil) + T.eq(b.player[row[2]],nil,'source clear retires native state: '..row[1]) + T.eq(mon[row[1]],nil,'transient source field is not written into party/save') + end + probe.write(model,mon,'flinched',true) + local pp=mon.moves[1].pp + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),true,'source flinch actually blocks native admission') + T.eq(not not probe.read(model,mon,'flinched'),false,'native admission clears source flinch') + T.eq(rolls(),0,'flinch does not consume battle RNG') + T.eq(mon.moves[1].pp,pp,'blocked admission cannot spend PP') + probe.write(model,mon,'confuseCount',2) + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),false,'high native roll allows a confused attack') + T.eq(b.player.confusedTurns,1,'actual native confusion decrements source counter') + T.eq(probe.read(model,mon,'confuseCount'),1,'source sees the actual remaining counter') + T.eq(rolls(),1,'confusion consumes exactly one actual byte roll') + probe.write(model,mon,'confuseCount',nil) + probe.write(model,mon,'recharge',true) + local locked=b:lockedAction(b.player) + T.eq(locked and locked.special,'recharge','source recharge reaches the real move lock') + probe.write(model,mon,'recharge',nil) + local hp=mon.hp + probe.write(model,mon,'endure',true) + b:applyDamage(b.player,100000,true) + T.eq(mon.hp,1,'source Endure caps actual incoming damage at one HP') + mon.hp=hp + b:applyDamage(b.player,100000,false) + T.eq(mon.hp,0,'Endure does not absorb native residual damage') + probe.write(model,mon,'endure',nil);mon.hp=hp + local low={id='QA_LOW_ACCURACY',accuracy=1,type='NORMAL'} + local function accuracy()return Damage.accuracyRoll({oneIn256Miss=true},low,b.player,b.enemy,function()return 255 end,nil,b.data)end + T.eq(accuracy(),false,'low accuracy misses before source override') + probe.write(model,mon,'xAccuracy',true);T.eq(accuracy(),true,'source X Accuracy bypasses the real hit check') + probe.write(model,mon,'xAccuracy',nil) + local function critical()return Damage.critRoll({},b.player,'FIX_TACKLE',function()return 20 end,false,b.data,b)end + T.eq(critical(),false,'fixed byte is outside the native base critical stage') + probe.write(model,mon,'focusEnergy',true);T.eq(critical(),true,'source Focus Energy reaches native GSC critical stage') + probe.write(model,mon,'focusEnergy',nil) + local marker={owned=true};view._qaPresentation=marker + collectgarbage('collect');T.eq(model:volatile(mon)._qaPresentation,marker,'consumer metadata persists while active') + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,b.game.save.party[2]) + local nextView=model:volatile(model.player) + T.check(nextView~=view,'new native wrapper has a new volatile owner') + T.eq(nextView._qaPresentation,nil,'source metadata does not leak to incoming actor') + probe.write(model,model.player,'confuseCount',4) + T.eq(b.player.confusedTurns,4,'incoming native battler owns the new source state') + T.eq(old.confusedTurns,nil,'new state cannot change the withdrawn native wrapper') + T.eq(mon.volatile,nil,'source view cannot create persistent party volatile data') + run.release() + for _,chosen in ipairs{'PROTECT','QA_GUARD'}do + local battle,owner,source=fixture(version,chosen) + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(battle.player.protecting,nil,'native repeated '..chosen..' fails its real odds roll') + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),0,'native failed protection clears source counter') + owner.release() + end + local battle,owner,source=fixture(version,'PROTECT','NO_ADDITIONAL_EFFECT') + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),nil,'non-protection effect resets run even with old PROTECT ID') + owner.release() +end +-- A module-global weak-key map whose value closure retains its key is not +-- collectible on LuaJIT's Lua5.1 collector. No caller-held view remains here. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local mon={};local b={mon=mon};local screen={player=b};local model={} + Model.bind(model,screen);Model.volatile(model,mon)._owned=true + T.eq(screen.player,b,'owner stays live during its actual view creation') + weak[b]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'module cache cannot retain retired battlers through view closures') +V.set(oldVersion) +T.finish('native source volatile state, real mechanics, switch and cache lifetime') From b7b990c898900bc2aacdf9250d388d35034cfe8a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:25:35 +0100 Subject: [PATCH 10/34] Document native volatile bridge scope and independently tested older host --- docs/native-gen2-volatile-state.md | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/native-gen2-volatile-state.md diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md new file mode 100644 index 000000000..33fd651b3 --- /dev/null +++ b/docs/native-gen2-volatile-state.md @@ -0,0 +1,34 @@ +# Native GSC volatile state + +The live Gen2 battle model exposes `volatile(mon)` as a view of its existing +native battler, rather than a disconnected metadata table. Canonical source +names `confuseCount`, `flinched`, `endure`, `recharge`, `protectCount`, +`focusEnergy` and `xAccuracy` address the native confusion counter, admission, +damage, action-lock, protection, critical and hit-check owners. Substitute, +protect and mist aliases are also supported. Unknown keys remain transient +consumer metadata, not newly implemented mechanics. + +Views are cached on the transient model only. Module-global weak-key caches can +retain retired battlers on LuaJIT when value closures reference the key. The +same active native owner has a stable view; withdrawn entries retire on query +and incoming wrappers get new views. No view or metadata is stored in a party +mon or serialized battler. + +GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, +including renamed moves; an old PROTECT ID with another effect resets it. +Protection RNG, opponent-first and Substitute rules remain separate open +mechanics work. No second battle simulator is introduced. + +On this older host, 423 real native state/mechanics/ownership/collection checks, +262 model checks and 83 move-priority checks pass independently. The fixture +loads an actual API2 source consumer and covers Gold/Silver/Crystal, Prism and +Polished Crystal. The fork's additional semantic-checkpoint backend is absent +here, so its checkpoint result is not claimed for this offered branch. +These ROM-free CPU checks do not establish complete source-model, GPU, +unchanged-mod or release acceptance. Depends on the native model boundary and +move-priority contributions offered separately. + +Source-field names were inspected against Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Native integration and tests are +contributions by Roxas2712, offered to Cedric / UNDERdecoded under the fork's +specific PR contribution permission, with existing third-party rights intact. From 2a3d02111e845484c151b9f6f746bf63dad093ae Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:38:01 +0100 Subject: [PATCH 11/34] Run native GSC protection with actual first-side, RNG and link compatibility ownership --- src/battle/BattleState.lua | 1 + src/battle/EffectRegistry.lua | 3 + src/battle/Gen2Protection.lua | 30 ++++++++ src/battle/Gen3MoveEffects.lua | 4 ++ src/link/Fingerprint.lua | 8 ++- src/link/Handshake.lua | 8 +++ tests/engine/gen2_protection.lua | 99 +++++++++++++++++++++++++++ tests/engine/gen2_protection_link.lua | 64 +++++++++++++++++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/battle/Gen2Protection.lua create mode 100644 tests/engine/gen2_protection.lua create mode 100644 tests/engine/gen2_protection_link.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index af74797ef..d3a3c3a6d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7723,6 +7723,7 @@ function BattleState:executeAction(user, target, action) if user.g4Gone then return end -- this battler's action is under way (Payback, Sucker Punch, Me First) user.actedTurn = self.turnCount + if GameVersion.isGen2() then require("src.battle.Gen2Protection").actionStarted(self, user) end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 87a4bd83a..25c1f8600 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -44,6 +44,9 @@ EffectRegistry.displayName = displayName -- flags recorded (every Gen 1 and Gen 2 one) is affected, that generation's -- rule. function EffectRegistry.protectAffected(move) + -- GSC self-protection must reach its own restriction even if the foe protects. + if require('src.core.GameVersion').isGen2() + and (move.effect == 'PROTECT_EFFECT' or move.effect == 'ENDURE_EFFECT') then return false end if move.protectAffected ~= nil then return move.protectAffected ~= false end if move.gen4Effect ~= nil and tonumber(move.flags) then return math.floor(tonumber(move.flags) / 2) % 2 == 1 diff --git a/src/battle/Gen2Protection.lua b/src/battle/Gen2Protection.lua new file mode 100644 index 000000000..299260dc6 --- /dev/null +++ b/src/battle/Gen2Protection.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC ProtectChance: a nonzero byte minus one against FF >> prior uses. +-- Keep the existing native action, battler and RNG owners. +local Protection={} +local function side(user)return user.isPlayer and 'player' or 'enemy'end + +function Protection.actionStarted(battle,user) + local turn=battle.turnCount or 0 + if battle._gen2ProtectionTurn~=turn then + battle._gen2ProtectionTurn=turn;battle._gen2ProtectionFirst=nil + end + if not battle._gen2ProtectionFirst then battle._gen2ProtectionFirst=side(user)end +end + +function Protection.apply(battle,user,field) + local turn=battle.turnCount or 0 + local first=battle._gen2ProtectionTurn==turn and battle._gen2ProtectionFirst + local count=math.max(0,math.floor(tonumber(user.protectRun)or 0)) + local threshold=count<8 and math.floor(255/2^count)or 0 + -- These branches precede the draw. A called protection effect retains the + -- original round's first side, including after a native switch replaces it. + if (first and first~=side(user))or(user.substituteHP or 0)>0 + or threshold==0 or battle.rng(1,255)-1>=threshold then + user.protectRun=0 + return false + end + user.protectRun=count+1;user[field]=true + return true +end +return Protection diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 1704c46d1..57c4f1e8c 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -284,6 +284,10 @@ end -- else, so a spammed PROTECT fails the way the cartridge makes it fail. local function protection(field, text) return function(battle, user) + if require("src.core.GameVersion").isGen2() then + if not require("src.battle.Gen2Protection").apply(battle, user, field) then return { Strings("But, it failed!") } end + return { Strings(text, displayName(user)) } + end local run = user.protectRun or 0 -- 1/1, then 1/2, 1/4, 1/8... A run of four is already a coin flip -- three times over, which is what stops it being a stall lock. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68ef..fd8654fb8 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -17,6 +17,10 @@ local Runtime = require("src.mods.Runtime") local Fingerprint = {} +-- Command/RNG compatibility is independent of byte-identical ROM data. +function Fingerprint.nativeBattleRevision() + return require('src.core.GameVersion').isGen2() and 'gsc-1' or nil +end -- ------- FNV-1a, two lanes @@ -238,6 +242,8 @@ local function surface(data, mods) writeSection(out, data, "move_effects") writeConstants(out, data) writeLinkFields(out, data) + local revision=Fingerprint.nativeBattleRevision() + if revision then out[#out+1]='[native_gsc_commands]'..revision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -247,7 +253,7 @@ Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array function Fingerprint.compute(data, mods) if not data then return digest("") end - local key = modKey(mods) + local key = modKey(mods).."|native_commands:"..tostring(Fingerprint.nativeBattleRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index aec8437e7..dad7599c4 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -158,6 +158,7 @@ function Handshake.hello(game, mode) engineVersion = Version.engine, apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), + nativeBattleRevision = Fingerprint.nativeBattleRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -174,6 +175,7 @@ end function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} if not remoteHello or not remoteHello.protocol then + if localHello.nativeBattleRevision then return "refused", "native_rules_required" end if localHello.linkModified then return "refused", "peer_v1_modified" end @@ -182,6 +184,7 @@ function Handshake.checkCompat(localHello, remoteHello) if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then return "refused", "engine_mismatch" end + if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then return "subset", "native_rules_mismatch" end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end @@ -264,6 +267,11 @@ end function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" + if (localHello and localHello.nativeBattleRevision) ~= (remoteHello and remoteHello.nativeBattleRevision) then + wrap(lines, Strings("The native battle rules differ.")) + wrap(lines, Strings("Use matching engine builds for link battles.")) + return lines + end if verdict == "refused" then if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua new file mode 100644 index 000000000..65809af44 --- /dev/null +++ b/tests/engine/gen2_protection.lua @@ -0,0 +1,99 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native registered protection effects, RNG boundaries and real queued order. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local P=require('src.battle.Gen3MoveEffects').primary +local before=V.get() +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadNone({data=data});require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_PROTECT',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + return b,run +end +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run=fixture(edition) + for _,effect in ipairs{{'PROTECT_EFFECT','protecting'},{'ENDURE_EFFECT','enduring'}}do + for count=0,9 do + local threshold=math.floor(255/2^count) + for _,byte in ipairs{0,1,62,63,126,127,253,254}do + local draws,loSeen,hiSeen=0 + b.rng=function(lo,hi)draws=draws+1;loSeen,hiSeen=lo,hi;return lo+byte%(hi-lo+1)end + b.player.protectRun=count;b.player[effect[2]]=nil + P[effect[1]](b,b.player) + local succeeds=threshold>0 and byte0 and 1 or 0,'one nonzero-byte draw until threshold reaches zero') + if threshold>0 then + T.eq(loSeen,1,'native draw starts at one') + T.eq(hiSeen,255,'native draw spans all nonzero bytes') + end + end + end + for _,reason in ipairs{'substitute','opponent-first'}do + b.player.protectRun=3;b.player[effect[2]]=nil + b.player.substituteHP=reason=='substitute'and 10 or nil + b.turnCount=10;b._gen2ProtectionTurn=10 + b._gen2ProtectionFirst=reason=='opponent-first'and'enemy'or'player' + local draws=0;b.rng=function(lo)draws=draws+1;return lo end + P[effect[1]](b,b.player) + T.eq(b.player[effect[2]],nil,reason..' prevents '..effect[1]) + T.eq(b.player.protectRun,0,reason..' resets actual counter') + T.eq(draws,0,reason..' fails before the random draw') + end + b.player.substituteHP=nil;b._gen2ProtectionTurn=nil;b._gen2ProtectionFirst=nil + end + -- Both sides choose the same priority. The slower user's actual queued + -- action must fail after the faster opponent has acted, without a draw. + b.player.curStats.speed=1;b.enemy.curStats.speed=999 + local selected=b.player.curMoves[1] + b.enemy.curMoves[1]={id='QA_ENDURE',pp=20} + b.enemyAction=function()return b.enemy.curMoves[1]end + b.queue={};local draws=0;b.rng=function(lo)draws=draws+1;return lo end + b:resolveTurn(selected) + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'real native actions are queued') + b:runQueueAction(first) + T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') + local afterFirst=draws + b:runQueueAction(second) + T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent') + T.eq(b.player.protectRun,0,'second actor resets source-readable counter') + T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG') + T.eq(selected.pp,19,'failed protection still spends the actual move PP') + -- A new round retires the previous first actor. Called effects remain in + -- the same round and cannot change its first side. + b:beginTurn();b:executeAction(b.player,b.enemy,{special='recharge'}) + T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side') + -- First-side identity must survive replacement of that side's wrapper. + b:beginTurn() + local withdrawn=b.player + b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]}) + T.check(b.player~=withdrawn,'real native switch replaces the first wrapper') + b.enemy.protectRun=2 + b:executeAction(b.enemy,b.player,{id='QA_ENDURE',pp=20}) + T.eq(b.enemy.enduring,nil,'second actor cannot Endure after opponent switches') + T.eq(b.enemy.protectRun,0,'switch-first failure resets actual counter') + run.release() +end +-- Existing Gen1/GBA behavior is deliberately untouched by the GSC owner. +for _,edition in ipairs{'red','firered','emerald'}do + local b,run=fixture(edition);local draws=0 + b.rng=function(lo)draws=draws+1;return lo end + b.player.substituteHP=10;b._gen2ProtectionTurn=b.turnCount;b._gen2ProtectionFirst='enemy' + P.PROTECT_EFFECT(b,b.player) + T.eq(b.player.protecting,true,edition..' retains its previous protection behavior') + T.eq(draws,0,edition..' first use retains its old random protocol') + run.release() +end +V.set(before) +T.finish('native GSC protection thresholds, restrictions, order, PP and RNG') diff --git a/tests/engine/gen2_protection_link.lua b/tests/engine/gen2_protection_link.lua new file mode 100644 index 000000000..f39e734c5 --- /dev/null +++ b/tests/engine/gen2_protection_link.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual participant/spectator queues must share GSC order and RNG ownership. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local H=require('src.link.Handshake') +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + local run=T.sdk.loadNone({data=data});T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].moves={{id='QA_PROTECT',pp=20}} + games[i]=game + end + local hello=H.hello(games[1],'battle');local legacy=Copy(hello);legacy.nativeBattleRevision=nil + T.eq(hello.nativeBattleRevision,'gsc-1','GSC advertises changed command/RNG semantics') + T.eq(H.checkCompat(hello,legacy),'subset','legacy GSC cannot enter changed lockstep') + T.eq(H.checkCompat(legacy,hello),'subset','GSC command mismatch is symmetric') + for _,host in ipairs{false,true}do + local state=setmetatable({game=games[1],myHello=hello,peerHello=legacy},{__index=require('src.link.LinkState')}) + state:decideCompat('battle',host) + T.eq(state.stage,'notice','actual admission refuses old commands before exchange') + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,20000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[1])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[1]and b.localHashes[1]and s.turnCount==1 and s.phase=='waitBoth'then break end + end + T.check(a.localHashes[1]~=nil and b.localHashes[1]~=nil,'both actual participant queues complete') + T.eq(a.localHashes[1],b.localHashes[1],'GSC protection RNG and state hashes agree') + T.same(a.localParts[1],b.localParts[1],'all completed native state components agree') + for _,battle in ipairs(roles)do + local x,y=battle.player.protectRun,battle.enemy.protectRun + T.check(x==1 and y==0 or x==0 and y==1,'only actual first actor succeeds; second clears its counter') + T.eq(battle.player.mon.moves[1].pp,19,'own first-round move spends PP exactly once') + T.eq(battle.enemy.mon.moves[1].pp,19,'other first-round move spends PP exactly once') + T.eq(battle.result,nil,'no hidden participant/spectator error') + end + T.eq(s.player.protectRun,a.player.protectRun,'spectator preserves host protection owner') + T.eq(s.enemy.protectRun,a.enemy.protectRun,'spectator preserves guest protection owner') + run.release() +end +V.set(old) +T.finish('native GSC protection participant, spectator and protocol admission') From eead513582022f65e6d73a37bf5bf59b34bb98a8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:38:28 +0100 Subject: [PATCH 12/34] Use the existing string owner for native rules mismatch messages --- src/link/Handshake.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index dad7599c4..cec478157 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -10,6 +10,7 @@ local Fingerprint = require("src.link.Fingerprint") local Schemas = require("src.mods.Schemas") local Version = require("src.core.Version") +local Strings = require("src.core.Strings") local Handshake = {} From dbabc4ea744d7f728c8dfda4f6eca8e1b7c9be72 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:40:30 +0100 Subject: [PATCH 13/34] Exercise actual older-host queue entries and audited GSC protocol transitions --- tests/engine/gen2_protection.lua | 8 +++---- tests/engine/gen2_protection_protocol.lua | 27 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_protection_protocol.lua diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua index 65809af44..af5440e26 100644 --- a/tests/engine/gen2_protection.lua +++ b/tests/engine/gen2_protection.lua @@ -62,20 +62,20 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do b:resolveTurn(selected) local first,second=b.queue[1],b.queue[2] T.check(first and second and first.fn and second.fn,'real native actions are queued') - b:runQueueAction(first) + b.nextInsert=0;first.fn() T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') local afterFirst=draws - b:runQueueAction(second) + b.nextInsert=0;second.fn() T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent') T.eq(b.player.protectRun,0,'second actor resets source-readable counter') T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG') T.eq(selected.pp,19,'failed protection still spends the actual move PP') -- A new round retires the previous first actor. Called effects remain in -- the same round and cannot change its first side. - b:beginTurn();b:executeAction(b.player,b.enemy,{special='recharge'}) + b.queue={};b:resolveTurn(b.player.curMoves[1]);b:executeAction(b.player,b.enemy,{special='recharge'}) T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side') -- First-side identity must survive replacement of that side's wrapper. - b:beginTurn() + b.queue={};b:resolveTurn(b.player.curMoves[1]) local withdrawn=b.player b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]}) T.check(b.player~=withdrawn,'real native switch replaces the first wrapper') diff --git a/tests/engine/gen2_protection_protocol.lua b/tests/engine/gen2_protection_protocol.lua new file mode 100644 index 000000000..fa78cb78d --- /dev/null +++ b/tests/engine/gen2_protection_protocol.lua @@ -0,0 +1,27 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V,F,H=require('src.core.GameVersion'),require('src.link.Fingerprint'),require('src.link.Handshake') +local Copy=require('src.mods.Merge').deepCopy +local before=V.get();local data=T.fixtures.fresh() +-- Independently audited Python integer FNV: the old raw fixture surface, +-- with only [native_gsc_commands]gsc-1 inserted before its mods domain. +for _,edition in ipairs{'red','gold','firered','crystal','emerald','silver','red'}do + V.set(edition);local gsc=V.isGen2() + T.eq(F.compute(data,{}),gsc and '40df18dc503213c8' or 'e33d251961f2a41d','only GSC changes the older host fixture surface') + T.eq(F.nativeBattleRevision(),gsc and 'gsc-1'or nil,'only GSC advertises new native rules') + local hello=H.hello({data=data,save={player={name='RULES'}}},'battle') + T.eq(H.checkCompat(hello,Copy(hello)),'full','matching actual hello admits lockstep') + if gsc then + local old=Copy(hello);old.nativeBattleRevision=nil + T.eq(H.checkCompat(hello,old),'subset','equal-digest old rule field cannot enter lockstep') + T.eq(H.checkCompat(old,hello),'subset','rule mismatch remains symmetric') + T.eq(H.checkCompat(hello,{}),'refused','unversioned peer cannot assume changed GSC rules') + local lines=H.describe(hello,old,'subset','battle') + T.check(table.concat(lines,' '):find('native battle rules',1,true)~=nil,'visible mismatch describes native rules') + else + T.eq(H.checkCompat(hello,{}),'vanilla_peer','other old-host editions retain old wire behavior') + end +end +V.set(before) +T.finish('older-host native GSC fingerprint cache and actual protocol negotiation') From a8ca624288d3fd15f6e5f9cac601ab98fc3840af Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:41:22 +0100 Subject: [PATCH 14/34] Document independently qualified older-host protection and source reference scope --- docs/native-gen2-protection.md | 38 ++++++++++++++++++++++++++++++ docs/native-gen2-volatile-state.md | 5 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-protection.md diff --git a/docs/native-gen2-protection.md b/docs/native-gen2-protection.md new file mode 100644 index 000000000..e0348b7f6 --- /dev/null +++ b/docs/native-gen2-protection.md @@ -0,0 +1,38 @@ +# Native GSC Protect and Endure + +Run GSC protection on the real shared-controller battler, with the source +model's nonzero-byte odds (`255 >> prior successes`), first-side and Substitute +restrictions. Actual action entry records the first side for that round, +including switches and failed/recharge actions; called effects retain it. +The next round replaces it. GSC self-protection bypasses the generic incoming +status gate so the real handler can clear its failed-use counter. + +Changed command/RNG semantics advertise `gsc-1` in link hello and a separate +GSC fingerprint domain. Older rules cannot enter the changed lockstep battle; +the compatibility screen names the native rules difference. Other generations +retain their existing handlers and fingerprints. This older host does not +carry the fork's semantic-checkpoint backend, so no checkpoint result is +claimed here. + +References: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, its protection handlers and +`Effects.protectSucceeds`; `pret/pokecrystal` +`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, +`engine/battle/move_effects/protect.asm`. The nonzero-domain native RNG call +matches the source model convention; this does not emulate a hardware PRNG or +claim raw zero-rejection trace parity. + +The native model/volatile/priority prerequisites remain separate offered PRs. +Independent results on this offered host: 3796 native effect/queued-order/PP/ +switch checks, 66 host/guest/spectator and admission checks, 37 actual protocol/ +cache/digest checks, 423 volatile checks and 83 priority checks pass. The frozen +Gen1 comparison independently passes 30,624 actual effect cases / 165,336 +comparisons. A separate Python integer-FNV audit confirms this host's legacy +fixture digest e33d251961f2a41d and GSC digest 40df18dc503213c8. Tests drive this +older host's actual queue-row closures; they do not assume the newer fork's +runQueueAction or beginTurn helpers exist. Fork-only checkpoint and complete +release acceptance are not inherited. GPU, complete called-move/turn mechanics, +unchanged-mod gameplay and full Gen1 feature parity remain separate work. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the fork's specific PR contribution permission, with existing rights intact. diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md index 33fd651b3..32fdaf375 100644 --- a/docs/native-gen2-volatile-state.md +++ b/docs/native-gen2-volatile-state.md @@ -16,8 +16,9 @@ mon or serialized battler. GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, including renamed moves; an old PROTECT ID with another effect resets it. -Protection RNG, opponent-first and Substitute rules remain separate open -mechanics work. No second battle simulator is introduced. +Protection RNG, opponent-first and Substitute rules are addressed by the +separate [native protection work](native-gen2-protection.md). +No second battle simulator is introduced. On this older host, 423 real native state/mechanics/ownership/collection checks, 262 model checks and 83 move-priority checks pass independently. The fixture From 5d8752a31c6e24e0498a06be565395095d7d234c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:02:46 +0100 Subject: [PATCH 15/34] Expose live source GSC RNG fields and roller over the older native owner --- src/battle/Gen2BattleView.lua | 2 + src/battle/gen2/Battle.lua | 24 ++++++- tests/engine/gen2_model_rng.lua | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_model_rng.lua diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index 06605b8e1..a5d6fe5bd 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -22,6 +22,8 @@ function View.bind(screen) enemyParty = function() return screen.enemyParty end, save = function() return screen.game.save end, data = function() return screen.data end, + rng = function() return rawget(screen.battle, '_nativeLoveRng') end, + random = function() return screen.battle:roller() end, wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 65b8946ab..c71bc500f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -6,13 +6,33 @@ local Model = {} local screens = setmetatable({}, { __mode = 'kv' }) local ended = setmetatable({}, { __mode = 'k' }) +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + function Model.bind(model, screen) screens[model] = screen + -- Source GSC's LOVE-style view uses the current native function even if + -- link setup or a scoped native owner replaces it after this bind. + rawset(model, '_nativeLoveRng', function(lo, hi) + local rng = owner(model).rng + if hi == nil then return rng(math.max(1, lo or 1)) end + if hi < lo then lo, hi = hi, lo end + return rng(lo, hi) + end) return model end -local function owner(model) - return assert(screens[model], 'native Gen2 model is not attached to a battle') +function Model:roller() + owner(self) + local roller = rawget(self, '_nativeRoller') + if not roller then + -- Zero-based BattleRandom over the same existing stream. Keep the cache + -- on the transient model; a global value closure could root a weak key. + roller = function(n) return owner(self).rng(n) - 1 end + rawset(self, '_nativeRoller', roller) + end + return roller end local function active(screen, mon) diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua new file mode 100644 index 000000000..b07ecddda --- /dev/null +++ b/tests/engine/gen2_model_rng.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual source consumers must share the native stream, including after its +-- function is replaced. No shadow generator, draws on lookup or saved closures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/rng_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/rng_probe/manifest.json']='{"id":"rng_probe","name":"Native RNG probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/rng_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end + mod.exports.cart=function(model,n)return model.random(n)end + mod.exports.method=function(model,n)return Battle.roller(model)(n)end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source RNG consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',25)} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + local probe=run.loader.exports.rng_probe + local trace={} + local function stream(tag) + return function(lo,hi) + local first,last=lo,hi + if hi==nil then lo,hi=1,lo end + trace[#trace+1]={tag=tag,lo=first,hi=last} + return lo+(#trace*37)%(hi-lo+1) + end + end + local native=stream('initial');b.rng=native + local model=b.battle + T.eq(type(model.rng),'function',edition..' source LOVE RNG exists') + T.eq(type(model.random),'function','source zero-based random exists') + T.eq(type(model.roller),'function','source roller method exists') + if type(model.rng)=='function'and type(model.random)=='function'and type(model.roller)=='function'then + local loveRng,random,roller=model.rng,model.random,model:roller() + T.eq(model.rng,loveRng,'LOVE adapter identity is stable') + T.eq(model.random,random,'zero-based adapter identity is stable') + T.eq(model:roller(),roller,'roller identity is stable') + T.eq(#trace,0,'lookup and cache creation consume no native draws') + for n=1,256 do + local index=#trace+1 + T.eq(probe.love(model,n),1+(index*37)%n,'source LOVE result uses the next actual native draw') + T.eq(#trace,index,'one actual draw for LOVE call') + index=#trace+1 + T.eq(probe.cart(model,n),(index*37)%n,'source random uses zero-based native result') + T.eq(#trace,index,'one actual draw for source random') + index=#trace+1 + T.eq(probe.method(model,n),(index*37)%n,'public class roller uses the next same native draw') + T.eq(#trace,index,'one actual draw for source roller') + end + for _,bounds in ipairs{{5,9},{9,5},{-3,2},{4,4}}do + local lo,hi=math.min(bounds[1],bounds[2]),math.max(bounds[1],bounds[2]) + local index=#trace+1 + T.eq(probe.love(model,bounds[1],bounds[2]),lo+(index*37)%(hi-lo+1),'source range normalization uses native draw') + T.eq(trace[index].lo,lo,'normalized native lower bound') + T.eq(trace[index].hi,hi,'normalized native upper bound') + end + for _,bound in ipairs{0,-7}do + local index=#trace+1 + T.eq(probe.love(model,bound),1,'source small bound normalizes to one') + T.eq(#trace,index,'normalized one-point range still consumes one actual draw') + end + local index=#trace+1 + T.eq(probe.love(model),1,'source missing bound normalizes to one') + T.eq(#trace,index,'missing bound uses one actual native draw') + local replacement=stream('replacement');b.rng=replacement + index=#trace+1 + T.eq(loveRng(2,8),2+(index*37)%7,'retained LOVE view follows replacement owner') + T.eq(trace[index].tag,'replacement','retained LOVE view cannot draw from old function') + index=#trace+1 + T.eq(random(256),(index*37)%256,'retained random follows replacement owner') + T.eq(trace[index].tag,'replacement','retained zero-based view cannot draw from old function') + index=#trace+1 + T.eq(roller(255),(index*37)%255,'retained roller follows replacement owner') + T.eq(trace[index].tag,'replacement','retained roller cannot draw from old function') + T.eq(b.rng,replacement,'adapters never replace the actual native function') + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,save.party[2]) + T.eq(b.battle,model,'real native switch retains its model') + T.eq(model.rng,loveRng,'real native switch retains LOVE adapter') + T.eq(model:roller(),roller,'real native switch retains source roller') + index=#trace+1 + T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') + T.eq(#trace,index,'incoming actor does not create another generator') + T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') + T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') + T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') + end + for _,mon in ipairs(save.party)do + T.eq(mon.rng,nil,'no RNG function enters party mon') + T.eq(mon.random,nil,'no random function enters party mon') + T.eq(mon._nativeRoller,nil,'no adapter cache enters party mon') + end + run.release() +end +-- Internal adapter cycles must be collectible when their native owner and +-- caller references end. A global value closure retaining a weak key is not. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local screen={rng=function(n)return n end};local model=Model.bind({},screen) + Model.roller(model);weak[model]=true;weak[screen]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'retired model/RNG adapters do not leak through module state') +V.set(before) +T.finish('native source model RNG fields, domains, replacement, switch and lifetime') From 059b55375c7f922266fce11b740224ce3caf75c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:07:32 +0100 Subject: [PATCH 16/34] Preserve the distinct source random field and decorated roller seam --- src/battle/Gen2BattleView.lua | 2 +- src/battle/gen2/Battle.lua | 4 +++- tests/engine/gen2_model_rng.lua | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index a5d6fe5bd..9a0e8b49c 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -23,7 +23,7 @@ function View.bind(screen) save = function() return screen.game.save end, data = function() return screen.data end, rng = function() return rawget(screen.battle, '_nativeLoveRng') end, - random = function() return screen.battle:roller() end, + random = function() return rawget(screen.battle, '_nativeRandom') end, wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index c71bc500f..e33aadf0c 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -12,6 +12,7 @@ end function Model.bind(model, screen) screens[model] = screen + rawset(model, '_nativeRandom', function(n) return owner(model).rng(n) - 1 end) -- Source GSC's LOVE-style view uses the current native function even if -- link setup or a scoped native owner replaces it after this bind. rawset(model, '_nativeLoveRng', function(lo, hi) @@ -29,7 +30,8 @@ function Model:roller() if not roller then -- Zero-based BattleRandom over the same existing stream. Keep the cache -- on the transient model; a global value closure could root a weak key. - roller = function(n) return owner(self).rng(n) - 1 end + local random = rawget(self, '_nativeRandom') + roller = function(n) return random(n) end rawset(self, '_nativeRoller', roller) end return roller diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua index b07ecddda..be92999ce 100644 --- a/tests/engine/gen2_model_rng.lua +++ b/tests/engine/gen2_model_rng.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. -- Actual source consumers must share the native stream, including after its -- function is replaced. No shadow generator, draws on lookup or saved closures. package.path='./?.lua;./?/init.lua;'..package.path @@ -20,6 +20,13 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end mod.exports.cart=function(model,n)return model.random(n)end mod.exports.method=function(model,n)return Battle.roller(model)(n)end + mod.exports.decorate=function() + local native=Battle.roller + Battle.roller=function(model) + mod.exports.lookups=(mod.exports.lookups or 0)+1 + return native(model) + end + end ]], })}) T.eq(#run.errors,0,'actual API2 source RNG consumer loads') @@ -46,6 +53,7 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do T.eq(model.rng,loveRng,'LOVE adapter identity is stable') T.eq(model.random,random,'zero-based adapter identity is stable') T.eq(model:roller(),roller,'roller identity is stable') + T.check(random~=roller,'source random field remains separate from the roller method wrapper') T.eq(#trace,0,'lookup and cache creation consume no native draws') for n=1,256 do local index=#trace+1 @@ -91,6 +99,15 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do index=#trace+1 T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') T.eq(#trace,index,'incoming actor does not create another generator') + probe.decorate() + local count=#trace + T.eq(model.random,random,'decorating roller does not replace the source random field') + T.eq(model.rng,loveRng,'decorating roller does not replace source LOVE field') + T.eq(probe.lookups,nil,'source field lookup does not invoke a decorated roller method') + index=#trace+1 + T.eq(probe.method(model,255),(index*37)%255,'actual API2 roller decoration keeps native draw ownership') + T.eq(probe.lookups,1,'actual API2 consumer reaches the live class decoration') + T.eq(#trace,count+1,'decoration lookup itself adds no draw') T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') From be9b3d074b22ddadb84a2c68e3a06bc17b5bac56 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:09:38 +0100 Subject: [PATCH 17/34] Exercise actual source RNG callbacks in older-host linked roles --- tests/engine/gen2_model_rng_link.lua | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/engine/gen2_model_rng_link.lua diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua new file mode 100644 index 000000000..d3104599c --- /dev/null +++ b/tests/engine/gen2_model_rng_link.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source callbacks draw from the actual seeded link stream in every role. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/source_rng'},{data=data,fs=T.sdk.memfs({ + ['mods/source_rng/manifest.json']='{"id":"source_rng","name":"Source RNG","version":"1.0.0","api":2,"entry":"main.lua","affects_link":true}', + ['mods/source_rng/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.rows={} + mod.events:on('battle.move_used',function(c) + local model=c.battle.battle + local rows=mod.exports.rows[c.battle] + if not rows then rows={};mod.exports.rows[c.battle]=rows end + rows[#rows+1]={name=c.user.mon.nickname,love=model.rng(16), + cart=model.random(256),roller=Model.roller(model)(255)} + end) + ]], + })}) + T.eq(#run.errors,0,'actual source callback consumer loads');T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].nickname=i==1 and'HOST'or'GUEST' + game.save.party[1].moves={{id='QA_ENDURE',pp=20}};games[i]=game + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,30000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[2])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[2]and b.localHashes[2]and s.turnCount==2 and s.phase=='waitBoth'then break end + end + for turn=1,2 do + T.check(a.localHashes[turn]~=nil and b.localHashes[turn]~=nil,'actual source-consumer link turn completes') + T.eq(a.localHashes[turn],b.localHashes[turn],'source RNG calls preserve native lockstep') + T.same(a.localParts[turn],b.localParts[turn],'every completed native state component agrees') + end + local rows=run.loader.exports.source_rng.rows + T.same(rows[a],rows[b],'both participants observe the same source RNG results') + T.same(rows[a],rows[s],'spectator observes the same source RNG results') + for _,battle in ipairs(roles)do + T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') + for _,row in ipairs(rows[battle]or{})do + T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') + T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') + T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') + end + T.eq(battle.player.mon.moves[1].pp,18,'own native moves spend PP once despite source random calls') + T.eq(battle.enemy.mon.moves[1].pp,18,'other native moves spend PP once despite source random calls') + T.eq(battle.result,nil,'no hidden link simulation error') + end + T.eq(#run.errors,0,'no source callback failure is swallowed') + run.release() +end +V.set(old) +T.finish('actual source RNG callbacks in native GSC link participant and spectator roles') From fdc2be3cd081c04b1467e14d166ccbc2babd51c0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:12:16 +0100 Subject: [PATCH 18/34] Document older-host source RNG owner and pending independent validation --- docs/native-gen2-model-rng.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/native-gen2-model-rng.md diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md new file mode 100644 index 000000000..627668e00 --- /dev/null +++ b/docs/native-gen2-model-rng.md @@ -0,0 +1,24 @@ +# Native Gen2 model RNG + +Expose `rng`, `random` and `roller()` on the live source model, backed by the +existing native controller's current function. LOVE-style ranges normalize +missing/small bounds and reversed ranges; the source random/roller domain is +zero-based for positive integer bounds. Queries consume no randomness and +retained adapters follow native function replacement, including link setup. + +The source random field and decorated roller method stay distinct. The native +read-only reader policy remains in effect. Model-owned function caches do not +enter party mons or serialized battlers, and no second generator is created. +Standalone constructor/RNG-injection and complete source-model parity remain +separate work. + +Reference: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Battle.new`, its LOVE-style RNG +view and `Battle:roller`. This older host's native model, volatile-state and +protection prerequisites are offered separately. The fork-only semantic +checkpoint backend is absent here, so its checkpoint result is not inherited. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR contribution permission, with existing rights intact. +Validation is pending independent execution on this offered host; no full +model/mod/GPU/release acceptance is claimed from static preparation. From dec137f65b6f48e700a512d47128aae1bd0302c2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:22:04 +0100 Subject: [PATCH 19/34] Record independently tested older-host source RNG and unchanged baseline failures --- docs/native-gen2-model-rng.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md index 627668e00..1de093552 100644 --- a/docs/native-gen2-model-rng.md +++ b/docs/native-gen2-model-rng.md @@ -20,5 +20,12 @@ checkpoint backend is absent here, so its checkpoint result is not inherited. Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under the specific PR contribution permission, with existing rights intact. -Validation is pending independent execution on this offered host; no full -model/mod/GPU/release acceptance is claimed from static preparation. +Independent validation on this offered host passes 7951 native RNG checks, +174 actual host/guest/spectator source-callback checks, model (262), volatile +(423) and protection-link (66) regressions. A separately executed frozen Gen1 +comparison passes 9471 comparisons / 7020 draw cases over actual seeded native +LinkBattle backends; reference hashes remain unchanged. The exact official +whole-host command passes 71/82 engine suites and SDK3/3, with the same eleven +pre-existing failures as its protection base (69/80). It adds two passing suites +without adding a whole-host failure; this older prerequisite stack is not +entirely green. Full model/mod/GPU/release acceptance remains separate. From 508b2cf5a1a6c510b0f221d88be4bc471f4d471e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:48:19 +0100 Subject: [PATCH 20/34] Restore native GSC trainer friendship and source class predicates --- docs/native-gen2-trainer-friendship.md | 47 ++++++++++++++++++++++++++ src/battle/BattleState.lua | 10 +++++- src/battle/Gen2TrainerClass.lua | 20 +++++++++++ src/battle/gen2/Battle.lua | 21 ++++++++++++ src/pokemon/Gen2Friendship.lua | 15 ++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-trainer-friendship.md create mode 100644 src/battle/Gen2TrainerClass.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md new file mode 100644 index 000000000..3ba0b0c66 --- /dev/null +++ b/docs/native-gen2-trainer-friendship.md @@ -0,0 +1,47 @@ +# Native GSC trainer friendship and source class predicates + +The shared native trainer constructor now applies the original Gold/Silver/ +Crystal `GYMBATTLE` friendship event before the first turn. Previously the +award was coupled to `BattleState:enter()` and the narrower badge-leader +marker: the Elite Four, Champion and Red received no award, and construction +alone did not expose the updated values to source consumers. + +`src.battle.gen2.Battle` exposes the source `GYM_LEADER_CLASSES`, +`KANTO_GYM_LEADER_CLASSES`, `isGymLeader(class)` and +`isKantoGymLeader(class)` contracts. The first predicate covers all 22 classes +in the cartridge's combined leader list; the second covers its eight Kanto +classes. The actual native constructor calls the public predicate, so an +API2 class decoration affects the real battle rather than a second model. + +Imported native records retain their numeric group index. The engine maps +that identity to the canonical class identifier rather than comparing the +localized display name, which is often just `LEADER`. It does not mutate the +imported trainer record or claim that every source trainer-view field is +implemented. + +The award uses the existing native friendship owner and the battle's ordered +party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1 +otherwise, clamped to 255. Excluded party members are untouched. API2 +`happiness.changed` callbacks carry the original native mon and the source +event ID 4. Applied events at saturation (and other zero-delta happiness +events) remain observable, as on the reference engine; unchanged walking +steps remain silent. + +The award is not replayed by screen entry, semantic checkpoint reconstruction +or presentation resume. Native badge/music and Yellow companion markers keep +their separate meaning. Other editions and Gen2 hacks keep their existing +friendship rules. No mod, Gen1 installation, player profile, save or ROM is +modified by this change. + +## Validation + +The committed ROM-free regressions exercise real `BattleState.newTrainer`, +the production API2 loader and event bus, all vanilla classes and seven +friendship boundaries, native egg representations, fainted/selected party +members, decorator reachability, other-edition guards and actual semantic +checkpoint reconstruction. Execution results and independent frozen-reference +comparison are recorded in the PR after running those checks. + +This closes the class-predicate/constructor-award gap. It does not establish +complete source-model constructors, turn/event parity, graphical parity, +unchanged VASC acceptance or release readiness. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index d3a3c3a6d..ca51f7dec 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1788,6 +1788,15 @@ function BattleState.newTrainer(game, oppClass, partyIndex) self.trainerLabel = shownName end self.introText = Strings("%s wants\nto fight!", shownName) + -- InitEnemyTrainer awards GSC friendship before the first turn, including + -- Elite Four/Champion/Red. Resolve the public source predicate so API2 + -- decorators affect this actual native owner. The party view contains the + -- original selected mons; excluded save-party members receive no award. + if require("src.pokemon.Gen2Friendship").isVanilla() + and require("src.battle.gen2.Battle").isGymLeader( + require("src.battle.Gen2TrainerClass").fromIndex(self.trainer.index)) then + require("src.pokemon.Gen2Friendship").gymBattle({party=self:playerPartyView()}) + end return self end @@ -3644,7 +3653,6 @@ function BattleState:enter() if self.isGymLeader then require("src.world.PikachuFollower") .modifyHappiness(self.game.save, "GYMLEADER") - require("src.pokemon.Gen2Friendship").gymBattle(self.game.save) end -- normally already playing: the transition wipe starts the theme -- (audio/play_battle_music.asm runs before the transition, and diff --git a/src/battle/Gen2TrainerClass.lua b/src/battle/Gen2TrainerClass.lua new file mode 100644 index 000000000..3a4044175 --- /dev/null +++ b/src/battle/Gen2TrainerClass.lua @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Canonical vanilla GSC class identities in TrainerGroups order. Imported +-- records carry this numeric index; their translated/display name is not an +-- identity (all badge leaders can be named LEADER). +local Class = {} +local names = { + 'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN', +} +function Class.fromIndex(index) + return names[tonumber(index)] +end +return Class diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index e33aadf0c..a4a0252d7 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -3,6 +3,27 @@ -- an alias to the screen and does not construct or advance a second battle. local Runtime = require('src.mods.Runtime') local Model = {} +-- IsGymLeader's cartridge list includes the Elite Four, Champion and Red, +-- then falls through into the eight Kanto leaders. Keep this public source +-- predicate distinct from the native badge/Pikachu companion marker. +Model.KANTO_GYM_LEADER_CLASSES = { + BROCK=true, MISTY=true, LT_SURGE=true, ERIKA=true, + JANINE=true, SABRINA=true, BLAINE=true, BLUE=true, +} +Model.GYM_LEADER_CLASSES = { + FALKNER=true, WHITNEY=true, BUGSY=true, MORTY=true, + PRYCE=true, JASMINE=true, CHUCK=true, CLAIR=true, + WILL=true, BRUNO=true, KAREN=true, KOGA=true, CHAMPION=true, RED=true, +} +for class in pairs(Model.KANTO_GYM_LEADER_CLASSES) do + Model.GYM_LEADER_CLASSES[class] = true +end +function Model.isGymLeader(class) + return class ~= nil and Model.GYM_LEADER_CLASSES[class] == true +end +function Model.isKantoGymLeader(class) + return class ~= nil and Model.KANTO_GYM_LEADER_CLASSES[class] == true +end local screens = setmetatable({}, { __mode = 'kv' }) local ended = setmetatable({}, { __mode = 'k' }) diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 7ef888c15..29dc2ab9b 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -11,6 +11,18 @@ local changes = { LEVELUPATHOME = {10,6,4}, } +local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, + FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local function changed(mon,from,event,reason) + local Runtime=require('src.mods.Runtime') + -- Applied source events remain observable even at byte saturation; + -- unchanged walking steps do not report a change. + if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then + Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + from=from,to=mon.happiness,delta=mon.happiness-from}) + end +end + function Friendship.isVanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end @@ -23,6 +35,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) + changed(mon,h,events[reason],"event") return true end @@ -34,7 +47,9 @@ function Friendship.stepCycle(save) if save.g2HappinessStepCount ~= 0 then return end for _, mon in ipairs(save.party or {}) do if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then + local from=mon.happiness mon.happiness = math.min(255, mon.happiness + 1) + changed(mon,from,nil,"step") end end end From c8e6e959a450327f7326ccf3cd1124a4bafbd747 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:48:38 +0100 Subject: [PATCH 21/34] Test the actual older-host API2 friendship path without checkpoint claims --- docs/native-gen2-trainer-friendship.md | 11 +- tests/engine/gen2_trainer_friendship.lua | 158 +++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_trainer_friendship.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 3ba0b0c66..05428db6f 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -27,8 +27,7 @@ event ID 4. Applied events at saturation (and other zero-delta happiness events) remain observable, as on the reference engine; unchanged walking steps remain silent. -The award is not replayed by screen entry, semantic checkpoint reconstruction -or presentation resume. Native badge/music and Yellow companion markers keep +The award is not replayed by screen entry. Native badge/music and Yellow companion markers keep their separate meaning. Other editions and Gen2 hacks keep their existing friendship rules. No mod, Gen1 installation, player profile, save or ROM is modified by this change. @@ -38,10 +37,14 @@ modified by this change. The committed ROM-free regressions exercise real `BattleState.newTrainer`, the production API2 loader and event bus, all vanilla classes and seven friendship boundaries, native egg representations, fainted/selected party -members, decorator reachability, other-edition guards and actual semantic -checkpoint reconstruction. Execution results and independent frozen-reference +members, decorator reachability and other-edition guards. This older offered +host lacks the fork's semantic checkpoint backend, so the fork-only checkpoint +test/result is not inherited. Execution results and independent frozen-reference comparison are recorded in the PR after running those checks. This closes the class-predicate/constructor-award gap. It does not establish complete source-model constructors, turn/event parity, graphical parity, unchanged VASC acceptance or release readiness. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR permission in LICENSE-ROXAS2712.md; existing rights remain intact. diff --git a/tests/engine/gen2_trainer_friendship.lua b/tests/engine/gen2_trainer_friendship.lua new file mode 100644 index 000000000..7b2133586 --- /dev/null +++ b/tests/engine/gen2_trainer_friendship.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real native constructors and an API2 consumer, without ROMs or mod edits. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +local originalGym,originalKanto=Model.isGymLeader,Model.isKantoGymLeader +-- Independent cartridge class order; display names deliberately all agree. +local classes={'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN'} +local leaders={FALKNER=true,WHITNEY=true,BUGSY=true,MORTY=true,PRYCE=true, + JASMINE=true,CHUCK=true,CLAIR=true,WILL=true,BRUNO=true,KAREN=true,KOGA=true, + CHAMPION=true,RED=true,BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local kanto={BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + local run=T.sdk.loadMods({'mods/friendship_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/friendship_probe/manifest.json']='{"id":"friendship_probe","name":"Native friendship probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/friendship_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.events={} + mod.events:on('happiness.changed',function(ev) + mod.exports.events[#mod.exports.events+1]=ev + end) + mod.exports.query=function(class) + if type(Battle.isGymLeader)~='function' or type(Battle.isKantoGymLeader)~='function' then return end + return Battle.isGymLeader(class),Battle.isKantoGymLeader(class) + end + mod.exports.decorate=function() + local native=Battle.isGymLeader + if type(native)~='function' then return end + Battle.isGymLeader=function(class) + mod.exports.lastClass=class + if mod.exports.override~=nil then return mod.exports.override end + return native(class) + end + end + ]], + })}) + T.eq(#run.errors,0,'real API2 friendship consumer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,5 do save.party[i]=Pokemon.new(data,'FIXMON_A',15)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_FRIENDSHIP={id='OWN_FRIENDSHIP',index=1,name='LEADER', + baseMoney=10,parties={{{level=5,species='FIXMON_C'}}}} + return game,run,run.loader.exports.friendship_probe +end +local function prepare(game,h) + for _,mon in ipairs(game.save.party)do + mon.hp,mon.happiness,mon.isEgg,mon.eggSteps=mon.stats.hp,h,nil,nil + end + game.save.party[3].hp=0 + game.save.party[4].isEgg=true + game.save.party[5].eggSteps=256 +end +for _,edition in ipairs{'gold','silver','crystal'}do + local game,run,probe=fixture(edition) + T.eq(type(Model.isGymLeader),'function','public source class predicate exists') + T.eq(type(Model.isKantoGymLeader),'function','public Kanto predicate exists') + for i,class in ipairs(classes)do + local gym,kgym=probe.query(class) + T.eq(gym,leaders[class]==true,'API2 source predicate '..class) + T.eq(kgym,kanto[class]==true,'API2 Kanto predicate '..class) + for _,h in ipairs{0,99,100,199,200,254,255}do + prepare(game,h);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=i + local party=game.save.party + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + local delta=h<100 and 3 or h<200 and 2 or 1 + local expected=leaders[class] and math.min(255,h+delta)or h + for n=1,5 do + T.eq(party[n].happiness,n<=2 and expected or h, + edition..' constructor award '..class..' '..h..' slot '..n) + T.eq(party[n].hp,n==3 and 0 or party[n].stats.hp,'bonus does not heal') + end + T.eq(b:playerPartyView(),party,'constructor keeps actual native party') + T.eq(#probe.events,leaders[class]and 2 or 0,'one event per standing non-egg, including byte saturation') + for n,ev in ipairs(probe.events)do + T.eq(ev.mon,party[n],'API2 event retains actual native mon') + T.eq(ev.event,4,'cartridge GYMBATTLE event id') + T.eq(ev.reason,'event','source reason') + T.eq(ev.from,h,'event before value') + T.eq(ev.to,expected,'event after value') + T.eq(ev.delta,expected-h,'event clamped delta') + end + if h==100 then + local events=#probe.events + b:enter() + T.eq(party[1].happiness,expected,'real enter does not award a second bonus') + T.eq(#probe.events,events,'real enter does not publish duplicate friendship events') + end + end + end + for _,class in ipairs{'UNKNOWN','falkner','LEADER','',false,1}do + local gym,kgym=probe.query(class) + T.eq(gym,false,'unknown source class does not become a leader') + T.eq(kgym,false,'unknown source class does not become a Kanto leader') + end + local gym,kgym=probe.query(nil) + T.eq(gym,false,'nil source class is not a leader');T.eq(kgym,false,'nil Kanto class is not a leader') + -- The native constructor must call the public source seam with canonical + -- identity, rather than a frozen private boolean or localized display name. + probe.decorate();probe.override=false;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=63 + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + T.eq(probe.lastClass,'RED','API2 decorator observes actual native Red class') + T.eq(game.save.party[1].happiness,100,'decorated rejection reaches native constructor') + probe.override=true;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index='22' + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1,{playerPartyIndices={2,3,4,5}}) + T.eq(probe.lastClass,'YOUNGSTER','ordinary imported numeric-string class resolves canonically') + T.eq(game.save.party[1].happiness,100,'out-of-scope save party member earns no bonus') + T.eq(game.save.party[2].happiness,102,'selected native standing member earns bonus') + T.eq(#probe.events,1,'selected fainted and both native egg representations stay excluded') + T.eq(probe.events[1]and probe.events[1].mon,game.save.party[2],'scoped event uses original selected mon') + T.eq(b:playerPartyView()[1],game.save.party[2],'ordered scope remains live') + -- Source ChangeHappiness reports applied zero-delta events, while its step + -- loop suppresses unchanged saturated values. Keep the two sites distinct. + prepare(game,255);probe.events={} + local friendship=require('src.pokemon.Gen2Friendship') + friendship.change(game.save.party[1],'XITEM') + T.eq(#probe.events,1,'zero-delta event remains observable by the actual API2 consumer') + T.eq(probe.events[1]and probe.events[1].delta,0,'zero-delta event is explicit') + probe.events={};game.save.g2HappinessStepCount=1 + friendship.stepCycle(game.save) + T.eq(#probe.events,0,'unchanged saturated step does not publish a change') + Model.isGymLeader,Model.isKantoGymLeader=originalGym,originalKanto + run.release() +end +for _,edition in ipairs{'red','blue','yellow','ruby','sapphire','emerald','firered','leafgreen','prism','polishedcrystal'}do + local game,run,probe=fixture(edition) + prepare(game,100) + for _,index in ipairs{1,11,16,63,64}do + game.data.trainers.OWN_FRIENDSHIP.index=index + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + end + T.eq(game.save.party[1].happiness,100,edition..' keeps its own constructor friendship rules') + T.eq(#probe.events,0,edition..' does not receive vanilla GSC award events') + run.release() +end +V.set(old) +T.finish('native GSC trainer class predicates, constructor friendship, live events and scoped party') From a92fd975eca01d4370e1297011d9a511b3962165 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:51:49 +0100 Subject: [PATCH 22/34] Match independently observed source happiness event names --- docs/native-gen2-trainer-friendship.md | 3 ++- src/pokemon/Gen2Friendship.lua | 7 +++++-- tests/engine/gen2_trainer_friendship.lua | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 05428db6f..ec6a7e811 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -23,7 +23,8 @@ The award uses the existing native friendship owner and the battle's ordered party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1 otherwise, clamped to 255. Excluded party members are untouched. API2 `happiness.changed` callbacks carry the original native mon and the source -event ID 4. Applied events at saturation (and other zero-delta happiness +symbolic event (`GYMBATTLE`); the numeric cartridge index remains available +as `eventId` (4). Applied events at saturation (and other zero-delta happiness events) remain observable, as on the reference engine; unchanged walking steps remain silent. diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 29dc2ab9b..6d58ab815 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -13,12 +13,15 @@ local changes = { local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local sourceEvents={LEVELUP='GAINLEVEL',VITAMIN='USEDITEM',XITEM='USEDXITEM', + FAINT='FAINTED',STRONGFOE='BEATENBYSTRONGFOE',LEVELUPATHOME='GAINLEVELATHOME'} local function changed(mon,from,event,reason) local Runtime=require('src.mods.Runtime') -- Applied source events remain observable even at byte saturation; -- unchanged walking steps do not report a change. if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then - Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + Runtime.emit("happiness.changed",{mon=mon,event=event and (sourceEvents[event]or event), + eventId=events[event],reason=reason, from=from,to=mon.happiness,delta=mon.happiness-from}) end end @@ -35,7 +38,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) - changed(mon,h,events[reason],"event") + changed(mon,h,reason,"event") return true end diff --git a/tests/engine/gen2_trainer_friendship.lua b/tests/engine/gen2_trainer_friendship.lua index 7b2133586..fd688edbf 100644 --- a/tests/engine/gen2_trainer_friendship.lua +++ b/tests/engine/gen2_trainer_friendship.lua @@ -93,7 +93,8 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(#probe.events,leaders[class]and 2 or 0,'one event per standing non-egg, including byte saturation') for n,ev in ipairs(probe.events)do T.eq(ev.mon,party[n],'API2 event retains actual native mon') - T.eq(ev.event,4,'cartridge GYMBATTLE event id') + T.eq(ev.event,'GYMBATTLE','source constructor event name') + T.eq(ev.eventId,4,'native cartridge event index remains available') T.eq(ev.reason,'event','source reason') T.eq(ev.from,h,'event before value') T.eq(ev.to,expected,'event after value') From ad949620a4e900da9ba071c1d70adeb5c3d2bd38 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:12:56 +0100 Subject: [PATCH 23/34] Supply older-host native party scope prerequisite for trainer friendship --- docs/native-gen2-trainer-friendship.md | 15 +++ src/battle/BattleState.lua | 42 ++++---- src/battle/Gen3MoveEffects.lua | 4 +- src/battle/Gen5MoveEffects.lua | 8 +- src/battle/Gen5MoveRules.lua | 2 +- src/battle/PlayerPartyScope.lua | 36 +++++++ src/ui/Gen3PartyMenu.lua | 4 +- src/ui/Gen4PartyMenu.lua | 4 +- src/ui/PartyMenu.lua | 2 +- tests/engine/native_trainer_party_scope.lua | 102 ++++++++++++++++++++ 10 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 src/battle/PlayerPartyScope.lua create mode 100644 tests/engine/native_trainer_party_scope.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index ec6a7e811..73d5d7388 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -33,6 +33,21 @@ their separate meaning. Other editions and Gen2 hacks keep their existing friendship rules. No mod, Gen1 installation, player profile, save or ROM is modified by this change. +## Older-host party prerequisite + +This offered host did not have the fork's battle-local party selection or +`playerPartyView()`. Supply the original native selection integration here: +validate and retain ordered original mon references in the trainer constructor, +then use the same roster for the lead, doubles, native party menus, benches, +party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder +`save.party`. Invalid selections fall back to the complete original party. + +The standalone `native_trainer_party_scope` regression drives actual older-host +constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat +across its supported editions. This prerequisite is tested on this offered host; +the corresponding fork result is not inherited. Execution status is recorded +in the draft PR before qualification. + ## Validation The committed ROM-free regressions exercise real `BattleState.newTrainer`, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ca51f7dec..e1c2d39fc 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1702,14 +1702,20 @@ end BattleState.buildTrainerParty = buildTrainerParty +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1737,7 +1743,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -1853,7 +1859,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, makeBattler(game.data, second, true, game.save, self)) @@ -2000,7 +2006,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2018,7 +2024,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -4509,7 +4515,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -5119,7 +5125,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -8720,7 +8726,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8908,7 +8914,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -9029,7 +9035,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -9059,9 +9065,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -9106,7 +9112,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9436,7 +9442,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9524,7 +9530,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9582,7 +9588,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -10862,7 +10868,7 @@ function BattleState:finish() -- go on, the partner heals in the field), so it stands. if self.result ~= "lose" and not self.demo and not self.pokedudeDemo and not (self.ally and self.result == "win") - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -12611,7 +12617,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() if self.player and not hidePlayer and not self.showPlayerBack diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 57c4f1e8c..384f1af58 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -1028,7 +1028,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..59029684f 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..bbf5f6d0c 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..8a32551c2 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -438,7 +438,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua new file mode 100644 index 000000000..2fae854b2 --- /dev/null +++ b/tests/engine/native_trainer_party_scope.lua @@ -0,0 +1,102 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Actual older-host constructors, menus, EXP/faint owners and party effects. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local B=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs(V.ORDER)do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/scope_probe/main.lua']=[[ + local mod=... + mod.hooks:wrap('battle.exp_award',function(next,ctx) + mod.exports.award=ctx + return next(ctx) + end) + ]], + })}) + T.eq(#run.errors,0,'real native party observer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,3 do save.party[i]=Pokemon.new(data,'FIXMON_A',20+i)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_SCOPE={id='OWN_SCOPE',index=22,name='SCOPE',baseMoney=10, + parties={{{level=5,species='FIXMON_C'}}}} + local original,first,second,third=save.party,save.party[1],save.party[2],save.party[3] + local selected={3,2} + local b=B.newTrainer(game,'OWN_SCOPE',1,{playerPartyIndices=selected}) + T.eq(b.player.mon,third,'native lead is the first selected original mon') + T.eq(b:playerPartyView()[1],third,'ordered scope retains actual first reference') + T.eq(b:playerPartyView()[2],second,'ordered scope retains actual second reference') + T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices') + T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster') + T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement') + local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu', + {battle=b,onSwitch=function()end}) + local shown=type(menu.party)=='function'and menu:party()or menu.party + T.eq(shown,b.playerParty,'actual generation-appropriate party menu uses scope') + T.eq(#shown,2,'actual party menu cannot offer an excluded mon') + -- The existing AI already understands playerParty. Ensure construction + -- and replacement draw from that same original roster, including doubles. + local vm={battle=b,user=b.player,target=b.enemy} + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,1,'real AI counts only one selected off-field replacement') + b.double=true;b:sendOutSecondPair() + T.eq(b:battlerAt(B.POS.PLAYER_RIGHT).mon,second,'second flank draws from selected roster') + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,0,'both selected mons on field leave no AI replacement') + -- Party-wide effects must not heal or count the excluded save-party mon. + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen3MoveEffects').primary.HEAL_BELL_EFFECT(b,b.player) + T.eq(first.status,'BRN','actual Heal Bell leaves excluded mon alone') + T.eq(second.status,nil,'actual Heal Bell heals selected second mon') + T.eq(third.status,nil,'actual Heal Bell heals selected lead') + first.status=nil + local count=require('src.battle.Gen3MoveEffects').full.BEAT_UP_EFFECT.hitCount( + {battle=b,user=b.player,target=b.enemy}) + T.eq(count,2,'real Beat Up hit count excludes healthy save-party outsider') + if V.isGen5()then + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen5MoveEffects').record(b,'BW_HEAL_BELL').run({ + battle=b,user=b.player,target=b.player, + move={id='OWN_SCOPE_BELL',name='BELL',power=0,type='NORMAL'}, + }) + T.eq(first.status,'BRN','actual Gen5 effect leaves excluded mon alone') + T.eq(second.status,nil,'actual Gen5 effect heals selected second mon') + T.eq(third.status,nil,'actual Gen5 effect heals selected lead') + end + -- Native EXP.ALL includes exactly this battle's party, even if stale + -- participation bookkeeping still names the excluded save-party mon. + b.queue={};b.participants={[first]=true,[second]=true,[third]=true} + save.inventory.EXP_ALL=1 + local excludedExp=first.exp + b:awardExp(b.enemy) + local award=run.loader.exports.scope_probe.award + T.check(award~=nil,'actual native EXP hook executes') + if award then + T.eq(award.participants,2,'actual EXP divisor contains selected participants') + T.eq(#award.alive,2,'actual EXP living roster excludes outsider') + end + T.eq(first.exp,excludedExp,'actual EXP.ALL cannot pay excluded outsider') + second.hp,third.hp=0,0 + T.check(first.hp>0,'excluded save-party member remains healthy') + b.result=nil;b:playerMonFainted() + T.eq(b.result,'lose','actual native defeat ignores excluded healthy party member') + T.eq(save.party,original,'battle never replaces real save.party') + T.eq(save.party[1],first,'battle never reorders excluded save-party member') + T.eq(save.party[2],second,'battle never reorders selected save-party member') + T.eq(save.party[3],third,'battle retains every original party mon') + for _,invalid in ipairs{{},{1,1},{0},{4},{1.5},{[1]=1,[3]=2},{1,unexpected=true},'bad'}do + local party,indices=require('src.battle.PlayerPartyScope').select(game,invalid) + T.eq(party,nil,'invalid native selection falls back to complete party') + T.eq(indices,nil,'invalid selection keeps no misleading remap') + end + run.release() +end +V.set(old) +T.finish('older-host real trainer party selection, UI, AI, party effects, EXP and defeat') From 607a0e772c1dfffa68c42b60ef67949549e97886 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:15:00 +0100 Subject: [PATCH 24/34] Clarify separate older-host scope qualification requirement --- docs/native-gen2-trainer-friendship.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 73d5d7388..b21b759eb 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -44,9 +44,9 @@ party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder The standalone `native_trainer_party_scope` regression drives actual older-host constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat -across its supported editions. This prerequisite is tested on this offered host; -the corresponding fork result is not inherited. Execution status is recorded -in the draft PR before qualification. +across its supported editions. Qualification requires execution on this offered +host; the corresponding fork result is not inherited. Execution status is +recorded in the draft PR. ## Validation From b17a8f1b955a3ecaa44ac814bc391bbece41fff3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:31:33 +0100 Subject: [PATCH 25/34] Use actual entered battle phase and native species schema in scope regression --- tests/engine/native_trainer_party_scope.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua index 2fae854b2..ee05106f1 100644 --- a/tests/engine/native_trainer_party_scope.lua +++ b/tests/engine/native_trainer_party_scope.lua @@ -10,6 +10,17 @@ local old=V.get() for _,edition in ipairs(V.ORDER)do V.set(edition) local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + if data.constants.gen>=3 then + -- These editions use the structural six-stat/IV schema. The base fixture + -- is Gen1-shaped, so give the owned species their proper native layout. + for _,def in pairs(data.pokemon)do + if type(def)=='table'and def.baseStats then + def.baseStats.spatk=def.baseStats.spatk or def.baseStats.special + def.baseStats.spdef=def.baseStats.spdef or def.baseStats.special + def.evYield={hp=0,attack=0,defense=0,speed=0,spatk=0,spdef=0} + end + end + end local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({ ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}', ['mods/scope_probe/main.lua']=[[ @@ -36,6 +47,10 @@ for _,edition in ipairs(V.ORDER)do T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices') T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster') T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement') + -- AI runs after real screen entry initializes native on-field sides; a + -- constructor's not-yet-entered empty side table is not an AI phase. + b:enter() + T.eq(b.sides[1].battlers[1],b.player,'actual enter initializes the selected on-field owner') local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu', {battle=b,onSwitch=function()end}) local shown=type(menu.party)=='function'and menu:party()or menu.party From 8d781f27d921ccb46b40ef2e2fa139e34b4bf74a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:40:09 +0100 Subject: [PATCH 26/34] Test source round clock at actual native GSC execution boundaries --- tests/engine/gen2_model_round_clock.lua | 77 +++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/engine/gen2_model_round_clock.lua diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua new file mode 100644 index 000000000..070927229 --- /dev/null +++ b/tests/engine/gen2_model_round_clock.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- An actual API2 consumer observes the native round clock at execution edges. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_CLOCK={id='QA_CLOCK',name='CLOCK',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/round_clock'},{data=data,fs=T.sdk.memfs({ + ['mods/round_clock/manifest.json']='{"id":"round_clock","name":"Round clock","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/round_clock/main.lua']=[[ + local mod=... + mod.exports.rows={} + for _,name in ipairs{'battle.turn_started','battle.move_used','battle.turn_ended'}do + local eventName=name + mod.events:on(name,function(event) + local b=event.battle + mod.exports.rows[#mod.exports.rows+1]={name=eventName,turn=b.battle.turn,native=b.turnCount,eventTurn=event.turn} + end) + end + mod.exports.read=function(model)return model.turn end + ]], + })}) + T.eq(#run.errors,0,'real API2 round observer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_CLOCK',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + b.enemy.curMoves={{id='QA_CLOCK',pp=20}} + b.enemyAction=function()return b.enemy.curMoves[1]end + local probe=run.loader.exports.round_clock + local model=b.battle + T.eq(probe.read(model),0,edition..' starts at source round zero') + T.eq(b.turnCount,nil,'reading initial round does not initialize native state') + for round=1,3 do + local pp=b.player.mon.moves[1].pp + b.queue={};b:resolveTurn(b.player.curMoves[1]) + T.eq(probe.read(model),round,'actual queued native round reaches public model') + local queued=#b.queue + for _=1,10 do T.eq(probe.read(model),round,'repeated observations never advance a round')end + T.eq(#b.queue,queued,'clock observation cannot drain native actions') + T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP') + -- Run the actual two chosen moves; their callback reads must see the same + -- round as the earlier turn-start callback, not a per-move/frame counter. + b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) + T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') + T.eq(probe.read(model),round,'both actual moves share one round') + b:endTurn() + T.eq(probe.read(model),round,'residual processing does not create a new round') + end + local starts,moves,ends=0,0,0 + for _,row in ipairs(probe.rows)do + T.eq(row.turn,row.native,'API2 execution event sees actual native round') + if row.eventTurn then T.eq(row.turn,row.eventTurn,'public model agrees with native event round')end + if row.name=='battle.turn_started'then starts=starts+1 + elseif row.name=='battle.move_used'then moves=moves+1 + elseif row.name=='battle.turn_ended'then ends=ends+1 end + end + T.eq(starts,3,'one real start callback per chosen round') + T.eq(moves,6,'both sides execute in every real round') + T.eq(ends,3,'one real residual completion callback per round') + T.eq(save.party[1].turn,nil,'round state never enters a party Pokemon') + T.eq(save.turn,nil,'round state never enters persistent save data') + local incoming=save.party[2] + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,incoming) + T.eq(model.player,incoming,'native switch really replaces the player') + T.eq(probe.read(model),3,'native switch does not reset battle-wide clock') + T.eq(#run.errors,0,'no swallowed source callback error') + run.release() +end +V.set(old) +T.finish('native GSC source model round clock at actual queue and API2 event edges') From 927bf733665242f8b34fa6a7b72f875631b8e5d4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:41:31 +0100 Subject: [PATCH 27/34] Exercise actual native residual method in round-clock probe --- tests/engine/gen2_model_round_clock.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index 070927229..b6de27aa5 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -50,7 +50,7 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') - b:endTurn() + b:endOfTurn() T.eq(probe.read(model),round,'residual processing does not create a new round') end local starts,moves,ends=0,0,0 From bc9d9ef77fab5b3d44530c65a27c98a6f332c829 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:43:33 +0100 Subject: [PATCH 28/34] Retain actual queued action rows while native execution inserts text --- tests/engine/gen2_model_round_clock.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index b6de27aa5..15c0d7567 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -47,7 +47,9 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP') -- Run the actual two chosen moves; their callback reads must see the same -- round as the earlier turn-start callback, not a per-move/frame counter. - b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'native planner queues both actual actions') + b:runQueueAction(first);b:runQueueAction(second) T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') b:endOfTurn() From a1edb86436fcdd07b88726d6ca50ee8d30b74901 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:44:30 +0100 Subject: [PATCH 29/34] Expose the actual native GSC round clock to source model consumers --- src/battle/Gen2BattleView.lua | 4 ++++ tests/engine/gen2_model_rng_link.lua | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index 9a0e8b49c..787584ccb 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -29,6 +29,10 @@ function View.bind(screen) enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, outcome = function() return screen.result end, + -- The controller increments once per actual round, before turn-start + -- observers, including link participants and spectators. A source query + -- must not start a round or initialize checkpoint/persistent state. + turn = function() return screen.turnCount or 0 end, battleType = function() return screen.battleType end, trainer = function() return screen.trainer end, tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua index d3104599c..5b0194b4c 100644 --- a/tests/engine/gen2_model_rng_link.lua +++ b/tests/engine/gen2_model_rng_link.lua @@ -20,7 +20,7 @@ for _,edition in ipairs{'gold','silver','crystal'}do local model=c.battle.battle local rows=mod.exports.rows[c.battle] if not rows then rows={};mod.exports.rows[c.battle]=rows end - rows[#rows+1]={name=c.user.mon.nickname,love=model.rng(16), + rows[#rows+1]={name=c.user.mon.nickname,turn=model.turn,love=model.rng(16), cart=model.random(256),roller=Model.roller(model)(255)} end) ]], @@ -61,7 +61,8 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.same(rows[a],rows[s],'spectator observes the same source RNG results') for _,battle in ipairs(roles)do T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') - for _,row in ipairs(rows[battle]or{})do + for index,row in ipairs(rows[battle]or{})do + T.eq(row.turn,math.floor((index-1)/2)+1,'actual participant/spectator moves observe the same native round') T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') From fc1aa04068c0a782078d0808a15f1f6170d6f84f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:46:39 +0100 Subject: [PATCH 30/34] Document native round-clock proof and remaining full-model scope --- docs/native-gen2-round-clock.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/native-gen2-round-clock.md diff --git a/docs/native-gen2-round-clock.md b/docs/native-gen2-round-clock.md new file mode 100644 index 000000000..faeda7aae --- /dev/null +++ b/docs/native-gen2-round-clock.md @@ -0,0 +1,32 @@ +# Native Gen2 model round clock + +An API2 reader of `screen.battle.turn` previously received `nil` even while +the native controller emitted numbered turn events. The frozen Gen1 Gen2 +model starts its `turn` field at zero and increments at round admission +(`src/battle/gen2/Battle.lua`, reference revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`). + +The source model now reads the existing native `turnCount`, or zero before +its first round. There is no second counter, timer, event or random stream. +The native controller still owns round admission. Repeated reads, individual +moves, residuals and switches do not advance or reset that clock. Link hosts, +guests and spectators read their actual lockstep controller. Semantic +checkpoint reconstruction reads its restored native count. + +Validation uses actual native constructors, queued actions, API2 event +callbacks, lockstep participants/spectator and semantic checkpoint restore. +The corrected queue probe on the preceding engine reports 295 failures in +400 checks: the public clock is absent at initialization and actual execution +edges. With the engine reader, all 400 pass across Gold, Silver, Crystal, +Prism and Polished Crystal. The link probe passes 210 checks, checkpoint +probe 57, unchanged RNG probe 7951, calculation/completion probe 252 and +friendship checkpoint probe 30 on the fork. The earlier queue-probe drafts +had test-harness mistakes; only its corrected negative run supports the +295-failure comparison. + +This closes the missing live clock observation. It does not implement the +source standalone constructor/turn executor, arbitrary writes to source +model fields, every battle event, full mod visual parity or release-package +acceptance. Those remain separate parity requirements. No mod or original +Gen1/player installation is changed. Implementation and tests are original +contributions by Roxas2712 under `LICENSE-ROXAS2712.md`. From 3e85ef0bd1da912904aa5e5ef6735babafdff641 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:00:11 +0100 Subject: [PATCH 31/34] Exercise older-host actual queue rows without unavailable newer helper --- tests/engine/gen2_model_round_clock.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index 15c0d7567..fb1b7055c 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -49,7 +49,13 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do -- round as the earlier turn-start callback, not a per-move/frame counter. local first,second=b.queue[1],b.queue[2] T.check(first and second and first.fn and second.fn,'native planner queues both actual actions') - b:runQueueAction(first);b:runQueueAction(second) + -- This older host executes action rows inline in updateQueue; the newer + -- runQueueAction helper belongs to a separately offered prerequisite. + -- Execute the actual planner-owned closures with that host's insertion + -- context, rather than adding a fake helper or a second move executor. + for _,item in ipairs{first,second}do + b.nextInsert=0;item.fn();b.nextInsert=nil;b.current=nil + end T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') b:endOfTurn() From f7d6c534313af6fae653911f70a2e5ad090939fa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:26:57 +0100 Subject: [PATCH 32/34] Separate native battler data from presentation on upstream host --- src/battle/BattleState.lua | 93 ++++++++++---------------------------- src/battle/Battler.lua | 51 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 69 deletions(-) create mode 100644 src/battle/Battler.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e1c2d39fc..2a15aeb0c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -40,6 +40,7 @@ local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") local Gen2BattleView = require('src.battle.Gen2BattleView') local Gen2Read = require('src.battle.Gen2BattleRead') +local Battler = require('src.battle.Battler') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -984,75 +985,29 @@ end -- --------------------------------------------------------------------- local function makeBattler(data, mon, isPlayer, save, battle) - local def = require('src.pokemon.Gen4Forms').definition(data,mon) - local badgeBoosts = data.constants and data.constants.badgeBoosts - local badges = nil - if isPlayer and save then - -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged - -- badgeBoosts rows so a retuned list changes what gets baked in - badges = {} - for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do - if save.inventory[row.badge] then badges[row.badge] = true end - end - end - local battler = { - mon = mon, - def = def, - name = mon.nickname or def.name, - -- The species this battler is SHOWING, which is not always `mon.species`: - -- a Transformed mon wears the copied species' pic and a Tower ghost wears - -- none at all. Both of those move this field where they move `sprite`, - -- so anything drawing the battler -- the flat pic here, or a renderer - -- standing a model in its place -- reads one answer and agrees with the - -- other. `mon.species` stays the truth about the Pokemon itself; this is - -- the truth about the picture. - species = mon.species, - isPlayer = isPlayer, - badges = badges, - -- merged registry views consumed by the pure battle modules - badgeBoosts = badgeBoosts, - statuses = data.statuses, - -- ...and the two HoldItems needs: the item table to look `mon.item` up - -- in, and the nature table, because whether a FIGY BERRY confuses the - -- Pokemon that ate it depends on which stat its nature lowers. Views, - -- not copies, so a berry eaten mid-battle is seen the next time it is - -- asked for. - items = data.items, - natures = data.constants and data.constants.natures, - shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) - -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move - -- while the tilemap still shows the prior condition until the next - -- post-action HUD refresh (core.asm after Execute*Move) - shownStatus = mon.status, - -- a sleep put on outside battle with its own count (the Battle Pike's - -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone - sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil, - stages = {}, - -- volatile state; Transform/Conversion/Mimic override the cur* fields - curStats = mon.stats, - curTypes = def.types, - curMoves = mon.moves, - sprite = (function() - local Sprites = require("src.pokemon.Sprites") - local path, tc = Sprites.path(data, mon.species, - isPlayer and "back" or "front", - { mon = mon, kind = "battle" }) - return getImage(path, monPalette(data, mon.species, - require("src.pokemon.Pokemon").isShiny(mon)), tc) - end)(), - -- Crystal's front-pic frame animation. Only the FRONT pic carries - -- animation tiles, so the player's back pic has none -- and Gold and - -- Silver have none at all, where this stays nil and the pic holds still. - -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own - -- strip, and only the Pokemon can say whether it is one - picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) - and require("src.pokemon.PicAnim").forMon(data, mon,isPlayer and "back" or "front") or nil, - -- Emerald's procedural front-pic animation -- the squash/hop/glow the - -- sprite itself does when it appears. Front pic only, same as above. - monAnim = (not isPlayer) - and require("src.pokemon.MonAnim").new(data, mon.species) or nil, - } - return Gen2Read.bind(battler, battle) + local battler = Battler.new(data, mon, isPlayer, save, battle) + -- The regular native screen hydrates the same art as before. Pure model + -- construction can use the data factory without allocating GPU resources. + battler.sprite = (function() + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(data, mon.species, + isPlayer and "back" or "front", + { mon = mon, kind = "battle" }) + return getImage(path, monPalette(data, mon.species, + require("src.pokemon.Pokemon").isShiny(mon)), tc) + end)() + -- Crystal's front-pic frame animation. Only the FRONT pic carries + -- animation tiles, so the player's back pic has none -- and Gold and + -- Silver have none at all, where this stays nil and the pic holds still. + -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own + -- strip, and only the Pokemon can say whether it is one. + battler.picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) + and require("src.pokemon.PicAnim").forMon(data, mon, isPlayer and "back" or "front") or nil + -- Emerald's procedural front-pic animation -- the squash/hop/glow the + -- sprite itself does when it appears. Front pic only, same as above. + battler.monAnim = (not isPlayer) + and require("src.pokemon.MonAnim").new(data, mon.species) or nil + return battler end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild diff --git a/src/battle/Battler.lua b/src/battle/Battler.lua new file mode 100644 index 000000000..4fc8e58cd --- /dev/null +++ b/src/battle/Battler.lua @@ -0,0 +1,51 @@ +-- Native battler data, extracted from BattleState.makeBattler. +-- Existing engine authors' rights and licences remain in effect (see the upstream licences). +-- Data/presentation boundary contributions by Roxas2712; see +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This module allocates no picture or animation resource. +local Forms = require('src.pokemon.Gen4Forms') +local Gen2 = require('src.battle.Gen2BattleRead') +local Battler = {} + +function Battler.new(data, mon, isPlayer, save, battle) + local def = Forms.definition(data, mon) + local badgeBoosts = data.constants and data.constants.badgeBoosts + local badges + if isPlayer and save then + -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged + -- badgeBoosts rows so a retuned list changes what gets baked in. + badges = {} + for _, row in ipairs(badgeBoosts or require('src.battle.Damage').BADGE_BOOSTS) do + if save.inventory[row.badge] then badges[row.badge] = true end + end + end + local battler = { + mon = mon, + def = def, + name = mon.nickname or def.name, + -- Presentation species may later change for Transform or a Tower ghost; + -- mon.species remains the Pokemon's identity in the save. + species = mon.species, + isPlayer = isPlayer, + badges = badges, + badgeBoosts = badgeBoosts, + statuses = data.statuses, + -- Registry views, not copies: a consumed berry and its nature lookup + -- must see the live merged records throughout the battle. + items = data.items, + natures = data.constants and data.constants.natures, + shownHP = mon.hp, + -- HUDs catch up at native refresh edges, not during a move's mutation. + shownStatus = mon.status, + -- A sleep applied outside battle keeps its own remaining count. + sleepTurns = (mon.status == 'SLP' and mon.sleepTurns) or nil, + stages = {}, + -- Transform/Conversion/Mimic replace these transient views. + curStats = mon.stats, + curTypes = def.types, + curMoves = mon.moves, + } + return Gen2.bind(battler, battle) +end + +return Battler From 281e51852be0b683ae5f52086f6e673f739018b6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:27:05 +0100 Subject: [PATCH 33/34] Exercise plain-data and actual native battler construction --- tests/engine/battler_data.lua | 92 ++++++++++++++++++++ tests/engine/battler_native_construction.lua | 82 +++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/engine/battler_data.lua create mode 100644 tests/engine/battler_native_construction.lua diff --git a/tests/engine/battler_data.lua b/tests/engine/battler_data.lua new file mode 100644 index 000000000..03b7ef5c9 --- /dev/null +++ b/tests/engine/battler_data.lua @@ -0,0 +1,92 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This process deliberately has no LÖVE stub. Model construction must not +-- silently allocate pictures, animations or a battle screen. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +T.eq(_G.love, nil, 'fresh plain-Lua process has no rendering runtime') +for _, name in ipairs({ + 'src.battle.BattleState', 'src.pokemon.Sprites', 'src.pokemon.PicAnim', + 'src.pokemon.MonAnim', 'src.render.Assets', +}) do + package.preload[name] = function() error('data constructor loaded presentation: ' .. name) end +end +local Battler = require('src.battle.Battler') +local Version = require('src.core.GameVersion') +local initial = Version.get() +local function copy(t) + if type(t) ~= 'table' then return t end + local r = {}; for k, v in pairs(t) do r[k] = copy(v) end; return r +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + local label = edition .. ': ' + local types, moves = {'NORMAL'}, {{id='QA_MOVE', pp=8}} + local stats = {hp=40, attack=20, defense=21, speed=22, special=23, spatk=24, spdef=25} + local def = {name='SPECIES', types=types} + local data = {pokemon={QA_MON=def}, statuses={PSN={}}, items={QA_BERRY={}}, + constants={gen=Version.generation(), natures={}, badgeBoosts={ + {badge='QA_BADGE', stat='speed', num=2, den=1}, + {badge='QA_MISSING', stat='attack', num=2, den=1}, + }}} + local mon = {species='QA_MON', nickname='NICK', hp=17, status='PSN', toxic=true, + stats=stats, moves=moves, item='QA_BERRY'} + local save = {inventory={QA_BADGE=1, BOULDERBADGE=1}, party={mon}} + local beforeMon, beforeSave, beforeData = copy(mon), copy(save), copy(data) + local player = Battler.new(data, mon, true, save) + local opponent = Battler.new(data, mon, false, save) + local link = Battler.new(data, mon, true, nil) + T.eq(player.mon, mon, label .. 'original party member is retained') + T.eq(player.def, def, label .. 'species registry row is retained') + T.eq(player.name, 'NICK', label .. 'nickname is displayed') + T.eq(player.curStats, stats, label .. 'stats are a live native view') + T.eq(player.curTypes, types, label .. 'types are a live registry view') + T.eq(player.curMoves, moves, label .. 'PP-bearing move slots retain identity') + T.eq(player.items, data.items, label .. 'item registry retains identity') + T.eq(player.statuses, data.statuses, label .. 'status registry retains identity') + T.eq(player.natures, data.constants.natures, label .. 'nature registry retains identity') + T.eq(player.badgeBoosts, data.constants.badgeBoosts, label .. 'merged badge rows retain identity') + T.same(player.badges, {QA_BADGE=true}, label .. 'only merged possessed badges are captured') + T.eq(opponent.badges, nil, label .. 'opponent does not inherit player badges') + T.eq(link.badges, nil, label .. 'link construction without save has no badge boosts') + T.neq(player.stages, opponent.stages, label .. 'each battler owns transient stages') + T.eq(player.shownHP, 17, label .. 'HUD starts at actual HP') + T.eq(player.shownStatus, 'PSN', label .. 'HUD starts at actual condition') + T.eq(player.toxicCounter, nil, label .. 'older host retains native poison defaults') + for _, battler in ipairs({player, opponent, link}) do + for _, key in ipairs({'sprite', 'picAnim', 'monAnim'}) do + T.eq(battler[key], nil, label .. 'data construction has no ' .. key) + end + end + T.same(mon, beforeMon, label .. 'construction cannot change a Pokemon') + T.same(save, beforeSave, label .. 'construction cannot change a save') + T.same(data, beforeData, label .. 'construction cannot change registries') + mon.hp, mon.status = 3, 'SLP'; mon.sleepTurns = 4; mon.nickname = nil + T.eq(player.shownHP, 17, label .. 'native HUD snapshot waits for refresh') + T.eq(player.shownStatus, 'PSN', label .. 'native condition snapshot waits for refresh') + moves[1].pp = 7; T.eq(player.curMoves[1].pp, 7, label .. 'real PP writes remain visible') + local asleep = Battler.new(data, mon, true, save) + T.eq(asleep.name, def.name, label .. 'unnamed Pokemon uses species name') + T.eq(asleep.sleepTurns, 4, label .. 'outside-battle sleep count survives') + T.eq(asleep.toxicCounter, nil, label .. 'sleep does not acquire a toxic counter') + T.neq(asleep.badges, player.badges, label .. 'badge snapshots are per battler') + data.constants.badgeBoosts = nil + local fallback = Battler.new(data, mon, true, save) + T.same(fallback.badges, {BOULDERBADGE=true}, label .. 'unmerged badges use actual native Damage defaults') + T.eq(_G.love, nil, label .. 'construction never installs a fake rendering runtime') +end + +Version.set('platinum') +local base = {name='DEOXYS', types={'PSYCHIC'}, baseStats={attack=1}} +local attack = {types={'FIGHTING'}, baseStats={attack=180}} +local data = {constants={gen=4}, pokemon={[386]=base, [496]=attack}} +local mon = {species=386, form=1, hp=1, stats={}, moves={}} +local form = Battler.new(data, mon, false) +T.eq(form.species, 386, 'form construction keeps actual species identity') +T.eq(form.name, 'DEOXYS', 'form construction keeps base display name') +T.eq(form.curTypes, attack.types, 'form construction uses native personal-record types') +T.eq(form.def.baseStats, attack.baseStats, 'form construction uses native personal-record stats') +T.eq(data.pokemon[386], base, 'form view does not replace registry row') +T.same(base.types, {'PSYCHIC'}, 'alternate form does not mutate base types') +Version.set(initial) +T.finish('native battler data without LÖVE or presentation resources') diff --git a/tests/engine/battler_native_construction.lua b/tests/engine/battler_native_construction.lua new file mode 100644 index 000000000..cdc21a7b0 --- /dev/null +++ b/tests/engine/battler_native_construction.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Exercise the actual native constructor and switches, including real sprite +-- selection and animation factories. No replacement art or cloned battle loop. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battler = require('src.battle.Battler') +local Screen = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Sprites = require('src.pokemon.Sprites') +local PicAnim = require('src.pokemon.PicAnim') +local MonAnim = require('src.pokemon.MonAnim') +local initial = Version.get() +local make, path, pic, anim = Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new +local builds, paths, pics, anims +Battler.new = function(...) + local b = make(...) + T.eq(b.sprite, nil, 'shared native factory does not hydrate a sprite') + T.eq(b.picAnim, nil, 'shared native factory does not hydrate a pic animation') + T.eq(b.monAnim, nil, 'shared native factory does not hydrate a procedural animation') + local fields = {}; for key, value in pairs(b) do fields[key] = value end + builds[#builds+1] = {battler=b, fields=fields} + return b +end +Sprites.path = function(data, species, side, opts) + paths[#paths+1] = {species=species, side=side, mon=opts.mon, kind=opts.kind} + return path(data, species, side, opts) +end +PicAnim.forMon = function(data, mon, side) + local value = pic(data, mon, side) + pics[#pics+1] = {mon=mon, side=side, value=value} + return value +end +MonAnim.new = function(data, species) + local value = anim(data, species) + anims[#anims+1] = {species=species, value=value} + return value +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + builds, paths, pics, anims = {}, {}, {}, {} + local data = T.fixtures.fresh(); data.constants.gen = Version.generation() + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 25), Pokemon.new(data, 'FIXMON_B', 20)} + local game = {data=data, save=save, stack={top=function() end}} + local battle = Screen.newWild(game, 'FIXMON_C', 15) + T.eq(#builds, 2, edition .. ' real wild constructor uses shared data factory for both sides') + T.eq(builds[1].battler, battle.player, 'player uses the actual constructed table') + T.eq(builds[2].battler, battle.enemy, 'enemy uses the actual constructed table') + T.eq(battle.player.mon, save.party[1], 'native player retains save identity') + T.eq(#paths, 2, 'one actual battle picture selection per side') + T.eq(paths[1].side, 'back', 'player keeps native back picture') + T.eq(paths[2].side, 'front', 'enemy keeps native front picture') + T.eq(paths[1].mon, battle.player.mon, 'player picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[2].mon, battle.enemy.mon, 'enemy picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[1].kind, 'battle', 'actual sprite selector retains battle context') + T.eq(#pics, 1, 'ordinary player back picture does not acquire a front animation') + T.eq(pics[1].mon, battle.enemy.mon, 'native picture animation owns actual opponent') + T.eq(pics[1].value, battle.enemy.picAnim, 'native picture animation result is retained') + T.eq(#anims, 1, 'only the opponent gets native procedural animation construction') + T.eq(anims[1].value, battle.enemy.monAnim, 'native procedural animation result is retained') + for _, row in ipairs(builds) do + for key, value in pairs(row.fields) do + T.eq(row.battler[key], value, edition .. ' hydration preserves native field ' .. key) + end + end + local incoming = save.party[2] + battle:switchPlayerInto(Screen.POS.PLAYER_LEFT, incoming) + T.eq(#builds, 3, 'actual switch constructs a fresh transient battler') + T.eq(battle.player, builds[3].battler, 'actual switch uses shared factory result') + T.eq(battle.player.mon, incoming, 'actual switch retains live bench Pokemon') + T.eq(paths[3].mon, incoming, 'switched sprite selector receives live bench Pokemon') + T.eq(paths[3].side, 'back', 'switch retains native player picture direction') + T.eq(#pics, 1, 'ordinary back-picture switch does not allocate front animation') + T.eq(#anims, 1, 'player switch does not allocate opponent procedural animation') +end +Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new = make, path, pic, anim +Version.set(initial) +T.finish('shared native battler construction and actual art hydration across editions') From 67a05ea1910b136dfc8a91a8862ee495550f041c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:39:46 +0100 Subject: [PATCH 34/34] Document offered-host battler construction proof and scope --- docs/native-battler-construction.md | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/native-battler-construction.md diff --git a/docs/native-battler-construction.md b/docs/native-battler-construction.md new file mode 100644 index 000000000..cedfb5684 --- /dev/null +++ b/docs/native-battler-construction.md @@ -0,0 +1,34 @@ +# Native battler construction without presentation resources + +The shared native `BattleState.makeBattler` previously built combat data, +selected pictures and created animation resources in one function. Extract +its data construction into `src/battle/Battler.lua`, and have the existing +screen constructor hydrate that same table with its original art factories. +There is no second turn simulator, replacement Pokemon or replacement art. + +This offered host retains its existing Gen2 calculation binding and poison +initialization. It does not import the fork-only `HeldItemRead` dependency +or silently introduce the fork's later toxic-counter behavior. Party/move/ +stat/registry identities, badge defaults, outside-battle sleep and form +personal records retain the existing native construction semantics. + +On implementation/test commit `281e5185`, fresh plain-Lua construction passes +577 checks without a LÖVE stub, with presentation-module loads rejected. +Actual wild construction and switches pass 810 checks across this host's +15 advertised editions using the real sprite and animation factories. +These are selected ROM-free checks, not complete current-host, GPU or +release qualification. The previous round-clock offer has separate scoped +RNG/link/model evidence; it does not qualify this new combined source. + +This is a prerequisite for sharing native battlers with standalone model +construction. It does not implement the complete source `Battle.new`, pure +turn/event executor, arbitrary writable model fields, full unchanged-mod +visual parity or final release acceptance. + +The data code is extracted from the existing engine; original authors' +rights and applicable licences remain effective. Extraction/integration +and tests by Roxas2712 are offered under +, +including its specific permission for Cedric / UNDERdecoded with credit. +That contribution notice does not replace earlier permissions or assign +ownership of the underlying engine.