Skip to content
38 changes: 38 additions & 0 deletions docs/native-gen2-model-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions docs/native-gen2-move-priority.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 44 additions & 33 deletions src/battle/BattleState.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -8786,7 +8793,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)
Expand Down Expand Up @@ -8828,7 +8835,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
Expand Down Expand Up @@ -9125,7 +9132,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()
Expand Down Expand Up @@ -9163,7 +9170,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", {
Expand Down Expand Up @@ -9536,7 +9543,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", {
Expand Down Expand Up @@ -9764,7 +9771,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
Expand Down Expand Up @@ -10800,6 +10807,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
Expand Down Expand Up @@ -10860,8 +10868,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
Expand Down Expand Up @@ -12963,4 +12973,5 @@ function BattleState:drawClassic()
end
end

Gen2BattleView.install(BattleState)
return BattleState
Loading