diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, third-party engine work, +other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/docs/native-battle-animation-runtime.md b/docs/native-battle-animation-runtime.md new file mode 100644 index 000000000..5baca515a --- /dev/null +++ b/docs/native-battle-animation-runtime.md @@ -0,0 +1,72 @@ +# Native battle animation bridge + +`src.core.game3.battle.anim` presents the admitted GBA battle through its native +`BattleState`, `Gen3MoveAnim` and `Gen3BallAnim` owners. It does not load another +battle engine or rewrite a mod package. FireRed, LeafGreen, Emerald, Ruby and +Sapphire use the same boundary and their own imported identifiers and records. + +## Shared clock and ownership + +The native update opens one presentation phase. A source `Anim.update()` can +advance that phase while a custom ending or pose holds the combat update. +Repeated source calls and a later native base call consume the same display +steps. The native dt drives sprite animations. Outside that phase, source +updates cannot manufacture frames. + +Move/ball effects, visual waits, HP drain and EXP fill remain native operations. +Only the native combat update consumes command rows, input and outcomes. +Source-launched clips occupy the native animation player and hold the command +queue until that clip finishes. A presentation serial distinguishes a finished +source clip from a newer native animation on the same player. Reset/unload +cancels only a clip still owned by that source admission. + +`present(id)` has stable identity for an admitted battler. It observes native +display HP/maximum/level/status/EXP, visibility, offsets and affine effects. +Source visual overrides compose around the existing native sprite draw; +reading a native effect never applies it a second time. `shownBattler` carries +an owned presentation override without replacing the native combatant. +Replacing the session, save, data or catalogue retires old poses and callbacks. + +`GameVersion.layout(id)` also reports the registered GBA family (`frlg` or +`rse`) so an unchanged field companion probe can recognize the native host. +It supports current-version and colon queries without changing cache/save +selection; an explicit registered layout takes precedence. + +## Geometry and screen effects + +`anim_coords` exposes authored anchors and ordering for singles and doubles. +Current imported cartridge coordinates take precedence over the standard +240x160 anchors. Temporary side bindings restore on success and failure and +preserve trailing nil arguments/results. + +Supported screen effects are inversion, fade to white/black, darkening, +grayscale and blending to a custom colour. Repeated begin calls reapply within +one guarded render seam; nested seams restore their caller's shader. The native +graphics guard restores render targets, transforms, shaders and leaked pushes +after an error. Synthetic asymmetric sprites test orientation as well as colour. + +## Current boundary + +This bridge implements native move launch/observation, source pose tweens, +display ratios, sequence observations, screen effects and coordinate bindings. +It deliberately does not advertise a complete presentation API version. +Authored animation commands, mutable VM/sprite/task/palette callbacks, +particle bands, trainer/intro/switch/catch stage control, source HP/EXP tween +commands and complete custom UI captures still require integration. A module +being importable is not evidence of a complete mod boot or playable campaign. + +## Validation + +- `tests/engine/native_battle_animation.lua`: all five editions; shared clocks, + owned clip completion, held command queues, stale poses/callbacks and 180-frame + native trajectory comparisons per edition with and without the bridge. +- `tools/battle-animation-runtime/lifecycle-check.lua`: SDK entry failure, + teardown and native peer callback identities. +- `tools/battle-animation-runtime/coords-check.lua`: optional local contract + oracle supplied through `BATTLE_COORDS_ORACLE`; no external engine is bundled. +- `tools/battle-animation-runtime/host`: actual Metal/OpenGL sprite and screen + effect rendering, pixel comparisons and graphics error restoration. + +Local qualification runs use the repository's QA storage manager, frozen source +copies and dedicated LOVE identities. They use synthetic imported data and art; +they do not open player profiles, save files or ROM originals. diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md new file mode 100644 index 000000000..5f24b3cc6 --- /dev/null +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -0,0 +1,75 @@ +# Native GBA battle source boundary + +This is an incremental presentation and observation bridge over the existing +`BattleState`. It does not establish complete unchanged-package boot or play. + +## Implemented boundaries + +- The admitted native stack owns battle identity, input, combat, RNG and exit. +- Stable source observations translate cartridge species/move/item/ability/type + identifiers and flattened Pokemon fields. Trainer class and trainer id remain + different fields. HP/status changes and PP transactions reach native Pokemon; + unsupported move replacements fail before an update. +- Native start notifications, update, UI pump, whole-screen drawing and the + background pass have independent scopes. Repeated base calls consume each + native body once. Calls outside an update scope cannot tick combat. +- Every queued native update/draw rechecks the original admission after source + hooks, including the inner UI/background hooks. Save, dataset, loader, stack, + edition and imported catalogue replacement retire the old binding; restoring + an earlier pointer cannot revive that binding. Native method bodies execute + outside a source callback owner. +- Explicit battle/UI captures suppress the repeated native screen draw within + the same renderer frame and render target. A separate preview target cannot + suppress the main screen. Capturing never advances combat. +- LuaJIT `pairs`/`next` expose typed battle fields, preserving nested typed + identity. Iterator registry values are weak, so an unused observation cannot + retain its own weak key through a closure. +- Covered battles remain observable, but cannot update or pop their cover. + Mod retirement invalidates its source views without cancelling native combat. + Completed views keep detached observations and reject further native writes. +- Basic source wild/trainer requests reuse the native encounter admission and + cleanup provider when present. Authored source parties, extended options, + headless simulation and source-controlled link termination remain unsupported. + Constructor-request acceptance is not yet independently qualified here. +- Pokemon observations use native imported records. Height and weight convert + metres/kilograms to cartridge decimetres/hectograms. Static pictures are owned + by the source service; native animation frames remain borrowed. +- Separate presentation players reuse native `MonAnim`/`PicAnim` timing. Their + timing is the existing native implementation, not a proof of exact source + sprite callbacks. They cannot consume combat commands. + +Older native controllers without HUD visibility or move-grid query methods +return an explicit unavailable reason for those observations. This does not +advertise the complete presentation protocol on that host. + +## Qualification + +The focused engine suites cover all five registered GBA editions using +synthetic imported data and actual native constructors, stack admissions and +exit. They exercise repeated base calls, covered states, UI input/draw scopes, +owner/data/edition retirement, numeric identity, PP and detached final views. +View-only collection checks isolate this bridge from the host's global facade +cache; they do not certify the entire host's Game/save collection policy. + +`tools/battle-source-runtime/host` draws the actual native `Gen3Battle` on a +GPU. It compares default and wrapped pixels, replaces only the field pass, +checks that the native command-area pixels remain identical, and checks +graphics restoration after a failing callback. Artwork and battle records are +synthetic; these are not original-package or cartridge gameplay checks. + +## Remaining presentation work + +The UI module deliberately does not advertise the complete sprite/particle +presentation protocol. Native animation VM, effect, healthbox, trainer, ball, +text, catch, switch and intro presentation interfaces still need their own +bindings. So do complete party/save collection schemas and authored source +Pokemon operations. Original-package initialization, real play sequences, +source effect timing/art parity and physical target acceptance remain open. + +The last unchanged-package loader probe advances beyond battle, Pokemon, +motion and UI imports to the missing `src.core.game3.battle.anim` interface. + +Older native hosts also retire already-loaded battle, Pokemon-picture and +motion services at loader unwind, even when their stream service is not yet +driven by loader retirement. This releases observations and owned pictures; +it does not pop or finish the actual native battle. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..5299618d9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,6 +53,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Native source observations identify this admitted controller directly. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -2593,11 +2595,9 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +-- Only display controllers live here. Command holds, HP awards, turn rows +-- and completion remain in updateQueue, even while a mod holds presentation. +function BattleState:updateMovePresentation() -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2648,11 +2648,16 @@ function BattleState:updateQueue() if not anim.caught then self.gen3Ball = nil end end end +end + +-- Display-only waits and bar completion. Returning idle grants no command; +-- only updateQueue may consume a battle row after this gate. +function BattleState:updateQueuePresentation() + self:updateMovePresentation() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source and source._holdsNativeQueue(self) then return true end -- a queued hold (faint slide, hit blink) counts down before the next row - if self.waitFrames and self.waitFrames > 0 then - self.waitFrames = self.waitFrames - 1 - return true - end + if self:stepPresentationWait() then return true end -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has -- actually stopped sounding, which is how the original gives a sound its -- own clear window instead of letting the next beat play over it @@ -2682,6 +2687,15 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -4270,6 +4284,28 @@ function BattleState:touchpressed(_, px, py) end return true end +function BattleState:updateBattlerPresentation(dt) + for _, b in activeBattlers(self) do + if b and b.picAnim then b.picAnim:update(dt) end + if b and b.monAnim then b.monAnim:update(dt) end + end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + return false +end + +-- Advance an ending/pose hold without executing battle commands or input. +function BattleState:updatePresentation(dt) + self:tickFx() + self:updateBattlerPresentation(dt) + self:updateQueuePresentation() +end + function BattleState:update(dt) self:tickFx() -- one sample a second into the fight, while it is still on screen @@ -4277,10 +4313,7 @@ function BattleState:update(dt) if self._stadiumReportTicks == 60 then self:reportStadiumBattle("mid") end -- Crystal pic animations run off the wall clock like every other battle -- pic effect; a Gold/Silver battler has no picAnim and this is a no-op. - for _, b in activeBattlers(self) do - if b and b.picAnim then b.picAnim:update(dt) end - if b and b.monAnim then b.monAnim:update(dt) end - end + self:updateBattlerPresentation(dt) local input = self.game.input -- L PUTS THE BOTTOM SCREEN AWAY, AND BRINGS IT BACK. @@ -12855,4 +12888,57 @@ function BattleState:drawClassic() end end +-- Source wrappers observe the real GBA owner. Unrequested modules and other +-- generations keep the direct native path; there is no additional clock. +do + local update, draw, field, exit, enter = BattleState.update, BattleState.draw, + BattleState.drawBattleField, BattleState.exit, BattleState.enter + local Clock = require('src.battle.PresentationClock') + for _, name in ipairs{'tickFx','updateBattlerPresentation','updateMovePresentation', + 'stepHPDrain','stepExpBar','stepPresentationWait','updateQueuePresentation'} do + local body = BattleState[name] + BattleState[name] = function(self, ...) + return Clock.once(self, name, body, ...) + end + end + BattleState._drawNative = draw + local battlerDraw = BattleState.drawBattlerPic + function BattleState:drawBattlerPic(battler, ...) + local anim = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if anim then return anim._drawBattler(self, battler, battlerDraw, ...) end + return battlerDraw(self, battler, ...) + end + function BattleState:enter() + enter(self) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeEnter(self) end + end + function BattleState:update(dt) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + local anim = source and package.loaded['src.core.game3.battle.anim'] + if anim then + return Clock.run(self, dt, function() + return source._nativeUpdate(self, dt, update) + end) + end + if source then return source._nativeUpdate(self, dt, update) end + return update(self, dt) + end + function BattleState:draw() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeDraw(self, draw) end + return draw(self) + end + function BattleState:drawBattleField() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeField(self, field) end + return field(self) + end + function BattleState:exit() + local source = package.loaded['src.core.game3.battle'] + if source then source._nativeExit(self) end + return exit(self) + end +end + return BattleState diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..d2f590fb4 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -754,6 +754,7 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo branchCtx, battle) local anim = self:recordFor(moveId, branchCtx) if not hasVisual(anim) then return false end + self.presentationSerial = (self.presentationSerial or 0) + 1 self.events = anim.events or {} self.shakes = anim.shakes self.blends = anim.blends diff --git a/src/battle/PresentationClock.lua b/src/battle/PresentationClock.lua new file mode 100644 index 000000000..00d8d13a1 --- /dev/null +++ b/src/battle/PresentationClock.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation holds borrow a native update; they never drain its command +-- queue. Results (including failures and nils) are shared within that update. +local M={} +local phases=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function M.active(screen)return phases[screen]~=nil end +function M.delta(screen)return phases[screen]and phases[screen].delta end +function M.once(screen,key,body,...) + local phase=phases[screen] + if not phase then return body(screen,...)end + local entry=phase.steps[key] + if not entry then + entry={};phase.steps[key]=entry + entry.result=pack(pcall(body,screen,...)) + end + local r=entry.result + if not r then return nil,'presentation step is already executing'end + if not r[1]then error(r[2],0)end + return unpack(r,2,r.n) +end +function M.run(screen,dt,body,...) + if phases[screen]then return body(...)end + local phase={steps={},delta=dt};phases[screen]=phase + local result=pack(pcall(body,...));phases[screen]=nil + -- A wrapper cannot swallow a failed display step and retry that step. + for _,entry in pairs(phase.steps)do + if entry.result and not entry.result[1]then error(entry.result[2],0)end + end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/compat/gen1/BattleIds.lua b/src/compat/gen1/BattleIds.lua new file mode 100644 index 000000000..cbc8b08c7 --- /dev/null +++ b/src/compat/gen1/BattleIds.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge indices belong to this imported dataset, including sparse species +-- slots. Portable names and National Dex numbers are not substitutes for them. +local M={} +local schema={pokemon={'speciesOrder','pokemon'},moves={'moveOrder','moves'},items={'itemOrder','items'}, + abilities={'abilityOrder','abilities',1},types={'typeOrder','types',1},trainers={'trainerOrder','trainers'}} +function M.new(data) + local indexes={} + local constants=data.constants + local catalogues={} + for kind,spec in pairs(schema)do + catalogues[kind]={order=constants and constants[spec[1]],rows=data[spec[2]]or constants and constants[spec[2]]} + end + local function valid() + if data.constants~=constants then return false end + for kind,c in pairs(catalogues)do + local spec=schema[kind] + if (constants and constants[spec[1]])~=c.order or(data[spec[2]]or constants and constants[spec[2]])~=c.rows then return false end + end + return true + end + local function index(kind) + if indexes[kind]then return indexes[kind]end + local spec=assert(schema[kind],'unknown battle identifier kind') + local constants=data.constants or{};local rows=data[spec[2]]or constants[spec[2]]or{} + local order=constants[spec[1]]or{};local forward,reverse={},{} + local function add(number,key) + if type(number)~='number'or number<0 or number%1~=0 then return end + if forward[number]~=nil and forward[number]~=key then forward[number]=false else forward[number]=key end + if reverse[key]~=nil and reverse[key]~=number then reverse[key]=false else reverse[key]=number end + end + for number,key in pairs(order)do if type(number)=='number'and rows[key]~=nil then add(number-(spec[3]or 0),key)end end + for key,row in pairs(rows)do + if type(row)=='table'and type(row.index)=='number'and order[row.index+(spec[3]or 0)]==nil then add(row.index,key)end + end + local result={forward=forward,reverse=reverse,rows=rows};indexes[kind]=result;return result + end + return { + valid=valid, + read=function(_,kind,key) + if not valid()then return nil end + if key==nil or key==false then return(kind=='items'or kind=='abilities')and 0 or nil end + local i=index(kind);local n=i.reverse[key] + if n~=nil then return n or nil end + if type(key)=='number'and i.rows[key]then return key end + end, + native=function(_,kind,value) + if not valid()then return nil end + if value==nil or value==0 and(kind=='items'or kind=='abilities')then return nil end + local i=index(kind) + if type(value)=='number'then return i.forward[value]or nil end + if i.rows[value]then return value end + end, + } +end +return M diff --git a/src/compat/gen1/BattleRequests.lua b/src/compat/gen1/BattleRequests.lua new file mode 100644 index 000000000..9d9e24837 --- /dev/null +++ b/src/compat/gen1/BattleRequests.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Queued native encounters reuse the host's admission, trainer flags, +-- afterBattle cleanup and callback cancellation. Unsupported source-owned +-- party simulations are rejected before constructing or rolling a Pokemon. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Version=require('src.core.GameVersion') +local R={} +local fields={wild=true,foe=true,trainerId=true,onDone=true} +function R.start(opts) + if type(opts)~='table'then return false,'invalid battle options'end + for key in pairs(opts)do if not fields[key]then return false,'native battle option unavailable: '..tostring(key)end end + if opts.onDone~=nil and type(opts.onDone)~='function'then return false,'invalid battle continuation'end + local game,why=Native.resolve();if not game then return false,why end + local Host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local owner=Host.currentMod or facade._callbackOwner() + local loader=game.mods;local mod=loader and loader.mods and loader.mods[owner] + if not(mod and loader.hooks and type(loader.hooks.wrap)=='function')then return false,'battle request has no live mod owner'end + local save,data,edition=game.save,game.data,Version.get() + local function licensed() + return game.mods==loader and game.save==save and game.data==data and Version.get()==edition + and loader.mods[owner]==mod and mod.enabled~=false and not mod.failed + end + local function current()return Native.game()end + local function world(g)return{overworld=function()return Native.world(g)end}end + local available,admission=pcall(require,'src.mods.Api3WildBattle') + if not available then return false,'native encounter admission provider unavailable: '..tostring(admission)end + local start=admission.new + if opts.wild==true then + local foe=opts.foe + if type(foe)~='table'then return false,'wild battle requires a species and level'end + for key in pairs(foe)do if key~='species'and key~='level'then return false,'authored wild Pokemon field unavailable: '..tostring(key)end end + local species=Ids.new(data):native('pokemon',foe.species) + if not species then return false,'unknown cartridge species'end + if type(foe.level)~='number'or foe.level%1~=0 or foe.level<1 or foe.level>100 then return false,'invalid wild level'end + -- Resolve has already validated the cartridge key; the native plan avoids + -- interpreting it again as a portable species name. + local plan={resolve=function()return species end,construct=function(g,key) + return require('src.battle.BattleState').newWild(g,key,foe.level) + end} + return start(loader,owner,current,world,licensed,require,nil,plan)(species,foe.level,opts.onDone) + end + if opts.foe~=nil then return false,'trainer requests require the imported trainer id'end + if opts.trainerId==nil then return false,'native trainer id required'end + return start(loader,owner,current,world,licensed,require,nil, + require('src.mods.ScriptBattles').nativeTrainer)(opts.trainerId,1,opts.onDone) +end +return R diff --git a/src/compat/gen1/BattleRuntime.lua b/src/compat/gen1/BattleRuntime.lua new file mode 100644 index 000000000..4ef2fc296 --- /dev/null +++ b/src/compat/gen1/BattleRuntime.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe real native stack admissions. Completed source views keep no save, +-- controller, imported dataset or borrowed GPU resource alive. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Views=require('src.compat.gen1.BattleView') +local Ids=require('src.compat.gen1.BattleIds') +local R={};R.__index=R +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function cohort(c) + return c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader + and c.game.stack==c.stack and Version.get()==c.edition and c.ids:valid() +end +local function member(c) + if c.retired or not cohort(c)then return false end + for _,state in ipairs(c.stack.states or{})do if state==c.screen then return true end end + return false +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end +function R:retire(c,completed) + if not c or c.retired then return end + c.views:retire();c.retired=true + self.records[c.screen]=nil + if self.current==c then self.current=nil end + if completed then + self.last=c.views.view + self.lastSession=setmetatable({c.game,c.data,c.save,c.stack,c.loader},{__mode='v'}) + self.lastEdition=c.edition + end + c.game,c.screen,c.data,c.save,c.stack,c.loader,c.ids=nil,nil,nil,nil,nil,nil,nil +end +function R:bind(screen) + local game=screen and Native.game(screen.game) + if not(game and game==Native.game()and screen.isBattleState and screen.data==game.data and game.stack and game.save)then return end + local c=self.records[screen] + if c and member(c)then local who=owner();if who then c.users[who]=true end;return c end + if c then self:retire(c,false)end + c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={},ids=Ids.new(game.data)} + if not member(c)then return end + if self.current and self.current~=c then self:retire(self.current,false)end + c.views=Views.new(game,screen,function()return member(c)end,c.ids) + local who=owner();if who then c.users[who]=true end + self.records[screen]=c;self.current=c;self.last=nil + return c +end +function R:active(value) + local game=Native.game(value) + if value~=nil and game~=Native.game()then return nil end + local last=self.lastSession + if last and(not game or last[1]~=game or last[2]~=game.data or last[3]~=game.save + or last[4]~=game.stack or last[5]~=game.mods or self.lastEdition~=Version.get())then + self.last,self.lastSession,self.lastEdition=nil,nil,nil + end + if self.current and(not member(self.current)or self.current.game~=game)then self:retire(self.current,false)end + if not game then return end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if state.isBattleState and state.game==game then return self:bind(state)end + end +end +function R:exit(screen) + local c=self.records[screen] + if c then self:retire(c,cohort(c))end +end +function R:release() + if self.current then self:retire(self.current,false)end + self.last,self.lastSession,self.lastEdition=nil,nil,nil +end +function R:poll()self:active()end +function R:releaseOwner(who) + if self.current and self.current.users[who]then self:retire(self.current,false)end +end +R.member=member +return R diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..949c55d68 --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,226 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid,identifiers) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=identifiers or Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + end + end + e.read=read + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..8d0464c60 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1117,6 +1117,14 @@ end -- ways no amount of widening an `isGen2()` reaches -- a filesystem instead of -- a flat ROM, two screens instead of one, and a 3D field. This exists so the -- Gen 4 work has a gate to hang itself on without touching the branches the +-- Registered GBA family for source mod probes; cache/save ownership stays native. +local gbaLayouts={firered='frlg',leafgreen='frlg',ruby='rse',sapphire='rse',emerald='rse'} +function GameVersion.layout(id,versionId) + if id==GameVersion then id=versionId end + local info=GameVersion.info(id) + return info and(info.layout or gbaLayouts[info.id])or nil +end + -- other three generations take. function GameVersion.isGen4(id) return GameVersion.generation(id) == 4 diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua new file mode 100644 index 000000000..dd02e0fa5 --- /dev/null +++ b/src/core/game3/battle.lua @@ -0,0 +1,189 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source battle presentation and observations, driven by the native owner. +local Runtime=require('src.compat.gen1.BattleRuntime') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local M={} +local runtime=Runtime.new() +local function admitted(c) + if Runtime.member(c)then return true end + runtime:retire(c,false);return false +end +local captures=setmetatable({},{__mode='k'}) +local function serial()local renderer=package.loaded['src.render.Renderer'];return renderer and renderer.frameSerial end +local function sameTarget(a,b,depth) + if a==b then return true end + if type(a)~='table'or type(b)~='table'or(depth or 0)>=4 then return false end + -- LOVE canvases are userdata; preserve identity for table-based test or + -- external wrappers too. Only the MRT binding description is structural. + if a.typeOf or b.typeOf then return false end + for k,v in pairs(a)do if not sameTarget(v,b[k],(depth or 0)+1)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function target(value,depth) + if depth==nil then value=love.graphics.getCanvas();depth=0 end + if type(value)~='table'or value.typeOf or depth>=4 then return value end + local out={};for k,v in pairs(value)do out[k]=target(v,depth+1)end;return out +end +local function call(name,...) + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==M and change.key==name and change.after==M[name]then owner=change.owner;break end + end + return facade._withCallbackOwner(owner,M[name],...) +end +function M.isActive()return runtime:active()~=nil end +function M.getState()local c=runtime:active();return c and c.views.view or runtime.last end +function M.getResult()local view=M.getState();return view and view.result end +function M.start(opts) + if Boundary.active('battle.start')then return Boundary.invoke('battle.start')end + return require('src.compat.gen1.BattleRequests').start(opts) +end +function M.songInfo(st) + st=st or M.getState();local kinds=st and st.kinds or{} + return{wild=st and st.wild or false,link=st and st.link or false, + trainerClass=st and st.trainerClass,trainerName=st and st.trainerName,frontier=kinds.frontier, + kind=kinds.kyogreGroudon and'kyogreGroudon'or kinds.regi and'regi'or nil} +end +function M.statWindowPhase() + local c=runtime:active();if not c then return false end + for _,state in ipairs(c.stack.states)do + if getmetatable(state)==c.screen.StatBox then return true end + end + return false +end +function M.update()return Boundary.invoke('battle.update')end +local function draw(value,w,h) + local c=runtime:active(value);if not c then return false,'no admitted native battle'end + local bw,bh=c.screen:uiSize();w,h=w or bw,h or bh + if w~=bw or h~=bh then return false,'battle capture must use the native surface size'end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + -- A capture can draw the native screen but cannot re-enter source wrappers. + return M._captureNative(c.screen) +end +M.draw=draw +function M._captureNative(screen) + local c=runtime:active();if not c or c.screen~=screen then return false,'native battle admission retired'end + local ok,result=Scope.call(function() + return require('src.core.Game3')._withCallbackOwner(nil,screen._drawNative,screen) + end) + if not ok then error(result,0)end + if admitted(c)then captures[screen]={serial=serial(),target=target()}end + return true +end +function M.abort(result) + result=result or'run' + local c=runtime:active();if not c then return false,'no admitted native battle'end + if c.stack:top()~=c.screen then return false,'native battle is covered by another state'end + if c.aborting then return false,'native battle completion already requested'end + if result~='run'and result~='lose'and result~='win'and result~='caught'then + return false,'a native battle outcome is required' + end + -- Aborting cannot award a victory or catch that the native rules never made. + if result~='run'and c.screen.result~=result then return false,'outcome has not been decided by the native battle'end + if c.screen.kind=='link'then return false,'link termination belongs to the native peer protocol'end + c.views:flush();c.aborting=true;c.screen.result=result;c.screen:finish();return true +end +function M._nativeEnter(screen) + local c=runtime:bind(screen);if not c then return end + if c.notified then return end;c.notified=true + local st=c.views.view + return Boundary.dispatch('battle.start',function()return true end,function() + return call('start',{wild=st.wild,playerParty=st.playerParty,foe=st.enemy and st.enemy.mon, + trainerId=screen.trainer and screen.trainer.id}) + end) +end +function M.reset() + if runtime:active()then return false,'cannot reset an admitted native battle'end + runtime:release();return true +end +function M._nativeUpdate(screen,dt,body) + captures[screen]=nil + local c=runtime:bind(screen);if not c then return body(screen,dt)end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + if Boundary.active('battle.update')then return Boundary.invoke('battle.update')end + return Boundary.dispatch('battle.update',function() + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + local function native(owner,step) + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + c.views:flush() + return require('src.core.Game3')._withCallbackOwner(nil,body,owner,step) + end + local ui=package.loaded['src.core.game3.battle.ui'] + if ui then return ui._nativeUpdate(screen,dt,native)end + return native(screen,dt) + end,function() + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeStep(screen,dt)end + if not admitted(c)or c.stack:top()~=screen then return false,'native battle admission retired'end + local result=call('update',dt,require('src.core.Game3').forNative(c.game)) + if admitted(c)and c.stack:top()==screen then c.views:flush()end + return result + end) +end +function M._nativeDraw(screen,body) + local c=runtime:bind(screen);if not c then return body(screen)end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + local capture=captures[screen];local frame=serial() + if capture and sameTarget(capture.target,target())and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then + if capture.serial==nil then captures[screen]=nil end;return true + end + local ui=package.loaded['src.core.game3.battle.ui'] + local function guarded(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end + local function native() + if not admitted(c)then return false,'native battle admission retired'end + if ui then return ui._nativeDraw(screen,guarded)end + return guarded(screen) + end + if M.draw==draw then return native()end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.draw',native,function() + return call('draw',require('src.core.Game3').forNative(c.game),w,h) + end) + end) + if not ok then error(result,0)end + return result +end +function M._nativeField(screen,body) + local bg=package.loaded['src.core.game3.battle.bg'] + if not bg then return body(screen)end + local c=runtime:bind(screen);if not c then return body(screen)end + return bg._nativeDraw(screen,function(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end) +end +function M._nativeExit(screen) + captures[screen]=nil + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeExit(screen)end + runtime:exit(screen) +end +-- Private typed consumers can resolve a source observation to the same native +-- admission; callers cannot submit another state or a stale completed view. +function M._screen(view) + local c=runtime:active() + if c and(view==nil or view==c.views.view)then return c.screen end +end +local service={poll=function()runtime:poll()end,release=function()captures=setmetatable({},{__mode='k'});runtime:release()end, + releaseOwner=function(_,who)runtime:releaseOwner(who)end} +M._service=service +require('src.core.game3.asset_stream').register(service) +setmetatable(M,{__index=function(_,key) + local c=runtime:active() + if key=='_active'then return c~=nil end + if key=='_st'then return c and c.views.view or runtime.last end + if not c then return nil end + if key=='_pendingEnd'then return c.screen.result end + if key=='_phase'then return c.screen.result and'ending'or c.screen.phase end +end}) +return M diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..5c3bb9c60 --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,267 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then local observed=Coords.battler(d.view,id);return observed and observed.status end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +function A.update(dt) + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + A._nativeStep(screen,dt) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Use the owner's dt, not a second caller-provided clock. The original + -- native update and this hold consume the same once-only display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 000000000..6f8611cf7 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Authored 240x160 sprite anchors, separate from opaque image bounds. Imported +-- cartridge coordinates override the standard anchors on the native owner. +local Battle=require('src.core.game3.battle') +local C={SINGLES={},DOUBLES={}} +C.SUBPRIORITY={[0]=30,40,20,50} +C.BG_PRIORITY_RANK={[0]=2,1,1,2} +C.DRAW_ORDER_SINGLES={1,0} +C.DRAW_ORDER_DOUBLES={3,1,0,2} +C.battleState=Battle.getState +local function point(x,y)return{x=x,y=y,[1]=x,[2]=y}end +for id,p in pairs{[0]={72,80},{176,40},{48,40},{112,80}}do C.SINGLES[id]=point(p[1],p[2])end +for id,p in pairs{[0]={32,80},{200,40},{90,88},{152,32}}do C.DOUBLES[id]=point(p[1],p[2])end +local double,overrides,binding +local users={} +local function recordOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local owner=host and host.currentMod or facade and facade._callbackOwner() + if owner then users[owner]=true end +end +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function C.sideOf(id)return type(id)=='number'and(id%2==0 and'player'or'enemy')or nil end +function C.partner(id)return type(id)=='number'and(id+2)%4 or nil end +function C.fixedId(key) + if type(key)=='number'and key==key and key>=0 and key<=3 then return math.floor(key)end + return({player=0,enemy=1})[key] +end +function C.idOf(key) + if type(key)=='table'then key=key.id or key.side end + if binding and binding[key]~=nil then return binding[key]end + return C.fixedId(key) +end +function C.bind(attacker,target) + recordOwner() + if attacker==nil then binding=nil;return end + attacker=C.fixedId(attacker);target=C.fixedId(target) + binding={} + if target then binding[C.sideOf(target)]=target end + if attacker then binding[C.sideOf(attacker)]=attacker end +end +function C.withBattler(key,fn,...) + local previous=binding;local id=C.idOf(key) + binding={player=previous and previous.player or 0,enemy=previous and previous.enemy or 1} + if id then binding[C.sideOf(id)]=id end + local result=pack(pcall(fn,C.sideOf(id),...));binding=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function C.sideArg(fn,index) + index=index or 1 + return function(...) + local args=pack(...);local id=C.idOf(args[index]) + if type(args[index])~='number'or not id then return fn(...)end + return C.withBattler(id,function(side) + args[index]=side;return fn(unpack(args,1,args.n)) + end) + end +end +function C.setDouble(value)recordOwner();if value==nil then double=nil else double=not not value end end +function C.isDouble(st) + if double~=nil then return double end + st=st or Battle.getState();return st and st.double==true or false +end +function C.setCoordinateOverrides(value)recordOwner();local previous=overrides;overrides=value;return previous end +function C.coords(st,key) + local id=C.idOf(key);if id==nil then return nil end + local p=overrides and overrides[id] + if p then return p end + local screen=Battle._screen(st) + local imported=screen and screen.data.constants.gen3BattlerCoords + local row=imported and(C.isDouble(st)and imported.doubles or imported.singles) + p=row and row[id+1]or(C.isDouble(st)and C.DOUBLES or C.SINGLES)[id] + return point(p.x or p[1],p.y or p[2]) +end +function C.ids(st)return C.isDouble(st)and{0,1,2,3}or{0,1}end +function C.battler(st,key) + st=st or Battle.getState();local id=C.idOf(key) + if not st or id==nil then return nil end + return st.battlers and st.battlers[id]or(id==0 and st.player or id==1 and st.enemy or nil) +end +function C.spritePresent(st,key) + local id=C.idOf(key);if id==nil or id>=2 and not C.isDouble(st)then return false end + local b=C.battler(st,id) + if not st then st=Battle.getState()end + if st and st.absent and st.absent[id]then return false end + return b==nil and id<2 or b~=nil and not b.absent and not b.fainted and(b.mon==nil or(b.mon.hp or 0)>0) +end +function C.subpriority(key)return C.SUBPRIORITY[C.idOf(key)]or 40 end +function C.bgPriorityRank(key)return C.BG_PRIORITY_RANK[C.idOf(key)]or 1 end +function C.monDrawOrder(st)return C.isDouble(st)and{3,1,0,2}or{1,0}end +function C.monBehindZ(key,st) + local id=C.idOf(key) + for i,slot in ipairs(C.monDrawOrder(st))do if slot==id then return(i-1)*100+95 end end + return 95 +end +function C.particleBand(k,st) + k=tonumber(k)or 0 + if k<=0 then return 0,99 end + return k*100+1,k>=#C.monDrawOrder(st)and 999 or k*100+99 +end +function C.layerZ(sub,monbg,bgPriority,st) + sub=tonumber(sub)or 0 + local order=C.monDrawOrder(st);local band=0 + for i=#order,1,-1 do + local id=order[i] + local front + if monbg and rawget(monbg,id)then front=((bgPriority and bgPriority[C.bgPriorityRank(id)])or 2)>=2 + else front=sub=3 then return math.max(1,math.min(98,98-sub))end + return C.layerZ(sub,vm and vm._monbg,vm and vm._bgPrio,st) +end +function C.idTable(values) + local result=setmetatable({},{__index=function(t,k)local id=C.idOf(k);if id~=nil then return rawget(t,id)end end, + __newindex=function(t,k,v)local id=C.idOf(k);rawset(t,id~=nil and id or k,v)end}) + for k,v in pairs(values or{})do result[k]=v end + return result +end +C._service={release=function()double,overrides,binding=nil,nil,nil;users={}end} +C._service.poll=function()if not Battle.isActive()then C._service.release()end end +C._service.releaseOwner=function(_,owner)if users[owner]then C._service.release()end end +require('src.core.game3.asset_stream').register(C._service) +return C diff --git a/src/core/game3/battle/bg.lua b/src/core/game3/battle/bg.lua new file mode 100644 index 000000000..9cd7a851c --- /dev/null +++ b/src/core/game3/battle/bg.lua @@ -0,0 +1,25 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The background hook consumes only the native field pass, never its HUD. +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local B={} +local function draw()return Boundary.invoke('battle.background')end +B.draw=draw +function B._nativeDraw(screen,body) + if B.draw==draw then return body(screen)end + if Boundary.active('battle.background')then return Boundary.invoke('battle.background')end + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.background',function()return body(screen)end,function() + local facade=require('src.core.Game3');local who=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==B and change.key=='draw'and change.after==B.draw then who=change.owner;break end + end + return facade._withCallbackOwner(who,B.draw,screen.gen3TerrainId or screen.terrainId,0,0,0) + end) + end) + if not ok then error(result,0)end + return result +end +return B diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua new file mode 100644 index 000000000..85e74700d --- /dev/null +++ b/src/core/game3/battle/ui.lua @@ -0,0 +1,70 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native battle surface and input-phase seams. Sprite/particle presentation +-- capabilities are separate; this module does not advertise that protocol. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local Ui={} +local function call(name,...) + local facade=require('src.core.Game3');local who=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==Ui and change.key==name and change.after==Ui[name]then who=change.owner;break end + end + return facade._withCallbackOwner(who,Ui[name],...) +end +local function query(method,state) + local screen=Battle._screen(state);if not screen then return false end + if type(screen[method])~='function'then return nil,'native UI query unavailable: '..method end + return screen[method](screen) +end +function Ui.statusHUDVisible()return query('statusHUDVisible')end +function Ui.bottomUIVisible(state) + return query('bottomUIVisible',state) +end +function Ui.moveGridNavigation() + return query('moveGridNavigation') +end +function Ui.bindState(state) + if state==nil then + if Battle.isActive()then return false,'cannot unbind an admitted native battle UI'end + return true + end + if not Battle._screen(state)then return false,'UI state is not the admitted native battle'end + return true +end +local function pump()return Boundary.invoke('battle.ui.update')end +Ui.pump=pump +local function draw(w,h) + local screen=Battle._screen();if not screen then return false,'no admitted native battle'end + local bw,bh=screen:uiSize() + if w~=nil and w~=bw or h~=nil and h~=bh then return false,'UI capture must use the native battle surface'end + if Boundary.active('battle.ui.draw')then return Boundary.invoke('battle.ui.draw')end + return Battle._captureNative(screen) +end +Ui.draw=draw +function Ui._nativeDraw(screen,body) + if Ui.draw==draw then return body(screen)end + if Boundary.active('battle.ui.draw')then return Boundary.invoke('battle.ui.draw')end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.ui.draw',function()return body(screen)end,function()return call('draw',w,h)end) + end) + if not ok then error(result,0)end + return result +end +function Ui._nativeUpdate(screen,dt,body) + if Ui.pump==pump then return body(screen,dt)end + if Boundary.active('battle.ui.update')then return Boundary.invoke('battle.ui.update')end + return Boundary.dispatch('battle.ui.update',function()return body(screen,dt)end,function()return call('pump',dt)end) +end +setmetatable(Ui,{__index=function(_,key) + local screen=Battle._screen() + if key=='_st'then return Battle.getState()end + if not screen then return nil end + if key=='_menuIndex'then return screen.menuIndex end + if key=='_moveIndex'then return screen.moveIndex end + if key=='_mode'then return({menu='menu',moveSelect='moves',mimicSelect='moves',targetSelect='target'})[screen.phase]or'none'end +end}) +return Ui diff --git a/src/core/game3/mon_anim.lua b/src/core/game3/mon_anim.lua new file mode 100644 index 000000000..cf18d74d6 --- /dev/null +++ b/src/core/game3/mon_anim.lua @@ -0,0 +1,85 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Mod-owned presentation players reuse the native procedural motions and +-- imported frame strips. They never tick a battler or consume a command. +local Session=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Motion=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Version=require('src.core.GameVersion') +local M={nativeMotion=true} +local records=setmetatable({},{__mode='k'}) +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function retire(c) + c.game,c.data,c.loader,c.motion,c.pictures,c.ids=nil,nil,nil,nil,nil,nil;c.stopped=true +end +local function valid(c) + if not c or c.stopped then return false end + if Session.game()~=c.game or c.game.data~=c.data or c.game.mods~=c.loader or Version.get()~=c.edition or not c.ids:valid()then retire(c);return false end + return true +end +function M.enabled() + local game=Session.game();local record=game and game.data and game.data.constants and game.data.constants.gen3MonAnim + return type(record)=='table'and type(record.anims)=='table'and next(record.anims)~=nil or false +end +function M.newSprite(species) + local game=Session.game();if not(game and game.data)then return nil,'no native Pokemon data'end + local ids=Ids.new(game.data);local key=ids:native('pokemon',species) + if not key then return nil,'unknown cartridge species'end + local sprite={frames=0} + records[sprite]={game=game,data=game.data,loader=game.mods,edition=Version.get(),key=key,owner=owner(),ids=ids} + return sprite +end +function M.summary(sprite,species,oneFrame) + local c=records[sprite];if not valid(c)then return nil,'native presentation player retired'end + if species~=nil and Ids.new(c.data):native('pokemon',species)~=c.key then return nil,'presentation species changed'end + c.motion=Motion.new(c.data,c.key) + if c.motion then c.motion:restart()end + c.pictures=not oneFrame and Pictures.new(c.data,c.key)or nil + if c.pictures then c.pictures:start()end + sprite.frames=0;return sprite +end +function M.step(sprite) + local c=records[sprite];if not valid(c)then return false,'native presentation player retired'end + if c.motion then c.motion:update(1/60)end + if c.pictures then c.pictures:update(1/60)end + sprite.frames=sprite.frames+1;return true +end +function M.done(sprite) + local c=records[sprite] + return not valid(c)or(not c.motion or not c.motion:active()) + and(not c.pictures or c.pictures.paused or c.pictures.steps==nil) +end +function M.busy(sprite)return not M.done(sprite)end +function M.stop(sprite)local c=records[sprite];if c then retire(c)end end +function M.transform(sprite) + local c=records[sprite];local t=valid(c)and c.motion and c.motion:transform()or Motion.IDENTITY + local tint=t.tint;local color,coeff=0,0 + if tint then + color=math.floor(tint[1]*31+.5)+math.floor(tint[2]*31+.5)*32+math.floor(tint[3]*31+.5)*1024 + coeff=(tint[4]or 0)*16 + end + return{x2=t.dx,y2=t.dy,sx=t.sx,sy=t.sy,rotation=t.rot,alpha=t.alpha, + invisible=t.alpha==0,frame=valid(c)and c.pictures and c.pictures:frame()or 0, + blendColor=color,blendCoeff=coeff} +end +function M.framePic(species,frame,shiny) + if type(frame)~='number'or frame%1~=0 or frame<=0 then return nil end + local game=Session.game();if not(game and game.data)then return nil end + local key=Ids.new(game.data):native('pokemon',species);if not key then return nil end + local mon={species=key,isShiny=shiny==true} + local record=Pictures.record(game.data,key,mon) + if shiny and record and not record.shinySheet then return nil,'native shiny frame strip unavailable'end + -- Borrow the native frame cache. Only its native owner may retire it. + local image=Pictures.frameImage(record,frame,mon) + if not image then return nil end + local w,h=image:getDimensions();return{image=image,w=w,h=h} +end +local service={release=function()for _,c in pairs(records)do retire(c)end end, + poll=function()for _,c in pairs(records)do valid(c)end end, + releaseOwner=function(_,who)for _,c in pairs(records)do if c.owner==who then retire(c)end end end} +M._service=service +require('src.core.game3.asset_stream').register(service) +return M diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua new file mode 100644 index 000000000..b9e6e9c02 --- /dev/null +++ b/src/core/game3/pokemon.lua @@ -0,0 +1,175 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge-numbered observations over native imported Pokemon data and art. +-- This module does not create another party, save, extractor or battle engine. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Pokemon=require('src.pokemon.Pokemon') +local Sprites=require('src.pokemon.Sprites') +local Assets=require('src.render.Assets') +local Version=require('src.core.GameVersion') +local P={} +local current,cache,users=nil,{},{} +local function release() + for _,entry in pairs(cache)do if entry.image and entry.image.release then entry.image:release()end end + current,cache,users=nil,{},{} +end +local function context() + local game=Native.game() + if not(game and game.data and type(game.data.pokemon)=='table')then release();return end + if not current or current.game~=game or current.data~=game.data or current.loader~=game.mods or current.edition~=Version.get()or not current.ids:valid()then + release();current={game=game,data=game.data,loader=game.mods,edition=Version.get(),ids=Ids.new(game.data)} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner() + if who then users[who]=true end + return current +end +local function copy(value,seen) + if type(value)~='table'then return type(value)~='function'and value or nil end + seen=seen or{};if seen[value]then return seen[value]end + local out={};seen[value]=out;for k,v in pairs(value)do out[k]=copy(v,seen)end;return out +end +local function species(value) + local c=context();if not c then return end + local key=c.ids:native('pokemon',value) + return key and c.data.pokemon[key],key,c +end +function P.ready()return context()~=nil end +function P.speciesOf(mon) + if type(mon)~='table'then return nil end + local _,key,c=species(mon.species) + return key and c.ids:read('pokemon',key) +end +function P.name(value)local row=species(value);return row and row.name end +function P.keyName(value)local row,key=species(value);return row and(row.key or row.id or key)end +function P.speciesFromName(value) + local c=context();if not c or type(value)~='string'then return nil end + local found + for key,row in pairs(c.data.pokemon)do + if value==key or value==row.id or value==row.name or value==row.key then + local id=c.ids:read('pokemon',key) + if found and found~=id then return nil,'ambiguous imported species name'end + found=id + end + end + return found +end +function P.national(value)local row=species(value);return row and row.dex end +function P.speciesFromNational(number) + local c=context();if not c then return nil end + local found + for key,row in pairs(c.data.pokemon)do if row.dex==number and not row.unused then + local id=c.ids:read('pokemon',key) + if found and found~=id then return nil,'ambiguous imported National Dex entry'end + found=id + end end + return found +end +function P.types(value) + local row,_,c=species(value);if not row then return nil end + local types=row.types or{row.type1,row.type2} + return{c.ids:read('types',types[1]),c.ids:read('types',types[2]or types[1])} +end +function P.stats(value) + local row=species(value);local s=row and row.baseStats;if not s then return nil end + return{hp=s.hp,atk=s.attack,def=s.defense,spe=s.speed,spa=s.spatk or s.special,spd=s.spdef or s.special} +end +function P.speciesMeta(value) + local row=species(value);if not row then return nil end + return{genderRatio=row.genderRatio,eggGroups=copy(row.eggGroups),eggCycles=row.eggCycles, + catchRate=row.catchRate,baseFriendship=row.friendship,growthRate=row.growthRate, + bodyColor=row.bodyColor,safariFleeRate=row.safariFleeRate} +end +function P.dexEntry(value) + if type(value)=='table'then value=P.speciesOf(value)end + local row=species(value);if not row then return nil end + -- Native imports store metres/kilograms; this source contract uses the + -- cartridge's decimetres/hectograms. Never pass metres as sprite height. + return{height=type(row.height)=='number'and math.floor(row.height*10+.5)or nil, + weight=type(row.weight)=='number'and math.floor(row.weight*10+.5)or nil, + category=row.category,description=type(row.dexEntry)=='string'and row.dexEntry or nil} +end +function P.abilities(value) + local row,_,c=species(value);if not row then return nil end + local a=row.abilities or{};return{c.ids:read('abilities',a[1]),c.ids:read('abilities',a[2])} +end +function P.abilityId(value,personality) + local a=P.abilities(value);if not a then return nil end + return a[(tonumber(personality)or 0)%2+1]or a[1] +end +function P.gender(value,personality) + local row,key,c=species(value);if not row then return nil end + local gender=Pokemon.genderOf(c.data,{species=key,personality=personality}) + return({male='M',female='F',none='U',unknown='U'})[gender] +end +function P.isShiny(mon) + if type(mon)~='table'then return false end + local c=context();local key=c and c.ids:native('pokemon',mon.species) + local native={};for k,v in pairs(mon)do native[k]=v end + -- Typed live views intentionally store their fields behind __index. + for _,k in ipairs{'species','personality','otId','otSecretId','trainerId','isShiny','shiny','dvs','ivs','level'}do + native[k]=mon[k] + end + if key then native.species=key end + return Pokemon.isShiny(native) +end +function P.isEgg(mon)return type(mon)=='table'and(mon.isEgg==true or mon.egg==true)or false end +function P.displayName(mon) + if type(mon)~='table'then return nil end + if mon.nickname and mon.nickname~=''then return mon.nickname end + return P.name(P.speciesOf(mon)) +end +function P.moveName(value) + local c=context();local key=c and c.ids:native('moves',value) + return key and c.data.moves[key].name +end +function P.movePp(value) + local c=context();local key=c and c.ids:native('moves',value) + return key and c.data.moves[key].pp +end +function P.movesAtLevel(value,level) + local row,_,c=species(value);if not row then return nil end + local moves,pp,maximum={},{},{} + for i,key in ipairs(Pokemon.movesAtLevel(row,level))do + moves[i]=c.ids:read('moves',key);pp[i]=c.data.moves[key]and c.data.moves[key].pp;maximum[i]=pp[i] + end + return moves,pp,maximum +end +function P.movesLearnedAt(value,level) + local row,_,c=species(value);if not row then return nil end + local out={};for _,entry in ipairs(row.learnset or{})do + if entry.level==level then out[#out+1]=c.ids:read('moves',entry.move)end + end;return out +end +function P.moveIdAt(mon,slot) + local value=mon and mon.moves and mon.moves[slot] + if type(value)=='number'then return value end + local c=context();return c and type(value)=='table'and c.ids:read('moves',value.id)or nil +end +function P.moveSlotCount(mon)return mon and mon.moves and#mon.moves or 0 end +function P.knowsMove(mon,number) + for slot=1,P.moveSlotCount(mon)do if P.moveIdAt(mon,slot)==number then return true end end;return false +end +local function picture(side,value,form,shiny,personality) + local row,key,c=species(value);if not row then return nil,'imported native species unavailable'end + if form~=nil and form~=0 then return nil,'native alternate-form picture binding unavailable'end + local mon={species=key,personality=personality,isShiny=shiny==true} + local path=Sprites.path(c.data,key,side,{mon=mon,kind='battle'}) + if not path then return nil,'native species picture unavailable'end + local old=cache[path];if old then return old end + local ok,image=pcall(love.graphics.newImage,Assets.resolve(path)) + if not ok then return nil,tostring(image)end + local w,h=image:getDimensions() + local entry={image=image,w=w,h=h};cache[path]=entry;return entry +end +function P.frontPic(value,form,shiny,personality)return picture('front',value,form,shiny,personality)end +function P.backPic(value,form,shiny)return picture('back',value,form,shiny)end +P.frontSprite=P.frontPic +P.flushPicCaches=release +P.invalidate=release +local service={release=release,poll=function()if current then context()end end, + releaseOwner=function(_,who)if users[who]then users[who]=nil;if not next(users)then release()end end end} +P._service=service +require('src.core.game3.asset_stream').register(service) +Assets.register(release) +return P diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..0809268ee 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -32,6 +32,20 @@ require("src.core.ModCompat").install() local Loader = {} Loader.__index = Loader +-- Compatibility battle services also retire on older native hosts whose +-- stream service is available but not yet driven by loader retirement. +local function retireBattlePresentation() + local first + for _,name in ipairs({'src.core.game3.battle','src.core.game3.battle.anim','src.core.game3.battle.anim_coords','src.core.game3.pokemon','src.core.game3.mon_anim'})do + local module=package.loaded[name];local service=type(module)=='table'and module._service + if type(service)=='table'and type(service.release)=='function'then + local ok,why=pcall(service.release,service) + if not ok and not first then first=why end + end + end + if first then error(first,0)end +end + local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only -- walk a dotted target path without creating anything; the base view a @@ -1315,6 +1329,7 @@ function Loader:load(data) -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() + retireBattlePresentation() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1654,6 +1669,7 @@ end -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() local undone = select(1, ModSeams.unwindAll()) + retireBattlePresentation() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index f4d06a01c..a6b0c086b 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -116,6 +116,16 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) + if name=='src.core.game3.battle' or type(name)=='string' and name:match('^src%.core%.game3%.battle%.') then + local facade=package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeMod then facade._observeMod(owner)end + local watched=false + for _,entry in ipairs(snap)do if entry.t==facade then watched=true;break end end + if type(facade)=='table'and not watched and not fresh[facade]then + fresh[facade]=true + snap[#snap+1]={t=facade,label='src.core.Game3',before=copyOf(facade)} + end + end if had == nil and type(name) == "string" and name:match("^src%.") and type(result) == "table" and not fresh[result] then fresh[result] = true diff --git a/src/render/BattleScreenEffect.lua b/src/render/BattleScreenEffect.lua new file mode 100644 index 000000000..ff45819cf --- /dev/null +++ b/src/render/BattleScreenEffect.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Repeated begin calls reapply an effect within one render seam. Nested seams +-- each restore their caller's shader, including error paths guarded by Scope. +local Scope=require('src.render.GraphicsScope') +local M={};M.__index=M +local codes={invert=1,fade_white=2,fade_black=3,darken=3,grayscale=4,custom_blend=5} +local source=[[ +extern number mode; +extern number amount; +extern vec3 blendColour; +vec4 effect(vec4 colour, Image image, vec2 uv, vec2 screen) { + vec4 pixel=Texel(image,uv)*colour; + vec3 target=pixel.rgb; + if(mode<1.5) target=vec3(1.0)-pixel.rgb; + else if(mode<2.5) target=vec3(1.0); + else if(mode<3.5) target=vec3(0.0); + else if(mode<4.5) target=vec3(dot(pixel.rgb,vec3(0.299,0.587,0.114))); + else target=blendColour; + pixel.rgb=mode<1.5 ? target : mix(pixel.rgb,target,amount); + return pixel; +} +]] +function M.new() + return setmetatable({value={type='none',coeff=0,targetColor={1,1,1}}, + bindings=setmetatable({},{__mode='k'}),outside={}},M) +end +function M:set(opts) + if not opts or opts.type==false or opts.type=='none'then + self.value={type='none',coeff=0,targetColor={1,1,1}};return true + end + local kind=opts.type or'invert' + assert(codes[kind],'unsupported battle screen effect') + local amount=opts.coeff==nil and 1 or opts.coeff + assert(type(amount)=='number'and amount==amount and amount>=0 and amount<=1,'invalid battle effect strength') + local colour=opts.targetColor or{1,1,1};local copy={} + for i=1,3 do local v=colour[i];assert(type(v)=='number'and v==v and v>=0 and v<=1,'invalid battle effect colour');copy[i]=v end + self.value={type=kind,coeff=amount,targetColor=copy};return true +end +function M:begin() + local value=self.value;local code=codes[value.type] + if not code then return false end + local g=love.graphics + if not self.shader then self.shader=g.newShader(source)end + local token=Scope.token()or self.outside + local previous=self.bindings[token] + if not previous then previous={shader=g.getShader()};self.bindings[token]=previous end + self.shader:send('mode',code);self.shader:send('amount',value.coeff) + self.shader:send('blendColour',value.targetColor) + g.setShader(self.shader);return true +end +function M:finish() + local token=Scope.token()or self.outside;local previous=self.bindings[token] + if not previous then return false end + self.bindings[token]=nil;love.graphics.setShader(previous.shader);return true +end +function M:release() + -- A surrounding render guard restores a shader still in use on an error + -- path. Do not destroy a borrowed binding during that unwind. + if self.shader and love.graphics.getShader()~=self.shader then self.shader:release();self.shader=nil end + self.bindings=setmetatable({},{__mode='k'}) +end +return M diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..8991aa4d5 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,62 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local scissor = G.getScissor and pack(G.getScissor()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + -- OpenGL applies scissor pixels using the currently bound canvas DPI. + -- Rebind the caller before restoring its saved logical graphics state. + G.setCanvas(unpack(canvas, 1, canvas.n)) + local restored, err = pcall(realPop) + if not restored then + return false, err + end + -- A leaked push may already have restored the same logical rectangle + -- against another DPI. Reapply it after the entire stack is restored. + if scissor then G.setScissor(unpack(scissor, 1, scissor.n)) end + return unpack(out, 1, out.n) +end +return S diff --git a/tests/engine/native_battle_animation.lua b/tests/engine/native_battle_animation.lua new file mode 100644 index 000000000..f7d3c2172 --- /dev/null +++ b/tests/engine/native_battle_animation.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local A=require('src.core.game3.battle.anim') +local C=A.Coords +local Clock=require('src.battle.PresentationClock') +local base=Battle.update +T.eq(A.update(1/60),false,'standalone callers cannot start an animation clock') +T.eq(A.launchMove(33),false);T.eq(A.present(0),nil) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + A.reset();Battle._service.release() + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.phase='QA_IDLE' + local st=Battle.getState();local p=A.present(0) + T.eq(A.present('player'),p);T.eq(p.displayHp,400);T.eq(p.displayMaxHp,400) + T.eq(p.id,0);T.eq(p.side,'player');T.eq(A.present(2),nil) + T.raises(function()p.id=1 end,'read-only') + T.raises(function()p.alpha=0/0 end,'invalid presentation') + C.setDouble(true);T.eq(C.isDouble(),true);C.setDouble(nil);T.eq(C.isDouble(),false) + C.bind(2,3);T.eq(C.idOf('player'),2);T.eq(C.idOf('enemy'),3) + local got=pack(C.withBattler(0,function(side,a,z)T.eq(side,'player');T.eq(C.idOf(side),0);return a,z,nil end,7,8)) + T.eq(got.n,3);T.eq(got[1],7);T.eq(got[2],8) + T.eq(C.idOf('player'),2,'nested binding is restored including trailing nils') + T.raises(function()C.withBattler(0,function()error('binding failure')end)end,'binding failure') + T.eq(C.idOf('player'),2) + local arg=C.sideArg(function(a,side,z)T.eq(a,4);T.eq(side,'enemy');T.eq(C.idOf(side),3);T.eq(z,nil);return true,nil end,2) + T.eq(pack(arg(4,3,nil)).n,2);C.bind(nil) + T.same(C.monDrawOrder(st),{1,0});T.eq(C.subpriority(0),30) + T.eq(C.bgPriorityRank(3),2);T.eq(C.monBehindZ(0),195) + T.same(pack(C.particleBand(2)),{n=2,201,999}) + game.data.constants.gen3BattlerCoords={singles={{x=73,y=81},{x=177,y=41}}} + local x,y=A.battlerCenter(0);T.eq(x,73);T.eq(y,81,'authored anchors use the admitted cartridge table') + local override={[0]={x=25,y=27}} + T.eq(C.setCoordinateOverrides(override),nil);T.eq(C.coords(nil,0),override[0]);C.setCoordinateOverrides(nil) + local poseFrames,poseDt=0 + b.player.picAnim={update=function(_,dt)poseFrames=poseFrames+1;poseDt=dt end} + local done=0 + T.eq(A.launchMove(33,{attackerId=0,targetId=1,onEnd=function()done=done+1 end}),true) + T.eq(A.launchMove(33),false,'an active native clip cannot be overwritten') + T.eq(b.gen3Anim.attackerPosition,0);T.eq(b.gen3Anim.targetPosition,1) + local ball=require('src.battle.Gen3BallAnim').new(game.data,{sendOut='opponent',battler=b.enemy}) + b.gen3Ball,b.gen3BallPlaying=ball,true + b.waitFrames=3;b.queue={{fn=function()error('held battle command executed')end}} + local frames=b.frame + local stepped,completed=0,0 + A.tweenStage(2,function(u,t)stepped=stepped+1;p.ox=12*u;T.eq(t.frames,stepped)end,function()completed=completed+1 end) + Battle.update=function(dt) + T.eq(A.update(999),true);T.eq(A.update(-999),true) + base(dt);return base(dt) + end + b:update(1/60) + T.eq(b.frame,frames+1);T.eq(b.gen3Anim.frame,1);T.eq(ball.frame,1) + T.eq(poseFrames,1);T.eq(poseDt,1/60,'a source argument cannot replace the native dt') + T.eq(b.waitFrames,3,'a source clip owns its own hold');T.eq(#b.queue,1);T.eq(stepped,1);T.eq(completed,0) + T.eq(p.ox,6);T.eq(A.battlerCenter(0),79) + Battle.update=function()return A.update(0)end + b:update(1/60) + T.eq(b.frame,frames+2);T.eq(b.gen3Anim.frame,2);T.eq(b.waitFrames,3) + T.eq(stepped,2);T.eq(completed,1);T.eq(#b.queue,1,'a source ending hold advances display without combat') + for i=1,300 do if not b.gen3AnimPlaying then break end;b:update(1/60)end + T.eq(b.gen3AnimPlaying,false);T.eq(done,1,'source completion follows the real native clip') + for i=1,3 do b:update(1/60)end;T.eq(b.waitFrames,0) + b.player.mon.hp=380;b.player.shownHP=400;b.draining=true + b:update(1/60) + local shown=b.player.shownHP;T.check(shown<400);T.eq(b.player.mon.hp,380,'display never applies damage') + T.eq(A.displayHpRatio(0),shown/400);T.eq(A.hpTweening(),true) + local heldFrame=b.frame;local overlay={};game.stack:push(overlay) + T.eq(A.update(1/60),false);T.eq(b:update(1/60),false) + T.eq(b.frame,heldFrame);T.eq(b.player.shownHP,shown);game.stack:pop() + -- Retire through a real session replacement; neither old poses nor queued + -- callbacks may write to the new admission, even if a save is restored. + local stale=p;local cancelled=0 + A.tweenStage(1,function()cancelled=cancelled+1 end) + local save=game.save;game.save={};T.neq(A.present(0),stale,'a replacement save creates a new presentation identity');game.save=save + T.raises(function()stale.ox=10 end,'retired') + b:update(1/60);T.eq(cancelled,0);T.neq(A.present(0),stale) + b.player.picAnim=nil;Battle.update=base + game.stack.states={};f.close();A._service.poll() + local function nativeTrace(enabled) + package.loaded['src.core.game3.battle.anim']=enabled and A or nil + local fixture=require('tools.battle-source-runtime.fixture').new(edition) + local g=fixture.game;package.loaded['src.core.Game']=g + g.data.constants.gen3BattleAnimGfx={} + g.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local state=fixture.fresh('TACKLE').battle;state.enter=false;g.stack:push(state) + state.player.shownHP,state.enemy.shownHP=400,400 + state.phase='messages';state.current=nil + state.queue={{fn=function() + local s=state + s.gen3Anim:start('MOVE_033',true,0,1,nil,s);s.gen3AnimPlaying=true + s.waitFrames=s.gen3Anim.total + end},{fn=function() + local s=state + s.enemy.mon.hp=370;s.enemy.shownHP=400;s.draining=true + end},{fn=function() + local s=state + s.gen3Ball=require('src.battle.Gen3BallAnim').new(g.data,{sendOut='opponent',battler=s.enemy}) + s.gen3BallPlaying=true;s.waitFrames=80 + end}} + local trace={} + for frame=1,180 do + state:update(1/60) + trace[frame]={frame=state.frame,phase=state.phase,queue=#state.queue,wait=state.waitFrames, + hp=state.enemy.mon.hp,shown=state.enemy.shownHP,draining=state.draining, + anim=state.gen3Anim.frame,playing=state.gen3AnimPlaying, + ball=state.gen3Ball and state.gen3Ball.frame,ballPlaying=state.gen3BallPlaying} + end + g.stack.states={};fixture.close();Battle._service.release();A.reset() + return trace + end + local baseline=nativeTrace(false);local adapted=nativeTrace(true) + for frame=1,#baseline do + local different=false + for key,value in pairs(baseline[frame])do if adapted[frame][key]~=value then different=true end end + for key,value in pairs(adapted[frame])do if baseline[frame][key]~=value then different=true end end + if different then print('FIRST_NATIVE_TRACE_DIFFERENCE',edition,frame, + require('src.link.Json').encode(baseline[frame]),require('src.link.Json').encode(adapted[frame]));break end + end + T.same(adapted,baseline,'an imported animation namespace preserves native queue timing '..edition) +end +Battle.update=base;A.reset();Battle._service.release() +-- Failed native work remains failed throughout its phase; a source wrapper +-- cannot swallow it and retry. The next real frame can recover normally. +local screen={};local attempts=0 +T.raises(function()Clock.run(screen,1/60,function() + local function fail()attempts=attempts+1;error('display failure')end + pcall(Clock.once,screen,'fx',fail);pcall(Clock.once,screen,'fx',fail) +end)end,'display failure') +T.eq(attempts,1);T.eq(Clock.active(screen),false) +T.eq(Clock.run(screen,1/60,function()return Clock.once(screen,'fx',function()return true,nil,4 end)end),true) +T.finish('native battle animation ownership') diff --git a/tests/engine/native_battle_ownership.lua b/tests/engine/native_battle_ownership.lua new file mode 100644 index 000000000..b52cc9d8f --- /dev/null +++ b/tests/engine/native_battle_ownership.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/ownership-check.lua') diff --git a/tests/engine/native_battle_session.lua b/tests/engine/native_battle_session.lua new file mode 100644 index 000000000..6b5ccf7c8 --- /dev/null +++ b/tests/engine/native_battle_session.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local Background=require('src.core.game3.battle.bg') +local Ui=require('src.core.game3.battle.ui') +local baseUpdate,baseDraw=Battle.update,Battle.draw +local originalGame=package.loaded['src.core.Game'] +local retained,weak +T.eq(Battle.isActive(),false) +T.eq(baseUpdate(),nil,'a source call cannot manufacture a native tick') +T.eq(baseDraw(),false) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game; + package.loaded['src.core.Game']=game + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.moveOrder={[33]=f.ids.TACKLE} + game.data.constants.typeOrder={'NORMAL'} + game.data.constants.abilityOrder={'NONE','PROBE_ABILITY'} + game.data.constants.abilities={NONE={index=0},PROBE_ABILITY={index=1}} + game.data.constants.types={NORMAL={index=0}} + game.data.constants.itemOrder={[7]='PROBE_ITEM'} + game.data.items.PROBE_ITEM={index=7} + local b=f.fresh('TACKLE').battle + b.player.ability='PROBE_ABILITY';b.player.mon.item='PROBE_ITEM' + local starts=0;local start=Battle.start + Battle.start=function(opts)starts=starts+1;T.eq(opts.wild,true);return start(opts)end + game.stack:push(b);Battle.start=start;T.eq(starts,1,'real native entry notifies source start once') + local st=assert(Battle.getState()) + T.eq(Battle.isActive(),true) + T.eq(baseDraw({data=game.data,save=game.save,stack=game.stack}),false,'foreign capture cannot retire the active view') + T.eq(Ui.bindState(nil),false) + T.eq(st,Battle.getState(),'one identity for the native admission') + T.eq(st.player.id,0);T.eq(st.enemy.id,1) + T.eq(st.player.side,'player');T.eq(st.enemy.side,'enemy') + T.eq(st.player.mon.species,4);T.eq(st.player.mon.moves[1],33) + T.eq(st.player.mon.item,7);T.eq(st.player.ability,1);T.eq(st.player.type1,0) + T.eq(st.player.mon.maxHp,400);T.eq(st.party[1],st.player.mon) + T.neq(st.player.mon,b.player.mon);T.neq(st, b) + st.player.mon.hp=399;T.eq(b.player.mon.hp,399) + st.player.mon.status=128;T.eq(b.player.mon.status,'PSN');T.eq(b.player.mon.toxic,true) + st.player.mon.status=0;T.eq(b.player.mon.status,nil) + T.raises(function()st.player.mon.hp=-1 end,'invalid native HP') + T.raises(function()st.weather='SUN'end,'not directly writable') + T.raises(function()st.player={}end,'not directly writable') + st.paperPresentation={enabled=true};T.eq(st.paperPresentation.enabled,true) + local ticks=0;local tickFx=b.tickFx + b.tickFx=function(self,...)ticks=ticks+1;return tickFx(self,...)end + b.phase='menu';b.queue={};b.current=nil + local pp=st.player.mon.pp + pp[1]=9 + Battle.update=function(dt,view) + T.eq(require('src.core.Game3').native(view),game) + baseUpdate(dt,view);return baseUpdate(dt,view) + end + local pump=Ui.pump + Ui.pump=function()pump();return pump()end + b:update(1/60);Ui.pump=pump + T.eq(pump(),nil,'UI calls outside the native input phase cannot tick combat') + T.eq(Ui.bindState(st),true);T.eq(Ui.bindState({}),false) + for _,method in ipairs{'statusHUDVisible','bottomUIVisible','moveGridNavigation'}do + if type(b[method])=='function'then T.eq(Ui[method](),b[method](b)) + else local value,why=Ui[method]();T.eq(value,nil);T.check(why:find('native UI query unavailable',1,true)~=nil)end + end + T.eq(ticks,1,'two wrapper base calls consume the native tick once') + T.eq(b.player.mon.moves[1].pp,9,'validated source PP edit reaches the actual native move') + T.eq(baseUpdate(1/60),nil);T.eq(ticks,1,'direct source update outside native scope has no clock') + local overlay={};game.stack:push(overlay) + T.eq(Battle.getState(),st,'covered combat remains observable') + T.eq(Battle.abort('run'),false,'abort cannot pop a foreign overlay') + T.eq(b:update(1/60),false);T.eq(ticks,1) + game.stack:pop() + Battle.update=baseUpdate + T.eq(Battle.statWindowPhase(),false) + game.stack:push(b.StatBox.new(game,b.player.mon)) + T.eq(Battle.statWindowPhase(),true,'the actual native level-up window is observed') + game.stack:pop() + local Host=require('src.mods.Runtime');Host.currentMod='battle_owner' + T.eq(Battle.getState(),st);Host.currentMod=nil + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Battle.getState(),st) + require('src.core.game3.asset_stream').cancelOwner('battle_owner') + T.raises(function()st.player.mon.hp=1 end,'retired') + T.eq(Battle.isActive(),true,'mod teardown retires views while native combat continues') + local fresh=Battle.getState();T.neq(fresh,st);st=fresh + local draws,fields=0,0 + local NativeDraw=require('src.battle.Gen3Battle');local draw,field=NativeDraw.draw,NativeDraw.drawField + NativeDraw.draw=function(screen)draws=draws+1;screen:drawBattleField()end + NativeDraw.drawField=function()fields=fields+1 end + local uiDraw=Ui.draw;local uiDraws=0 + Ui.draw=function(w,h)uiDraws=uiDraws+1;uiDraw(w,h);return uiDraw(w,h)end + Battle.draw=function(view,w,h)T.eq(w,240);T.eq(h,160);baseDraw(view,w,h);return baseDraw(view,w,h)end + b:draw();T.eq(uiDraws,1);T.eq(draws,1);T.eq(fields,1,'base draw consumes the field and native UI once') + local bgDraw=Background.draw + Background.draw=function()return true end + b:draw();T.eq(draws,2);T.eq(fields,1,'background replacement leaves the native UI draw admitted') + Background.draw=bgDraw + Battle.draw=function()love.graphics.push();error('battle presentation error')end + local push,pop=love.graphics.push,love.graphics.pop + T.raises(function()b:draw()end,'battle presentation error') + T.eq(love.graphics.push,push);T.eq(love.graphics.pop,pop) + Battle.draw=baseDraw;Ui.draw=uiDraw;NativeDraw.draw,NativeDraw.drawField=draw,field + local bad=st.player.mon.moves;bad[1]=99 + T.raises(function()b:update(1/60)end,'source move replacement') + bad[1]=33;b:update(1/60) + local rng=st.rng + local nativeHp=b.player.mon.hp + local callbacks=0;b.onFinish=function(result)T.eq(result,'lose');callbacks=callbacks+1 end + b.result='lose';b:finish() + T.eq(Battle.isActive(),false);T.eq(Battle.getState(),st) + T.eq(st.result,'lose');T.eq(st.over,true);T.eq(callbacks,1) + T.eq(st.player.mon.hp,nativeHp,'retired state has a detached final observation') + T.raises(function()st.player.mon.hp=10 end,'retired') + T.raises(function()rng(1,2)end,'retired') + T.eq(Battle.abort('run'),false,'completion cannot pop a subsequent native screen') + T.eq(Battle.reset(),true);T.eq(Battle.getState(),nil) + retained={st,st.player,st.player.mon,rng} + weak=setmetatable({game,game.save,b},{__mode='v'}) + f.close();require('src.core.Game3').release() +end +package.loaded['src.core.Game']=originalGame +for _=1,4 do collectgarbage('collect')end +T.eq(weak[3],nil,'retained completed views do not keep the native controller alive') +T.eq(retained[1].result,'lose');T.raises(function()retained[4](1,2)end,'retired') +-- Isolate this view's ownership from the host's process-wide Game3 facade cache. +local function retiredViews() + local data={pokemon={},moves={},constants={}} + local game={data=data,save={party={}}} + local screen={kind='wild',result='run',rng=function(lo)return lo end} + local weak=setmetatable({game,game.save,data,screen},{__mode='v'}) + local e=require('src.compat.gen1.BattleView').new(game,screen,function()return true end) + local view,rng=e.view,e.rng;e:retire();return view,rng,weak +end +local view,rng,refs=retiredViews() +for _=1,4 do collectgarbage('collect')end +for i=1,4 do T.eq(refs[i],nil,'retired battle views release native owner '..i)end +T.eq(view.result,'run');T.raises(function()rng(1,2)end,'retired') +T.finish('native GBA battle admission, numeric views, once-only phases and teardown') diff --git a/tests/engine/native_battle_source_lifecycle.lua b/tests/engine/native_battle_source_lifecycle.lua new file mode 100644 index 000000000..230905d08 --- /dev/null +++ b/tests/engine/native_battle_source_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_mon_animation_source.lua b/tests/engine/native_mon_animation_source.lua new file mode 100644 index 000000000..787eca455 --- /dev/null +++ b/tests/engine/native_mon_animation_source.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Source=require('src.core.game3.mon_anim') +local Native=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Host=require('src.mods.Runtime') +local original=package.loaded['src.core.Game'] +T.eq(Source.enabled(),false) +T.eq(Source.newSprite(4),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.gen3MonAnim={anims={CHARMANDER={anim='H_SHAKE',delay=3}}} + local rec={sheet='unused-synthetic-strip.png',width=64,height=64,count=2, + play={{frame=1,dur=2},{frame=2,dur=3}},idle={{frame=0,dur=1}}} + game.data.pokemon.CHARMANDER.picAnim=rec + T.eq(Source.enabled(),true) + Host.currentMod='motion_owner';local sprite=assert(Source.newSprite(4));Host.currentMod=nil + T.eq(Source.summary(sprite,4,false),sprite) + local motion=assert(Native.new(game.data,'CHARMANDER'));motion:restart() + local frames=assert(Pictures.new(game.data,'CHARMANDER'));frames:start() + for tick=0,70 do + local native=motion:transform();local source=Source.transform(sprite) + T.eq(source.x2,native.dx);T.eq(source.y2,native.dy) + T.eq(source.sx,native.sx);T.eq(source.sy,native.sy);T.eq(source.rotation,native.rot) + T.eq(source.frame,frames:frame());T.eq(sprite.frames,tick) + Source.step(sprite);motion:update(1/60);frames:update(1/60) + end + T.eq(Source.done(sprite),true) + T.eq(Source.framePic(4,1,true),nil,'a missing shiny strip cannot flash the normal palette') + T.eq(Source.summary(sprite,4,true),sprite) + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Source.step(sprite),true) + require('src.core.game3.asset_stream').cancelOwner('motion_owner');T.eq(Source.step(sprite),false) + T.eq(Source.done(sprite),true) + local swapped=assert(Source.newSprite(4));Source.summary(swapped,4) + require('src.core.GameVersion').set(edition=='firered'and'emerald'or'firered') + T.eq(Source.step(swapped),false,'edition replacement retires the presentation lease') + require('src.core.GameVersion').set(edition) + local retired=assert(Source.newSprite(4));Source.summary(retired,4) + game.data={pokemon={}};T.eq(Source.step(retired),false,'dataset replacement retires the presentation lease') + f.close() +end +Source._service.release();package.loaded['src.core.Game']=original +T.finish('native motion and frame-player presentation adapter; native timing only') diff --git a/tests/engine/native_pokemon_source.lua b/tests/engine/native_pokemon_source.lua new file mode 100644 index 000000000..66fc3b836 --- /dev/null +++ b/tests/engine/native_pokemon_source.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.core.game3.pokemon') +local original=package.loaded['src.core.Game'] +local Host=require('src.mods.Runtime') +T.eq(P.ready(),false) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + local data=game.data + data.constants=data.constants or{} + data.constants.speciesOrder={[4]='CHARMANDER',[277]='SPARSE_SPECIES'} + data.pokemon.SPARSE_SPECIES={index=277,dex=252,name='SPARSE',id='SPARSE_SPECIES'} + data.constants.typeOrder={'NORMAL'};data.constants.types={NORMAL={index=0}} + data.constants.abilityOrder={'NONE','PROBE'};data.constants.abilities={NONE={index=0},PROBE={index=1}} + data.constants.moveOrder={[33]=f.ids.TACKLE} + local row=data.pokemon.CHARMANDER + row.height,row.weight,row.genderRatio,row.abilities=.7,6.9,127,{'PROBE','PROBE'} + row.level1Moves={f.ids.TACKLE};row.learnset={{level=7,move=f.ids.TACKLE}} + T.eq(P.ready(),true);T.eq(P.speciesOf{species='CHARMANDER'},4) + T.eq(P.speciesOf{species=277},277);T.eq(P.national(277),252) + T.eq(P.speciesFromNational(252),277,'cartridge slots and National Dex are distinct') + T.eq(P.speciesFromName('CHARMANDER'),4);T.eq(P.keyName(4),'CHARMANDER') + T.eq(P.name(4),'CHARMANDER');T.eq(P.name(8888),nil) + T.eq(P.types(4)[1],0);T.eq(P.types(4)[2],0) + T.eq(P.stats(4).atk,row.baseStats.attack) + local dex=P.dexEntry(4);T.eq(dex.height,7,'metres become source decimetres');T.eq(dex.weight,69) + local meta=P.speciesMeta(4);meta.genderRatio=0;T.eq(row.genderRatio,127,'observations do not lend native records') + T.eq(P.gender(4,0),'F');T.eq(P.gender(4,255),'M') + T.eq(P.abilities(4)[1],1);T.eq(P.abilityId(4,2),1) + T.eq(P.isShiny{species=4,personality=0,otId=0,otSecretId=0},true) + T.eq(P.isShiny(setmetatable({},{__index={species=4,personality=0,otId=0,otSecretId=0}})),true,'typed mon views use their declared identity fields') + T.eq(P.isShiny{species=4,personality=12,otId=0,otSecretId=0},false) + T.eq(P.isEgg{isEgg=true},true);T.eq(P.isEgg{species=4},false) + T.eq(P.displayName{species=4,nickname='NAMED'},'NAMED') + T.eq(P.displayName{species=4},'CHARMANDER') + local moves,pp,max=P.movesAtLevel(4,10) + T.eq(moves[1],33);T.eq(pp[1],10);T.eq(max[1],10) + T.eq(P.moveName(33),'TACKLE');T.eq(P.movePp(33),10) + T.eq(P.movesLearnedAt(4,7)[1],33) + T.eq(P.moveIdAt({moves={33}},1),33);T.eq(P.moveIdAt({moves={{id=f.ids.TACKLE}}},1),33) + T.eq(P.knowsMove({moves={33}},33),true);T.eq(P.knowsMove({moves={33}},34),false) + Host.currentMod='sprite_owner' + local image=assert(P.frontPic(4)) + T.eq(P.frontPic(4),image,'native source picture is cached within its dataset') + T.check(image.image~=nil and image.w>0 and image.h>0) + T.eq(P.frontPic(4,2),nil,'unsupported forms cannot silently show the base sprite') + require('src.core.game3.asset_stream').cancelOwner('other_owner') + T.eq(P.frontPic(4),image) + P.invalidate();T.neq(P.frontPic(4),image,'owned picture invalidation never reuses its released image') + Host.currentMod=nil;P.invalidate();f.close() +end +package.loaded['src.core.Game']=original +T.finish('native source Pokemon identity, metadata units and picture ownership') diff --git a/tests/engine/native_version_layout.lua b/tests/engine/native_version_layout.lua new file mode 100644 index 000000000..c2a5fcee9 --- /dev/null +++ b/tests/engine/native_version_layout.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Version=require('src.core.GameVersion') +local layouts={firered='frlg',leafgreen='frlg',emerald='rse',ruby='rse',sapphire='rse'} +local original=Version.get() +for id,info in pairs(Version.VERSIONS)do + local prefix,suffix,manifest=info.cachePrefix,info.saveSuffix,info.manifest + T.eq(Version.layout(id),info.layout or layouts[id],id..' metadata layout') + T.eq(Version:layout(id),info.layout or layouts[id],id..' colon query') + Version.set(id);T.eq(Version.layout(),info.layout or layouts[id],id..' current query') + T.eq(Version.get(),id);T.eq(info.cachePrefix,prefix);T.eq(info.saveSuffix,suffix);T.eq(info.manifest,manifest) +end +Version.VERSIONS.QA_LAYOUT={id='QA_LAYOUT',generation=3,layout='custom'} +T.eq(Version.layout('QA_LAYOUT'),'custom','an explicit registered layout takes precedence') +Version.VERSIONS.QA_LAYOUT=nil +T.eq(Version.layout('UNKNOWN'),nil,'unknown IDs cannot inherit the current cartridge layout') +Version.set(original) +T.finish('native registered version layout queries') diff --git a/tools/battle-animation-runtime/coords-check.lua b/tools/battle-animation-runtime/coords-check.lua new file mode 100644 index 000000000..9401a88d9 --- /dev/null +++ b/tools/battle-animation-runtime/coords-check.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional local contract comparison. The oracle is supplied by the caller; +-- no external engine, private package or cartridge is distributed here. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local C=require('src.core.game3.battle.anim_coords') +local reference=assert(loadfile(assert(os.getenv('BATTLE_COORDS_ORACLE'))))() +for _,double in ipairs{false,true}do + local st={double=double,battlers={}} + C.setDouble(double);reference.setDouble(double) + T.same(C.monDrawOrder(st),reference.monDrawOrder(st)) + T.same(C.ids(st),reference.ids(st)) + for id=0,3 do + T.same(C.coords(st,id),reference.coords(st,id)) + T.eq(C.subpriority(id),reference.subpriority(id)) + T.eq(C.bgPriorityRank(id),reference.bgPriorityRank(id)) + T.eq(C.monBehindZ(id,st),reference.monBehindZ(id,st)) + T.eq(C.partner(id),reference.partner(id)) + for _,hp in ipairs{0,1}do + st.battlers[id]={mon={hp=hp}} + T.eq(C.spritePresent(st,id),reference.spritePresent(st,id)) + end + end + for k=0,5 do + local a,b=C.particleBand(k,st);local x,y=reference.particleBand(k,st) + T.eq(a,x);T.eq(b,y) + end + for sub=0,255 do + for pri=0,3 do + for _,vm in ipairs{{},{_monbg={[0]=true,[3]=true},_bgPrio={[1]=1,[2]=3}}}do + T.eq(C.zFor(pri,sub,vm,st),reference.zFor(pri,sub,vm,st)) + end + end + end +end +C.setDouble(nil);reference.setDouble(nil) +for attacker=0,3 do for target=0,3 do + C.bind(attacker,target);reference.bind(attacker,target) + for _,side in ipairs{'player','enemy'}do T.eq(C.idOf(side),reference.idOf(side))end +end end +C.bind(nil);reference.bind(nil) +T.finish('authored battler coordinate contract') diff --git a/tools/battle-animation-runtime/host/conf.lua b/tools/battle-animation-runtime/host/conf.lua new file mode 100644 index 000000000..7aea1d29a --- /dev/null +++ b/tools/battle-animation-runtime/host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/battle-animation-runtime/host/main.lua b/tools/battle-animation-runtime/host/main.lua new file mode 100644 index 000000000..a6381f06b --- /dev/null +++ b/tools/battle-animation-runtime/host/main.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +io.stdout:setvbuf('no') +local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local T=require('tests.harness').suite('native battle animation GPU') + local A=require('src.core.game3.battle.anim') + local Battle=require('src.core.game3.battle') + local Scope=require('src.render.GraphicsScope') + local g=love.graphics + local nativeSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeSource,'@'..root..'/src/battle/BattleState.lua') + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'actual requested GPU backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY')) + love.filesystem.createDirectory('tests/fixture_data/assets') + local pixels=love.image.newImageData(64,64) + pixels:mapPixel(function(x,y) + if x<7 or y<5 or x>56 or y>57 then return 0,0,0,0 end + if y<20 and x<37 then return 1,0,91/255,1 end + if x>40 and y>35 then return 0,1,125/255,1 end + return .2,.3,.9,1 + end) + for _,side in ipairs{'back','front'}do pixels:encode('png','tests/fixture_data/assets/fixmon_a_'..side..'.png')end + pixels:release() + local comparisons,readbacks=0,0 + local function read(canvas) + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();readbacks=readbacks+1;return bytes + end + local function frame(canvas,draw) + local ok,why=Scope.call(function() + g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0);g.setColor(1,1,1,1);draw() + end) + assert(ok,why);return read(canvas) + end + local function same(a,b,label,tolerance) + comparisons=comparisons+1 + if not tolerance then return T.eq(a,b,label)end + local error=0 + for i=1,#a do error=math.max(error,math.abs(a:byte(i)-b:byte(i)))end + if error>tolerance then + local count=0 + for i=1,#a,4 do + local bad=false;for c=0,3 do if math.abs(a:byte(i+c)-b:byte(i+c))>tolerance then bad=true end end + if bad then + print('PIXEL_ORACLE_DIFFERENCE',label,((i-1)/4)%160,math.floor((i-1)/4/160), + string.format('%d,%d,%d,%d',a:byte(i,i+3)),string.format('%d,%d,%d,%d',b:byte(i,i+3)));count=count+1;if count==4 then break end + end + end + print('MAX_PIXEL_ERROR',error) + end + T.check(#a==#b and error<=tolerance,label) + end + -- Independent pixel oracle: inverse-map output pixel centres into the + -- already rendered native image, without using graphics transforms. + local function remap(bytes,width,height,map,alpha) + local rows={} + for y=0,height-1 do + local row={} + for x=0,width-1 do + local sx,sy=map(x+.5,y+.5);sx,sy=math.floor(sx),math.floor(sy) + local value='\0\0\0\0' + if sx>=0 and sx=0 and sy