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/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md new file mode 100644 index 000000000..1de093552 --- /dev/null +++ b/docs/native-gen2-model-rng.md @@ -0,0 +1,31 @@ +# Native Gen2 model RNG + +Expose `rng`, `random` and `roller()` on the live source model, backed by the +existing native controller's current function. LOVE-style ranges normalize +missing/small bounds and reversed ranges; the source random/roller domain is +zero-based for positive integer bounds. Queries consume no randomness and +retained adapters follow native function replacement, including link setup. + +The source random field and decorated roller method stay distinct. The native +read-only reader policy remains in effect. Model-owned function caches do not +enter party mons or serialized battlers, and no second generator is created. +Standalone constructor/RNG-injection and complete source-model parity remain +separate work. + +Reference: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Battle.new`, its LOVE-style RNG +view and `Battle:roller`. This older host's native model, volatile-state and +protection prerequisites are offered separately. The fork-only semantic +checkpoint backend is absent here, so its checkpoint result is not inherited. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR contribution permission, with existing rights intact. +Independent validation on this offered host passes 7951 native RNG checks, +174 actual host/guest/spectator source-callback checks, model (262), volatile +(423) and protection-link (66) regressions. A separately executed frozen Gen1 +comparison passes 9471 comparisons / 7020 draw cases over actual seeded native +LinkBattle backends; reference hashes remain unchanged. The exact official +whole-host command passes 71/82 engine suites and SDK3/3, with the same eleven +pre-existing failures as its protection base (69/80). It adds two passing suites +without adding a whole-host failure; this older prerequisite stack is not +entirely green. Full model/mod/GPU/release acceptance remains separate. 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. 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 new file mode 100644 index 000000000..32fdaf375 --- /dev/null +++ b/docs/native-gen2-volatile-state.md @@ -0,0 +1,35 @@ +# 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 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 +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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..d3a3c3a6d 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 @@ -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? @@ -3616,7 +3622,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 +5911,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 +6026,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 +6239,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 @@ -7716,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 @@ -7785,7 +7793,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 +8128,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 @@ -8297,9 +8305,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 @@ -8786,7 +8797,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 +8839,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 +9136,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 +9174,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 +9547,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 +9775,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 +10811,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 +10872,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', {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 -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12963,4 +12977,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/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/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..9a0e8b49c --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,63 @@ +-- 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, + rng = function() return rawget(screen.battle, '_nativeLoveRng') end, + random = function() return rawget(screen.battle, '_nativeRandom') end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + 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, + 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/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/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/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..1cd1a6cf2 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 @@ -65,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/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..e33aadf0c --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,125 @@ +-- 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' }) + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +function Model.bind(model, screen) + screens[model] = screen + rawset(model, '_nativeRandom', function(n) return owner(model).rng(n) - 1 end) + -- Source GSC's LOVE-style view uses the current native function even if + -- link setup or a scoped native owner replaces it after this bind. + rawset(model, '_nativeLoveRng', function(lo, hi) + local rng = owner(model).rng + if hi == nil then return rng(math.max(1, lo or 1)) end + if hi < lo then lo, hi = hi, lo end + return rng(lo, hi) + end) + return model +end + +function Model:roller() + owner(self) + local roller = rawget(self, '_nativeRoller') + if not roller then + -- Zero-based BattleRandom over the same existing stream. Keep the cache + -- on the transient model; a global value closure could root a weak key. + local random = rawget(self, '_nativeRandom') + roller = function(n) return random(n) end + rawset(self, '_nativeRoller', roller) + end + return roller +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. + if type(move) ~= 'table' then return self.data.moves[move] end + 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 + +-- 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. +function Model:endBattle(outcome, details) + local screen = owner(self) + -- 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 + if not (details and details.nativeNotification) then screen.result = outcome end + 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/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..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 = {} @@ -158,6 +159,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 +176,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 +185,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 +268,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_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..b16f8d48c --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,193 @@ +-- 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(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 + 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. + -- 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 + 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'})[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 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') + 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') diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua new file mode 100644 index 000000000..be92999ce --- /dev/null +++ b/tests/engine/gen2_model_rng.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Actual source consumers must share the native stream, including after its +-- function is replaced. No shadow generator, draws on lookup or saved closures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/rng_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/rng_probe/manifest.json']='{"id":"rng_probe","name":"Native RNG probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/rng_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end + mod.exports.cart=function(model,n)return model.random(n)end + mod.exports.method=function(model,n)return Battle.roller(model)(n)end + mod.exports.decorate=function() + local native=Battle.roller + Battle.roller=function(model) + mod.exports.lookups=(mod.exports.lookups or 0)+1 + return native(model) + end + end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source RNG consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',25)} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + local probe=run.loader.exports.rng_probe + local trace={} + local function stream(tag) + return function(lo,hi) + local first,last=lo,hi + if hi==nil then lo,hi=1,lo end + trace[#trace+1]={tag=tag,lo=first,hi=last} + return lo+(#trace*37)%(hi-lo+1) + end + end + local native=stream('initial');b.rng=native + local model=b.battle + T.eq(type(model.rng),'function',edition..' source LOVE RNG exists') + T.eq(type(model.random),'function','source zero-based random exists') + T.eq(type(model.roller),'function','source roller method exists') + if type(model.rng)=='function'and type(model.random)=='function'and type(model.roller)=='function'then + local loveRng,random,roller=model.rng,model.random,model:roller() + T.eq(model.rng,loveRng,'LOVE adapter identity is stable') + T.eq(model.random,random,'zero-based adapter identity is stable') + T.eq(model:roller(),roller,'roller identity is stable') + T.check(random~=roller,'source random field remains separate from the roller method wrapper') + T.eq(#trace,0,'lookup and cache creation consume no native draws') + for n=1,256 do + local index=#trace+1 + T.eq(probe.love(model,n),1+(index*37)%n,'source LOVE result uses the next actual native draw') + T.eq(#trace,index,'one actual draw for LOVE call') + index=#trace+1 + T.eq(probe.cart(model,n),(index*37)%n,'source random uses zero-based native result') + T.eq(#trace,index,'one actual draw for source random') + index=#trace+1 + T.eq(probe.method(model,n),(index*37)%n,'public class roller uses the next same native draw') + T.eq(#trace,index,'one actual draw for source roller') + end + for _,bounds in ipairs{{5,9},{9,5},{-3,2},{4,4}}do + local lo,hi=math.min(bounds[1],bounds[2]),math.max(bounds[1],bounds[2]) + local index=#trace+1 + T.eq(probe.love(model,bounds[1],bounds[2]),lo+(index*37)%(hi-lo+1),'source range normalization uses native draw') + T.eq(trace[index].lo,lo,'normalized native lower bound') + T.eq(trace[index].hi,hi,'normalized native upper bound') + end + for _,bound in ipairs{0,-7}do + local index=#trace+1 + T.eq(probe.love(model,bound),1,'source small bound normalizes to one') + T.eq(#trace,index,'normalized one-point range still consumes one actual draw') + end + local index=#trace+1 + T.eq(probe.love(model),1,'source missing bound normalizes to one') + T.eq(#trace,index,'missing bound uses one actual native draw') + local replacement=stream('replacement');b.rng=replacement + index=#trace+1 + T.eq(loveRng(2,8),2+(index*37)%7,'retained LOVE view follows replacement owner') + T.eq(trace[index].tag,'replacement','retained LOVE view cannot draw from old function') + index=#trace+1 + T.eq(random(256),(index*37)%256,'retained random follows replacement owner') + T.eq(trace[index].tag,'replacement','retained zero-based view cannot draw from old function') + index=#trace+1 + T.eq(roller(255),(index*37)%255,'retained roller follows replacement owner') + T.eq(trace[index].tag,'replacement','retained roller cannot draw from old function') + T.eq(b.rng,replacement,'adapters never replace the actual native function') + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,save.party[2]) + T.eq(b.battle,model,'real native switch retains its model') + T.eq(model.rng,loveRng,'real native switch retains LOVE adapter') + T.eq(model:roller(),roller,'real native switch retains source roller') + index=#trace+1 + T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') + T.eq(#trace,index,'incoming actor does not create another generator') + probe.decorate() + local count=#trace + T.eq(model.random,random,'decorating roller does not replace the source random field') + T.eq(model.rng,loveRng,'decorating roller does not replace source LOVE field') + T.eq(probe.lookups,nil,'source field lookup does not invoke a decorated roller method') + index=#trace+1 + T.eq(probe.method(model,255),(index*37)%255,'actual API2 roller decoration keeps native draw ownership') + T.eq(probe.lookups,1,'actual API2 consumer reaches the live class decoration') + T.eq(#trace,count+1,'decoration lookup itself adds no draw') + T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') + T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') + T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') + end + for _,mon in ipairs(save.party)do + T.eq(mon.rng,nil,'no RNG function enters party mon') + T.eq(mon.random,nil,'no random function enters party mon') + T.eq(mon._nativeRoller,nil,'no adapter cache enters party mon') + end + run.release() +end +-- Internal adapter cycles must be collectible when their native owner and +-- caller references end. A global value closure retaining a weak key is not. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local screen={rng=function(n)return n end};local model=Model.bind({},screen) + Model.roller(model);weak[model]=true;weak[screen]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'retired model/RNG adapters do not leak through module state') +V.set(before) +T.finish('native source model RNG fields, domains, replacement, switch and lifetime') diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua new file mode 100644 index 000000000..d3104599c --- /dev/null +++ b/tests/engine/gen2_model_rng_link.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source callbacks draw from the actual seeded link stream in every role. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/source_rng'},{data=data,fs=T.sdk.memfs({ + ['mods/source_rng/manifest.json']='{"id":"source_rng","name":"Source RNG","version":"1.0.0","api":2,"entry":"main.lua","affects_link":true}', + ['mods/source_rng/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.rows={} + mod.events:on('battle.move_used',function(c) + local model=c.battle.battle + local rows=mod.exports.rows[c.battle] + if not rows then rows={};mod.exports.rows[c.battle]=rows end + rows[#rows+1]={name=c.user.mon.nickname,love=model.rng(16), + cart=model.random(256),roller=Model.roller(model)(255)} + end) + ]], + })}) + T.eq(#run.errors,0,'actual source callback consumer loads');T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].nickname=i==1 and'HOST'or'GUEST' + game.save.party[1].moves={{id='QA_ENDURE',pp=20}};games[i]=game + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,30000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[2])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[2]and b.localHashes[2]and s.turnCount==2 and s.phase=='waitBoth'then break end + end + for turn=1,2 do + T.check(a.localHashes[turn]~=nil and b.localHashes[turn]~=nil,'actual source-consumer link turn completes') + T.eq(a.localHashes[turn],b.localHashes[turn],'source RNG calls preserve native lockstep') + T.same(a.localParts[turn],b.localParts[turn],'every completed native state component agrees') + end + local rows=run.loader.exports.source_rng.rows + T.same(rows[a],rows[b],'both participants observe the same source RNG results') + T.same(rows[a],rows[s],'spectator observes the same source RNG results') + for _,battle in ipairs(roles)do + T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') + for _,row in ipairs(rows[battle]or{})do + T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') + T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') + T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') + end + T.eq(battle.player.mon.moves[1].pp,18,'own native moves spend PP once despite source random calls') + T.eq(battle.enemy.mon.moves[1].pp,18,'other native moves spend PP once despite source random calls') + T.eq(battle.result,nil,'no hidden link simulation error') + end + T.eq(#run.errors,0,'no source callback failure is swallowed') + run.release() +end +V.set(old) +T.finish('actual source RNG callbacks in native GSC link participant and spectator roles') 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') diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua new file mode 100644 index 000000000..af5440e26 --- /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.nextInsert=0;first.fn() + T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') + local afterFirst=draws + 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.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.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') + 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') 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') 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')