Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/native-battle-transition-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Native transition observations and source phases

`src.core.game3.battle_transition` observes the actual `BattleTransition` on
the native StateStack. Entry, tick, draw and finish wrappers are dispatched at
that state's native phases. Calling the source tick outside the update scope
does not advance it. Duplicate base calls reuse a phase result; completion
retires one exact admission and invokes its native continuation at most once.
Covered, finished and retired-dataset admissions cannot pop another screen.
Aborting does not invoke the battle callback. Failed presentation-end notices
are reported without discarding the native continuation.

The source sequence identity, frame and phase are projections of that state.
There is no copied task scheduler, simulation, palette timer or audio loop.
Registered native style selection and foreground draws remain authoritative.
The enum/selection catalogue distinguishes FRLG, Emerald and RS, including
RS's shorter enum, class ordinals, class-based exceptions and level equality.
The inherited Emerald class profile is corrected during RS import. An RS-only
cache revision transparently rebuilds old imports. Runtime profile reads stay
authoritative, including deliberately authored Emerald-shaped profiles.
See https://github.com/pret/pokeruby/blob/master/include/constants/trainers.h.

For existing geometric native styles, an owned copy of the current world
override passes through `drawWorld` before the renderer blits it. Only the
transition's own wipe/veil packets are consumed. Borrowed world images are
never mutated or released. Explicit legacy orientation is normalized once;
native orientation stays upright. Scratch targets are reused until their
view changes, then released on exit/teardown. Foreground `def.draw` styles
keep their native UI pass and are not invoked a second time as world effects.

Verification uses the actual native stack and state, an independent original
selection oracle, and Metal/OpenGL pixel comparisons against the previous
native renderer. It includes explicit orientation negatives, callback replay,
covered UI, retired saves, native peer draws and error/teardown checks.

## Open contracts

