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-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-volatile-state.md b/docs/native-gen2-volatile-state.md new file mode 100644 index 000000000..33fd651b3 --- /dev/null +++ b/docs/native-gen2-volatile-state.md @@ -0,0 +1,34 @@ +# Native GSC volatile state + +The live Gen2 battle model exposes `volatile(mon)` as a view of its existing +native battler, rather than a disconnected metadata table. Canonical source +names `confuseCount`, `flinched`, `endure`, `recharge`, `protectCount`, +`focusEnergy` and `xAccuracy` address the native confusion counter, admission, +damage, action-lock, protection, critical and hit-check owners. Substitute, +protect and mist aliases are also supported. Unknown keys remain transient +consumer metadata, not newly implemented mechanics. + +Views are cached on the transient model only. Module-global weak-key caches can +retain retired battlers on LuaJIT when value closures reference the key. The +same active native owner has a stable view; withdrawn entries retire on query +and incoming wrappers get new views. No view or metadata is stored in a party +mon or serialized battler. + +GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, +including renamed moves; an old PROTECT ID with another effect resets it. +Protection RNG, opponent-first and Substitute rules remain separate open +mechanics work. No second battle simulator is introduced. + +On this older host, 423 real native state/mechanics/ownership/collection checks, +262 model checks and 83 move-priority checks pass independently. The fixture +loads an actual API2 source consumer and covers Gold/Silver/Crystal, Prism and +Polished Crystal. The fork's additional semantic-checkpoint backend is absent +here, so its checkpoint result is not claimed for this offered branch. +These ROM-free CPU checks do not establish complete source-model, GPU, +unchanged-mod or release acceptance. Depends on the native model boundary and +move-priority contributions offered separately. + +Source-field names were inspected against Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Native integration and tests are +contributions by Roxas2712, offered to Cedric / UNDERdecoded under the fork's +specific PR contribution permission, with existing third-party rights intact. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..af74797ef 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 @@ -7785,7 +7792,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 +8127,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 +8304,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- FURY CUTTER and ROLLOUT double for each consecutive LANDING and reset -- the same way. Both counters live on the user and are cleared here, -- which is the one place every move passes through. - if move.id ~= "PROTECT" and move.id ~= "DETECT" and move.id ~= "ENDURE" then - user.protectRun = nil + local keepsProtection = move.id == "PROTECT" or move.id == "DETECT" or move.id == "ENDURE" + if GameVersion.isGen2() then + -- The GSC counter follows the effect, including renamed source moves. + keepsProtection = move.effect == "PROTECT_EFFECT" or move.effect == "ENDURE_EFFECT" end + if not keepsProtection then user.protectRun = nil end if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end @@ -8786,7 +8796,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 +8838,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 +9135,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 +9173,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 +9546,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 +9774,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 +10810,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 +10871,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 +12976,5 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c287f54a9..60cd86214 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -6,6 +6,7 @@ -- Battlers built by makeBattler also carry the merged badgeBoosts rows and -- statuses records; hand-built battlers fall back to the vanilla tables. +local Gen2Read = require('src.battle.Gen2BattleRead') local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") @@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) local function shl(x) return math.min(255, x * 2) end local speed if ruleset.critUsesBaseSpeed == false then - speed = Stats.applyStage(attacker.curStats.speed, + speed = Stats.applyStage(Gen2Read.stats(attacker).speed, attacker.stages and attacker.stages.speed or 0) else speed = attacker.def.baseStats.speed @@ -231,6 +232,8 @@ end -- self-hit reads the opponent's screens). -- Returns damage, {crit=bool, typeMult=x10}. function Damage.compute(ruleset, attacker, defender, move, opts) + local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender) + local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender) opts = opts or {} local rng = opts.rng or love.math.random -- ...and the same field again, because a STATUS move must not roll damage. @@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. - local split = special and attacker.curStats.spatk and defender.curStats.spdef + local split = special and attackStats.spatk and defendStats.spdef local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" local atk, dfn if crit and ruleset.critIgnoresStages then - atk = attacker.curStats[atkStat] - dfn = defender.curStats[defStat] + atk = attackStats[atkStat] + dfn = defendStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], + atk = Stats.applyStage(attackStats[atkStat], stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], + dfn = Stats.applyStage(defendStats[defStat], stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), @@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then -- STAB local stab = false - for _, t in ipairs(attacker.curTypes) do + for _, t in ipairs(attackTypes) do if t == move.type then stab = true break end end if stab then @@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- type effectiveness: each TypeEffects row is applied to the -- running damage separately with its own floor (0.5*0.5 lands on -- floor(floor(d/2)/2), not d*0.25) - mult = TypeChart.effectiveness(move.type, defender.curTypes) + mult = TypeChart.effectiveness(move.type, defendTypes) -- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being -- immune to NORMAL and FIGHTING. It does not become weak to them -- -- the immunity simply becomes neutral -- so this reads the chart's @@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if mult == 0 and bwLift then foresight = true mult = 10 - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do if m ~= 0 then mult = math.floor(mult * m / 10) end end end @@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } end - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies if not (foresight and m == 0) then d = math.floor(d * m / 10) end diff --git a/src/battle/Gen2BattleRead.lua b/src/battle/Gen2BattleRead.lua new file mode 100644 index 000000000..f4e044834 --- /dev/null +++ b/src/battle/Gen2BattleRead.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Pure reads at native calculation sites. Wrappers, PP, volatile state and +-- checkpoint tables retain their identity and original serializable fields. +local Model = require('src.battle.gen2.Battle') +local Read = {} +local owners = setmetatable({}, { __mode = 'kv' }) +local nativeStat, nativeDef = Model.battleStat, Model.speciesDef + +function Read.bind(battler, screen) + if battler and screen and screen.battle and screen.battle ~= screen then + owners[battler] = screen + end + return battler +end + +local function modelOf(battler) + local screen = owners[battler] + local model = screen and screen.battle + -- Non-Gen2 screens use themselves as their model and keep direct reads. + return model ~= screen and model or nil +end + +function Read.stats(battler) + local stats = battler and battler.curStats + if Model.battleStat == nativeStat then return stats end + local model = battler and modelOf(battler) + if not model then return stats end + local values = {} + for key in pairs(stats or {}) do + values[key] = model:battleStat(battler.mon, key) + end + return values +end + +function Read.types(battler) + local types = battler and battler.curTypes + if Model.speciesDef == nativeDef then return types end + local model = battler and modelOf(battler) + if not model then return types end + -- Conversion/Transform own an explicit native type list. A species reader + -- must not erase a move's live volatile override, or mutate its source row. + if not battler.def or types ~= battler.def.types then return types end + local def = model:speciesDef(battler.mon) + return def and def.types or types +end + +return Read diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..06605b8e1 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Separate live Gen2 model over the existing shared native controller. +local View = {} +local Model = require('src.battle.gen2.Battle') +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local readers = { + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true 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/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..65b8946ab --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- The live Gen2 model boundary of the shared native controller. This is not +-- an alias to the screen and does not construct or advance a second battle. +local Runtime = require('src.mods.Runtime') +local Model = {} +local screens = setmetatable({}, { __mode = 'kv' }) +local ended = setmetatable({}, { __mode = 'k' }) + +function Model.bind(model, screen) + screens[model] = screen + return model +end + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +local function active(screen, mon) + if screen.player == 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/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_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_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')