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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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