This is not original task/visual parity or complete renderer/game acceptance.
FRLG spiral and the additional Emerald/Frontier effect tasks are not yet
implemented in the native renderer. Native mugshots still need their complete
portrait/task state. `defs()` reports owned native style descriptors, not the
original task-function tables; `_fx` task internals are not fabricated.
`blackSpans`/`screenCoverage` explicitly report unavailable native mask
observations. Full original palette/audio tasks, foreground/UI-owned world
effect composition, arbitrary source effect authoring, all custom renderer
paths, five-edition battle/gameplay and full mod-coexistence acceptance remain
required. Package loading past this module alone is not a gameplay result.
95 changes: 95 additions & 0 deletions src/compat/gen1/TransitionCatalog.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
-- Cartridge enum facts; visual execution remains on native registered styles.
local M={}
local base={'BLUR','SWIRL','SHUFFLE','BIG_POKEBALL','POKEBALLS_TRAIL','CLOCKWISE_WIPE','RIPPLE','WAVE',
'SLICE','WHITE_BARS_FADE','GRID_SQUARES','ANGLED_WIPES'}
local tails={frlg={'LORELEI','BRUNO','AGATHA','LANCE','BLUE','SPIRAL'},
rse={'SIDNEY','PHOEBE','GLACIA','DRAKE','CHAMPION','AQUA','MAGMA','REGICE','REGISTEEL','REGIROCK',
'KYOGRE','GROUDON','RAYQUAZA','SHRED_SPLIT','BLACKHOLE','BLACKHOLE_PULSATE','RECTANGULAR_SPIRAL',
'FRONTIER_LOGO_WIGGLE','FRONTIER_LOGO_WAVE','FRONTIER_SQUARES','FRONTIER_SQUARES_SCROLL',
'FRONTIER_SQUARES_SPIRAL','FRONTIER_CIRCLES_MEET','FRONTIER_CIRCLES_CROSS','FRONTIER_CIRCLES_ASYMMETRIC_SPIRAL',
'FRONTIER_CIRCLES_SYMMETRIC_SPIRAL','FRONTIER_CIRCLES_MEET_IN_SEQ','FRONTIER_CIRCLES_CROSS_IN_SEQ',
'FRONTIER_CIRCLES_ASYMMETRIC_SPIRAL_IN_SEQ','FRONTIER_CIRCLES_SYMMETRIC_SPIRAL_IN_SEQ'}}
local commonStyles={'g3_blur','g3_swirl','g3_shuffle','g3_bigPokeball','g3_pokeballsTrail','g3_clockwiseBlackfade',
'g3_ripple','g3_wave','g3_slice','g3_whitefade','g3_grid','g3_angled'}
function M.family(edition)
if edition=='firered'or edition=='leafgreen'then return 'frlg'end
if edition=='emerald'or edition=='ruby'or edition=='sapphire'then return 'rse'end
end
function M.new(edition)
local family=assert(M.family(edition),'no native GBA transition catalogue')
local rs=edition=='ruby'or edition=='sapphire'
local c={ID={},names={},styles={},MUGSHOT_BY_ID={},TERRAIN={NORMAL=0,CAVE=1,FLASH=2,WATER=3},family=family}
for i,name in ipairs(base)do c.ID[name]=i-1;c.names[i-1]=name;c.styles[i-1]=commonStyles[i]end
for i,name in ipairs(tails[family])do
if not rs or i<=5 then c.ID[name]=11+i;c.names[11+i]=name end
end
if family=='frlg'then
c.styles[1],c.styles[4],c.styles[5],c.styles[8],c.styles[9]='frlg_swirl','frlg_balltrail','frlg_clockwise','frlg_slice','frlg_whitebars'
end
local names=family=='frlg'and{'lorelei','bruno','agatha','lance','blue'}or{'sidney','phoebe','glacia','drake','champion'}
for i,name in ipairs(names)do c.MUGSHOT_BY_ID[11+i]=name;c.styles[11+i]='g3_mugshot'end
if family=='rse'and not rs then
for i,name in ipairs({'aqua','magma','regice','registeel','regirock','kyogre','groudon','rayquaza'})do c.styles[16+i]='g3_'..name end
end
if rs then
for alias,name in pairs({CLOCKWISE_BLACKFADE='CLOCKWISE_WIPE',WHITEFADE='WHITE_BARS_FADE',SHARDS='ANGLED_WIPES',SYDNEY='SIDNEY',STEVEN='CHAMPION'})do c.ID[alias]=c.ID[name]end
end
c.TUNE={introFades=family=='frlg'and 2 or 3,blurDelay=family=='frlg'and 2 or 4,
wipeStepX=family=='frlg'and 32 or 16,wipeStepY=family=='frlg'and 16 or 8,
rippleFadeAt=family=='frlg'and 41 or 81,rippleFadeDelay=family=='frlg'and -8 or -2}
return c
end
function M.terrain(edition,opts)
opts=opts or{}
if opts.flash or(tonumber(opts.flashLevel)or 0)>0 then return 2 end
if M.family(edition)=='frlg'then
if opts.surfing or opts.isWater or opts.mapKind=='water'or opts.mapType==4 or opts.mapType==5 then return 3 end
if opts.isCave or opts.mapKind=='cave'or opts.mapKind=='dungeon'or opts.mapType==3 then return 1 end
else
if opts._surfable then return 3 end
if tonumber(opts.mapType)==4 then return 1 end
if tonumber(opts.mapType)==5 then return 3 end
end
return 0
end
local wild={{8,9},{5,10},{0,10},{7,6}}
local trainer={{4,11},{2,3},{0,10},{1,6}}
function M.pick(edition,opts,isTrainer)
opts=opts or{}
local frlg=M.family(edition)=='frlg';local cls=tonumber(opts.trainerClass);local id=tonumber(opts.trainerId)
if isTrainer then
if frlg then
if opts.trainerTower or opts.eReader then cls=nil end
if cls==87 then
for i,name in ipairs({'Lorelei','Bruno','Agatha','Lance'})do if opts['is'..name]then return 11+i end end
return ({[410]=12,[411]=13,[412]=14,[413]=15,[735]=12,[736]=13,[737]=14,[738]=15})[id]or 16
end
if cls==90 or opts.isRival or opts.isChampion then return 16 end
for i,name in ipairs({'Lorelei','Bruno','Agatha','Lance','Blue'})do if opts['is'..name]then return 11+i end end
else
local rs=edition=='ruby'or edition=='sapphire'
if id==1024 or cls==(rs and 32 or 38)then return 16 end
if cls==(rs and 24 or 31)then return ({[261]=12,[262]=13,[263]=14,[264]=15})[id]or 16 end
if edition=='emerald'then
if cls==9 or cls==53 or cls==49 then return 18 end
if cls==3 or cls==13 or cls==11 then return 17 end
end
end
elseif opts.pyramid and not frlg then
return (tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 0 or 10
end
local terrain=opts.terrain or M.terrain(edition,opts)
local row=(isTrainer and trainer or wild)[terrain+1]or(isTrainer and trainer or wild)[1]
return row[(tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 1 or 2]
end
function M.special(edition,group,opts)
opts=opts or{}
if group=='trainer_hill'or group=='secret_base'or group=='e_reader'or group=='battle_tower'then
return(tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 4 or 3
end
assert(edition=='emerald','transition group is unavailable for this edition')
local list=group=='pyramid'and{31,32,33}or group=='dome'and{29,31,32,33}or{29,30,31,32,34,35,36,37,38,39,40,41}
local random=opts.random or math.random
return list[(random(#list*65536)%#list)+1]
end
return M
210 changes: 210 additions & 0 deletions src/compat/gen1/TransitionRuntime.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
-- Observe actual stack admissions; never create a parallel transition clock.
local Native=require('src.compat.gen1.NativeSession')
local Version=require('src.core.GameVersion')
local Catalog=require('src.compat.gen1.TransitionCatalog')
local Boundary=require('src.compat.gen1.ScriptBoundary')
local R={};R.__index=R
local unpack=table.unpack or unpack
local function pack(...)return {n=select('#',...),...}end
local function copy(t,seen)
if type(t)~='table'then
if type(t)=='number'or type(t)=='string'or type(t)=='boolean'then return t end
return nil
end
seen=seen or{};if seen[t]then return seen[t]end
local out={};seen[t]=out
for k,v in pairs(t)do if type(k)=='string'or type(k)=='number'then out[k]=copy(v,seen)end end
return out
end
local function owner()
local host=package.loaded['src.mods.Runtime'];local game=package.loaded['src.core.Game3']
return host and host.currentMod or game and game._callbackOwner and game._callbackOwner()
end
local function cohort(c)
return not c.invalidated and c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader and Version.get()==c.edition
end
local function member(c)
if c.released or not cohort(c)or c.state.finished then return false end
for _,state in ipairs(c.game.stack and c.game.stack.states or{})do if state==c.state then return true end end
return false
end
local function releaseResource(r)if r and r.release then pcall(r.release,r)end end
function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end
function R:notify(c,completed)
if c.notified then return end;c.notified=true
local ui=package.loaded['src.ui.game3.presentation']
if ui and ui.notify then
local ok,why=pcall(ui.notify,'battleTransitionEnd',{finished=completed})
if not ok then
c.presentationError=tostring(why)
require('src.mods.Runtime').reportError(c.owner or 'compatibility-transition',c.presentationError)
end
end
end
function R:drop(c)
if not c or c.released then return end
c.released=true;releaseResource(c.canvas);releaseResource(c.input);c.canvas,c.input=nil,nil
end
function R:release()
for _,c in pairs(self.records)do
if c.state.onDone==c.installedCallback then c.state.onDone=c.original end
c.invalidated=true;self:drop(c)
end
self.records=setmetatable({},{__mode='kv'});self.last=nil
end
function R:bind(state)
local c=self.records[state];if c and cohort(c)then return c end
if c then self:drop(c)end
local game=Native.game(state.game)
if not(game and game.data and game.save and game.stack and Catalog.family(Version.get()))then return nil end
local own=state.cohort
if own and(own.data~=game.data or own.save~=game.save or own.loader~=game.mods or own.edition~=Version.get())then return nil end
c={state=state,game=game,data=game.data,save=game.save,loader=game.mods,edition=Version.get(),owner=owner()}
c.catalog=Catalog.new(c.edition);c.options=copy(state.options or{})
c.options.wild=not c.options.trainer;c.options.playerLevel=c.options.leadLevel
c.options.terrain=c.options.mapTransitionType
local chosen=Catalog.pick(c.edition,c.options,c.options.trainer)
if c.catalog.styles[chosen]==state.style then c.id=chosen
else for id,style in pairs(c.catalog.styles)do if style==state.style then c.id=id;break end end end
local original=state.onDone
c.original=original
c.continue=function()
if c.callbackError then error(c.callbackError,0)end
if c.callbackResult then return unpack(c.callbackResult,1,c.callbackResult.n)end
if not cohort(c)or not state.finished or state.phase~='done'then return false,'native transition has not completed'end
c.callbackResult={n=0}
local fn=original;original=nil;c.original=nil
local result=pack(pcall(function()if fn then return fn()end end));fn=nil
if not result[1]then c.callbackError=result[2];error(result[2],0)end
c.callbackResult={n=result.n-1};for i=2,result.n do c.callbackResult[i-1]=result[i]end
return unpack(c.callbackResult,1,c.callbackResult.n)
end
state.onDone=c.continue;c.installedCallback=c.continue;self.records[state]=c;self.last=c
return c
end
function R:current()
local game=Native.game();if not game then return nil end
local class=package.loaded['src.render.BattleTransition']
for i=#(game.stack and game.stack.states or{}),1,-1 do
local state=game.stack.states[i]
if getmetatable(state)==class and not state.finished then
local c=self.records[state]
if c and not cohort(c)then self:drop(c);return nil end
return c or self:bind(state)
end
end
end
function R:observed()
local c=self:current()or self.last
if c and cohort(c)then return c end
if c then self:drop(c);self.last=nil end
end
function R:configure(c,id,opts,callback)
assert(member(c),'native transition admission retired')
opts=opts or{}
assert(type(opts)=='table','invalid transition options')
if id~=nil then
assert(type(id)=='number'and id%1==0 and c.catalog.names[id],'invalid transition id')
if id~=c.id then
local style=assert(c.catalog.styles[id],'native transition effect is not implemented')
local def=c.data.transitions and c.data.transitions[style]or require('src.render.BattleTransition').STYLES[style]
assert(def,'native transition style is not registered')
c.state.style,c.state.def,c.state.wipeLen=style,def,def.frames;c.id=id
end
end
if callback~=nil then
assert(type(callback)=='function','invalid transition completion');c.state.onDone=callback;c.installedCallback=callback
end
if opts.skipIntro then c.state.phase='wipe';c.state.t=0 end
c.options=copy(opts)
if opts.headless then return self:complete(c,true)end
return true
end
function R:phase(c,name,body,fn,...)
if not member(c)then return false,'native transition admission retired'end
local result=pack(Boundary.dispatch('transition.'..name,body,fn,...))
if c.callbackError then error(c.callbackError,0)end
return unpack(result,1,result.n)
end
function R:complete(c,completed)
if not c or not member(c)then return false,'native transition admission retired'end
local who=owner()
if c.owner and who and c.owner~=who then return false,'native transition belongs to another mod'end
if c.game.stack:top()~=c.state then return false,'native transition is covered by another state'end
self:notify(c,completed)
local result=c.state:complete(completed)
if c.callbackError then error(c.callbackError,0)end
return result
end
function R:start(id,opts,callback)
local game,why=Native.game();if not(game and game.data and game.save and game.stack)then return false,why or'no native transition owner'end
if self:current()then return false,'native transition is busy'end
local catalogue=Catalog.new(Version.get());id=id or catalogue.ID.SLICE
local style=catalogue.styles[id]
if not style then return false,'native transition effect is not implemented'end
local args=copy(opts or{});args.style=style
local state=require('src.render.BattleTransition').new(game,callback,args)
local c=self:bind(state)
if c.catalog.styles[id]==state.style then c.id=id end
c.options=copy(opts or{});c.sourceStarted=true
game.stack:push(state)
return true
end
function R:exit(state)
local c=self.records[state];if not c then return end
self:notify(c,state.finished and state.phase=='done')
self:drop(c)
end
function R:cancelOwner(who)
local c=self:current()
if c and c.sourceStarted and c.owner==who and c.game.stack:top()==c.state then
self:notify(c,false);c.state:complete(false)
end
end
function R:drawWorld(c,canvas,w,h)
if not c or not member(c)then return false end
if c.options.overUi then return false end
if c.state.def.draw then return false,'native foreground transition owns its UI draw'end
assert(canvas and canvas.getDimensions,'invalid transition canvas')
local cw,ch=canvas:getDimensions();w,h=w or cw,h or ch
assert(cw==w and ch==h and w>0 and h>0,'transition view dimensions differ')
local g=love.graphics
if c.input and(c.input:getWidth()~=w or c.input:getHeight()~=h)then releaseResource(c.input);c.input=nil end
c.input=c.input or g.newCanvas(w,h,{dpiscale=canvas:getDPIScale()})
c.input:setFilter('nearest','nearest')
g.push('all')
local ok,why=pcall(function()
g.origin();g.setCanvas(c.input);g.setShader();g.setScissor();g.setDepthMode();g.setColor(1,1,1,1)
g.setBlendMode('replace','premultiplied');g.draw(canvas,0,0)
end)
g.pop();if not ok then error(why,0)end
local drawn=c.state:drawInto(canvas,w,h,c.input)
if drawn then c.worldDrawn=true end
return drawn
end
function R:composite(renderer,fn)
local c=self:current();if not c or c.options.overUi or not renderer.worldOverride then return false end
if c.state.def.draw then return false end
local source=renderer.worldOverride
local ok,w,h=pcall(source.getDimensions,source);if not ok then return false end
local g=love.graphics
if c.canvas and(c.canvas:getWidth()~=w or c.canvas:getHeight()~=h)then releaseResource(c.canvas);c.canvas=nil end
c.canvas=c.canvas or g.newCanvas(w,h,{dpiscale=source:getDPIScale()});c.canvas:setFilter('nearest','nearest')
g.push('all')
local success,drawn=pcall(function()
g.origin();g.setCanvas(c.canvas);g.setShader();g.setScissor();g.setDepthMode();g.setColor(1,1,1,1)
g.setBlendMode('replace','premultiplied')
if renderer.worldOverrideMirrored then g.draw(source,0,h,0,1,-1)else g.draw(source,0,0)end
return fn(c.canvas,w,h)
end)
g.pop();if not success then error(drawn,0)end
if drawn then
if renderer.worldOverride==source then renderer:setWorldOverride(c.canvas,{worldOrientation='native'})end
if renderer.battleWipe and renderer.battleWipe.transition==c.state then renderer.battleWipe=nil end
local veil=renderer.nativeTransitionVeil
if veil and veil.state==c.state and veil.value==renderer.screenVeil then renderer.screenVeil=nil end
end
return drawn
end
R.copy=copy
return R
Loading