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-transition-runtime.md b/docs/native-battle-transition-runtime.md new file mode 100644 index 000000000..884b63d32 --- /dev/null +++ b/docs/native-battle-transition-runtime.md @@ -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. diff --git a/src/compat/gen1/TransitionCatalog.lua b/src/compat/gen1/TransitionCatalog.lua new file mode 100644 index 000000000..2069eb3fe --- /dev/null +++ b/src/compat/gen1/TransitionCatalog.lua @@ -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 diff --git a/src/compat/gen1/TransitionRuntime.lua b/src/compat/gen1/TransitionRuntime.lua new file mode 100644 index 000000000..175ec9456 --- /dev/null +++ b/src/compat/gen1/TransitionRuntime.lua @@ -0,0 +1,228 @@ +-- 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 nativeCohort(c) + return 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 cohort(c) + return not c.invalidated and nativeCohort(c) +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 + if c.sourceStarted then c.state.onDone=nil else c.state.onDone=c.original end + end + if c.sourceStarted then c.discardContinuation()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.discardContinuation=function()original=nil;c.original=nil end + 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 + -- Native peers may retain this wrapper after the observer is retired. + -- Preserve the native continuation, but never revive a retired source + -- owner's callback or a continuation from another save/loader cohort. + if c.cancelled or(c.invalidated and c.sourceStarted)or not nativeCohort(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;c.discardContinuation() + 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'] + if not class then return nil end + 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 then + c.cancelled=true + c.discardContinuation() + self:notify(c,false) + if c.state.onDone==c.installedCallback then c.state.onDone=nil end + -- A foreign screen keeps its stack position. If the old admission is + -- uncovered later, its retired continuation remains disabled. + if c.game.stack:top()==c.state then c.state:complete(false)end + 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 diff --git a/src/core/game3/battle_transition.lua b/src/core/game3/battle_transition.lua new file mode 100644 index 000000000..cf745e14d --- /dev/null +++ b/src/core/game3/battle_transition.lua @@ -0,0 +1,155 @@ +-- Source-facing transition phases over the real native StateStack admission. +local Runtime=require('src.compat.gen1.TransitionRuntime') +local Catalog=require('src.compat.gen1.TransitionCatalog') +local Version=require('src.core.GameVersion') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local runtime=Runtime.new() +local T={} +local function selectionOptions(edition,opts) + if Catalog.family(edition)~='rse'then return opts end + local out=Runtime.copy(opts or{}) + local game,world,player=require('src.compat.gen1.NativeSession').resolve() + if game and out.mapType==nil then + local types={NONE=0,TOWN=1,CITY=2,ROUTE=3,UNDERGROUND=4,UNDERWATER=5,OCEAN_ROUTE=6,UNKNOWN=7,INDOOR=8,SECRET_BASE=9} + out.mapType=types[world.map.def.mapType]or tonumber(world.map.def.mapType) + end + local behavior=out.mapBehavior + if game and behavior==nil then + local collision=package.loaded['src.core.game3.collision'] + if collision then behavior=collision.behavior(player.cellX,player.cellY)end + end + if behavior~=nil then + local env=package.loaded['src.core.game3.battle.env_rse'] + if env and env.isSurfable then out._surfable=env.isSurfable(behavior) + elseif game then + local record=game.data.constants and game.data.constants.gen3SourceBehaviorFlags + local converter=require('src.compat.gen1.MetatileBehavior').translator(edition) + local raw=converter and converter.raw(behavior) + local bits=record and type(record.raw)=='string'and raw and record.raw:byte(raw+1) + out._surfable=bits~=nil and math.floor(bits/2)%2==1 + end + end + return out +end +local function methodOwner(name) + 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==T and change.key==name and change.after==T[name]then return change.owner end + end +end +local function call(name,...) + local facade=package.loaded['src.core.Game3'] + if facade and facade._withCallbackOwner then return facade._withCallbackOwner(methodOwner(name)or facade._callbackOwner(),T[name],...)end + return T[name](...) +end +function T.family()return Catalog.family(Version.get())end +function T.ids(family) + local edition=Version.get() + if family then assert(family=='frlg'or family=='rse','unknown transition family');if family~=T.family()then edition=family=='frlg'and'firered'or'emerald'end end + local c=Catalog.new(edition) + function c.getTerrainByMap(opts)return Catalog.terrain(edition,selectionOptions(edition,opts))end + function c.pickWild(opts)return Catalog.pick(edition,selectionOptions(edition,opts),false)end + function c.pickTrainer(opts)return Catalog.pick(edition,selectionOptions(edition,opts),true)end + if c.family=='rse'then function c.pickSpecial(group,opts)return Catalog.special(edition,group,opts)end end + return c +end +function T.getTerrainByMap(opts)return T.ids().getTerrainByMap(opts)end +function T.pickWild(opts)return T.ids().pickWild(opts)end +function T.pickTrainer(opts)return T.ids().pickTrainer(opts)end +function T.pick(opts)opts=opts or{};if opts.transitionId then return opts.transitionId end;return Catalog.pick(Version.get(),selectionOptions(Version.get(),opts),not opts.wild)end +function T.defs() + local defs={};local catalogue=Catalog.new(Version.get()) + local native=require('src.render.BattleTransition') + local c=runtime:current();local data=c and c.data + for id,style in pairs(catalogue.styles)do + local def=data and data.transitions and data.transitions[style]or native.STYLES[style] + if def then defs[id]={id=id,nativeStyle=style,frames=def.frames,kind=def.kind,flash=def.flash}end + end + return defs +end +function T.start(id,opts,callback) + if Boundary.active('transition.start')then + local c=assert(runtime.starting,'native transition unavailable') + if c.startConfigured then return true end + local result=runtime:configure(c,id,opts,callback);c.startConfigured=true + Boundary.invoke('transition.start');return result + end + return runtime:start(id,opts,callback) +end +function T.isActive()return runtime:current()~=nil end +function T.finish() + if Boundary.active('transition.finish')then return Boundary.invoke('transition.finish')end + return runtime:complete(runtime:current(),true) +end +function T.abort()return runtime:complete(runtime:current(),false)end +function T.tick()return Boundary.invoke('transition.tick')end +function T.draw() + local c=runtime:current();if not c then return false end + if c.worldDrawn then c.worldDrawn=false;return true end + return Boundary.invoke('transition.draw') +end +function T.drawWorld(canvas,w,h)return runtime:drawWorld(runtime:current(),canvas,w,h)end +function T.blackSpans()return nil,'native scanline-mask observations are not available for every registered style'end +function T.screenCoverage()return nil,'native scanline-mask observations are not available for every registered style'end +function T.release()runtime:release()end +function T.cancelOwner(who)runtime:cancelOwner(who)end +function T._nativeStart(state) + local c=runtime:bind(state);if not c then return end + -- A direct source start has already passed through its wrappers. Only a + -- native encounter needs the public start notification on stack entry. + if c.sourceStarted then return runtime:configure(c,c.id,c.options,c.continue)end + local previous=runtime.starting;runtime.starting=c + local ok,result=pcall(runtime.phase,runtime,c,'start',function()return true end,function() + return call('start',c.id,c.options,c.continue) + end) + runtime.starting=previous + if not ok then error(result,0)end + return result +end +function T._nativeTick(state,body) + local c=runtime:bind(state);if not c then return body()end + c.worldDrawn=false + return runtime:phase(c,'tick',body,function()return call('tick')end) +end +function T._nativeDraw(state,body) + local c=runtime:bind(state);if not c then return body()end + return runtime:phase(c,'draw',body,function()return call('draw')end) +end +function T._nativeFinish(state,body) + local c=runtime:bind(state);if not c then return body()end + c.ending=true + return runtime:phase(c,'finish',function() + if c.game.stack:top()~=state then return false,'native transition is covered by another state'end + runtime:notify(c,true);return body() + end,function()return call('finish')end) +end +function T._nativeExit(state)runtime:exit(state)end +function T._compositeWorld(renderer)return runtime:composite(renderer,function(...)return call('drawWorld',...)end)end +setmetatable(T,{ + __index=function(_,key) + if key=='ID'or key=='TERRAIN'then return T.ids()[key]end + local c=runtime:observed() + if not c then return key=='_active'and false or key=='_frame'and 0 or key=='_phase'and'idle'or nil end + if key=='_active'then return not c.released and not c.state.finished end + if key=='_phase'then + if c.released then return c.state.phase=='done'and'done'or'idle'end + if c.ending then return 'ending'end + return(c.state.phase=='frlg_intro'or c.state.phase=='flash')and'intro'or'main' + end + if key=='_frame'then return c.state.frame end + if key=='_opts'then return c.options end + if key=='_transitionId'then return c.id end + if key=='_worldDrawn'then return c.worldDrawn==true end + if key=='_intro'and c.state.phase=='frlg_intro'then + local p=(math.max(1,c.state.t)-1)%16 + return{blend=math.max(0,math.min(16,p<8 and(p+1)*2 or(15-p)*2))} + end + end, + __newindex=function(t,key,value) + if key=='_worldDrawn'then local c=runtime:current();if c then c.worldDrawn=value==true end + elseif key:sub(1,1)=='_'then error('transition state belongs to its native owner: '..key,2) + else rawset(t,key,value)end + end, +}) +return T diff --git a/src/import/Gen3TransitionProfile.lua b/src/import/Gen3TransitionProfile.lua new file mode 100644 index 000000000..5777730a6 --- /dev/null +++ b/src/import/Gen3TransitionProfile.lua @@ -0,0 +1,12 @@ +-- RS class ordinals differ from Emerald. Older relocated caches retained +-- Emerald's default pair; resolve only that known inherited profile, leaving +-- explicitly authored class profiles alone. +local M={} +function M.classes(edition,classes) + classes=classes or{} + if(edition=='ruby'or edition=='sapphire')and classes.eliteFour==31 and classes.champion==38 then + return{eliteFour=24,champion=32,magma={},aqua={}} + end + return classes +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..d4db0d62f 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -5171,7 +5171,8 @@ function RomExtractorGen3:extractConstants() underwater = trans.underwater, secretBaseOpponent = trans.secretBaseOpponent, secretBaseTransition = trans.championTransition, - trainerClasses = trans.trainerClasses, + trainerClasses = require('src.import.Gen3TransitionProfile').classes( + type(self.version)=='table' and self.version.id or self.version, trans.trainerClasses), eliteFour = trans.eliteFour, championTransition = trans.championTransition, aquaTransition = trans.aquaTransition, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..bb16d64ef 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1317,6 +1317,9 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + if version == 'ruby' or version == 'sapphire' then + revision = revision .. 'rs-transition-classes-v1:' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index f3560edd0..98f015ce7 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -1103,6 +1103,12 @@ function BattleTransition.new(game, onDone, opts) self.game = game self.onDone = onDone self.t = 0 + self.frame = 0 + self.options = opts or {} + if game and game.data and game.save then + self.cohort = { data=game.data, save=game.save, loader=game.mods, + edition=require('src.core.GameVersion').get() } + end opts = opts or {} -- EVERYTHING EMERALD'S CHOICE ASKS FOR. The three the Game Boy needed are -- still here; the rest are what GetBattleTransitionTypeByMap, @@ -1115,8 +1121,10 @@ function BattleTransition.new(game, onDone, opts) trainerClass = opts.trainerClass, trainerId = opts.trainerId, trainerName = opts.trainerName, legendSpecies = opts.legendSpecies, - legendary = opts.legendary } - local style = Runtime.call("transition.style", vanillaStyle, ctx) + legendary = opts.legendary, requestedStyle = opts.style } + local style = Runtime.call("transition.style", function(value) + return value.requestedStyle or vanillaStyle(value) + end, ctx) local def = styleDef(game, style) -- a hook that names an unregistered style falls back to the vanilla bits if not def then @@ -1140,7 +1148,53 @@ function BattleTransition.new(game, onDone, opts) return self end -function BattleTransition:update(dt) +local function present(self) + local c=self.cohort + if c and (self.game.data~=c.data or self.game.save~=c.save or self.game.mods~=c.loader + or require('src.core.GameVersion').get()~=c.edition) then return false end + local states=self.game.stack and self.game.stack.states + if states then + for _,state in ipairs(states)do if state==self then return true end end + return false + end + return true +end + +function BattleTransition:enter() + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeStart then source._nativeStart(self)end +end + +function BattleTransition:exit() + local source=package.loaded['src.core.game3.battle_transition'] + if source and source._nativeExit then source._nativeExit(self)end +end + +function BattleTransition:complete(completed) + if self.finished or not present(self) then return false end + local stack=self.game.stack + if stack.top and stack:top()~=self then return false,'transition is covered by another native state'end + self.finished=true + self.phase=completed and 'done' or 'idle' + local callback=self.onDone;self.onDone=nil + stack:pop() + if completed and callback then callback()end + return true +end + +function BattleTransition:finish() + if self.finished or not present(self)then return false end + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeFinish then + return source._nativeFinish(self,function()return self:complete(true)end) + end + return self:complete(true) +end + +local function updateBody(self,dt) + if self.finished or not present(self)then return false end + if self.game.stack.top and self.game.stack:top()~=self then return false end + self.frame = self.frame + 1 self.t = self.t + 1 if self.phase == "frlg_intro" then if self.t >= FRLG_INTRO_FRAMES then @@ -1154,20 +1208,20 @@ function BattleTransition:update(dt) end else if self.t >= self.wipeLen + self.blackHold then - self.game.stack:pop() - if self.onDone then self.onDone() end + self:finish() end end end -function BattleTransition:draw() +local function drawBody(self, renderer) if self.phase == "frlg_intro" then local p = (math.max(1, self.t) - 1) % 16 local blend = p < 8 and (p + 1) * 2 or (15 - p) * 2 local a = math.max(0, math.min(1, blend / 16)) - local r = self.game and self.game.renderer + local r = renderer or self.game and self.game.renderer if r then r.screenVeil = { FRLG_INTRO_GRAY, a } + r.nativeTransitionVeil = { state=self, value=r.screenVeil } else love.graphics.setColor(FRLG_INTRO_GRAY, FRLG_INTRO_GRAY, FRLG_INTRO_GRAY, a) @@ -1184,9 +1238,10 @@ function BattleTransition:draw() -- pixel the LCD shows. Hand it to the renderer as a screen-space veil -- so it covers the whole surface at any zoom; only the headless and -- no-renderer paths fall back to filling the 160x144 box. - local r = self.game and self.game.renderer + local r = renderer or self.game and self.game.renderer if r then r.screenVeil = { shade, math.abs(v) } + r.nativeTransitionVeil = { state=self, value=r.screenVeil } return end love.graphics.setColor(shade, shade, shade, math.abs(v)) @@ -1216,7 +1271,7 @@ function BattleTransition:draw() -- alongside a second one outside it. Nothing is stretched -- the pattern is -- continued with more tiles, not scaled-up pixels -- so at 1x the grid works -- out to exactly 20x18 and this is the classic wipe unchanged. - local renderer = self.game and self.game.renderer + renderer = renderer or self.game and self.game.renderer if renderer then renderer.battleWipe = { style = style, prog = prog, t = self.t, game = self.game, @@ -1283,4 +1338,45 @@ function BattleTransition:draw() love.graphics.setColor(1, 1, 1, 1) end +function BattleTransition:update(dt) + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeTick then + return source._nativeTick(self,function()return updateBody(self,dt)end) + end + return updateBody(self,dt) +end + +function BattleTransition:draw() + if self.finished or not present(self)then return false end + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeDraw then + return source._nativeDraw(self,function()return drawBody(self)end) + end + return drawBody(self) +end + +-- Render the same current native effect onto an owned world view. Collect +-- screen-space packets locally; do not replace Game.renderer or sample the +-- canvas being written by scanline effects. +function BattleTransition:drawInto(canvas,w,h,source) + if self.finished or not present(self)then return false end + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + g.origin();g.setCanvas(canvas);g.setShader();g.setScissor();g.setDepthMode() + local packet={};drawBody(self,packet) + if packet.battleWipe then + require('src.render.Renderer').drawBattleWipe(self.game.renderer or {},packet.battleWipe, + w,h,math.floor((w-240)/2),math.floor((h-160)/2),240,160,1,1,source) + end + local veil=packet.screenVeil + if veil and veil[2]>0 then + g.setColor(veil[1],veil[1],veil[1],veil[2]);g.rectangle('fill',0,0,w,h) + end + end) + g.pop() + if not ok then error(why,0)end + return true +end + return BattleTransition diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..3bbb7f0ea 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -344,6 +344,7 @@ function Renderer:beginFrame(transparent) -- fade in from white after a battle) -- covers the window, not just the -- 160x144 letterbox self.screenVeil = nil + self.nativeTransitionVeil = nil -- edge-anchored UI regions, re-declared by their elements each frame self.uiAnchors = nil -- last frame's trueColor rects and sprite redraws go before anything @@ -1021,6 +1022,11 @@ function Renderer:endFrame(zones, worldZones) bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) end + local compatibleTransition=package.loaded['src.core.game3.battle_transition'] + if compatibleTransition and compatibleTransition._compositeWorld then + compatibleTransition._compositeWorld(self) + end + -- A PUBLISHER MAY HAVE FREED ITS CANVAS SINCE IT HANDED IT OVER. -- -- Reported from play on Android, switching CAM TILT between CARTRIDGE and 90: diff --git a/tests/engine/native_transition_runtime.lua b/tests/engine/native_transition_runtime.lua new file mode 100644 index 000000000..9b304cf84 --- /dev/null +++ b/tests/engine/native_transition_runtime.lua @@ -0,0 +1 @@ +dofile('tools/transition-runtime/check.lua') diff --git a/tools/transition-runtime/check.lua b/tools/transition-runtime/check.lua new file mode 100644 index 000000000..190472994 --- /dev/null +++ b/tools/transition-runtime/check.lua @@ -0,0 +1,177 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Source=require('src.core.game3.battle_transition') +T.eq(package.loaded['src.core.Game'],nil,'requiring transition observations cannot bootstrap a game') +T.eq(Source.isActive(),false) +love=require('tests.love_stub') +local Version=require('src.core.GameVersion') +local Native=require('src.render.BattleTransition') +local Host=require('src.mods.Runtime') +local Catalog=require('src.compat.gen1.TransitionCatalog') +local initial={start=Source.start,tick=Source.tick,finish=Source.finish,draw=Source.draw,drawWorld=Source.drawWorld} +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + Source.release() + local f=require('tools.session-runtime.fixture').new(edition) + local game=f.game + if edition=='ruby'or edition=='sapphire'then + -- Exercise an existing relocated cache as well as the new import profile. + local record={wild={{8,9},{5,10},{0,10},{7,6}},trainer={{4,11},{2,3},{0,10},{1,6}}, + names={[4]='pokeballsTrail',[8]='slice',[9]='whiteBarsFade',[12]='sidney',[16]='champion'}, + trainerClasses={eliteFour=31,champion=38},eliteFour={SIDNEY=12},championTransition=16} + game.data.constants.gen3BattleTransitions=record + local imported=require('src.import.Gen3TransitionProfile').classes(edition,record.trainerClasses) + T.eq(imported.eliteFour,24);T.eq(imported.champion,32) + T.eq(record.trainerClasses.eliteFour,31,'runtime fallback cannot rewrite a borrowed native profile') + record.trainerClasses=imported + local elite=Native.new(game,nil,{trainer=true,trainerClass=24,trainerName='SIDNEY'}) + T.eq(elite.style,'g3_mugshot','corrected RS import routes its actual Elite Four class') + local ordinary=Native.new(game,nil,{trainer=true,trainerClass=31,weaker=true}) + T.eq(ordinary.style,'g3_pokeballsTrail','RS class31 does not inherit the Emerald Elite Four role') + record.trainerClasses={eliteFour=31,champion=38} + T.eq(Native.new(game,nil,{trainer=true,trainerClass=31}).style,'g3_mugshot','an authored Emerald-shaped profile remains authoritative at runtime') + local custom={eliteFour=99,champion=100} + T.eq(require('src.import.Gen3TransitionProfile').classes(edition,custom),custom,'authored profiles retain their chosen ordinals') + game.data.constants.gen3BattleTransitions=nil + end + local notices={} + package.loaded['src.ui.game3.presentation']={notify=function(event,ctx)notices[#notices+1]={event,ctx.finished}end} + local ticks,starts,finished,callbacks=0,0,0,0 + Source.start=function(id,opts,cb) + starts=starts+1 + local result=initial.start(id,opts,function()cb();cb()end) + initial.start(id,opts,cb) + return result + end + Source.tick=function()ticks=ticks+1;local result=initial.tick();initial.tick();return result end + Source.finish=function()finished=finished+1;local result=initial.finish();initial.finish();return result end + local ids=Source.ids();T.eq(ids.ID.SLICE,8) + Host.currentMod='transition-owner' + game.stack:push(Native.new(game,function()callbacks=callbacks+1 end, + {style=Catalog.new(edition).styles[8],skipIntro=true})) + T.eq(Source.isActive(),true) + Host.currentMod=nil + local state=game.stack:top() + T.eq(getmetatable(state),Native);T.eq(Source.isActive(),true);T.eq(starts,1) + T.eq(Source._phase,'main');T.eq(Source._transitionId,8);T.eq(Source._frame,0) + local options=Source._opts + Source.tick();Source.tick();T.eq(state.frame,0,'arbitrary calls cannot drive a second native clock') + local beforeTicks=ticks + state:update(1/60);T.eq(state.frame,1);T.eq(ticks,beforeTicks+1) + T.eq(Source._opts,options,'sequence identity is stable through updates') + local top={};game.stack:push(top) + state:update(1/60);T.eq(state.frame,1,'covered native transitions cannot advance') + Host.currentMod='transition-peer';T.eq(Source.abort(),false);Host.currentMod=nil + T.eq(game.stack:top(),top,'abort cannot pop a foreign modal state') + game.stack:pop() + while Source.isActive()do game.stack:update(1/60)end + T.eq(callbacks,1,'callback wrappers cannot invoke native continuation twice') + T.eq(finished,1);T.eq(#notices,1);T.eq(notices[1][1],'battleTransitionEnd');T.eq(notices[1][2],true) + T.eq(game.stack:top(),f.world) + state:update(1/60);T.eq(callbacks,1,'retained finished state cannot finish again') + T.eq(Source._phase,'done');T.eq(Source.isActive(),false) + local aborts=0 + Host.currentMod='transition-owner' + T.eq(Source.start(ids.ID.SLICE,{},function()aborts=aborts+1 end),true) + local aborted=game.stack:top() + T.eq(Source.abort(),true);Host.currentMod=nil + T.eq(aborts,0);T.eq(aborted.onDone,nil);T.eq(notices[2][2],false) + T.eq(game.stack:top(),f.world) + -- Releasing observers must restore an engine-owned continuation. + local vanillaCallbacks=0 + local plain=Native.new(game,function()vanillaCallbacks=vanillaCallbacks+1 end,{}) + game.stack:push(plain);Source.release() + plain:complete(true);T.eq(vanillaCallbacks,1,'adapter teardown preserves native continuations') + local wrappedCallbacks=0 + local wrapped=Native.new(game,function()wrappedCallbacks=wrappedCallbacks+1 end,{}) + game.stack:push(wrapped) + local wrappedBase=wrapped.onDone + wrapped.onDone=function()return wrappedBase()end + local heldNative=wrapped.onDone + Source.release();wrapped:complete(true) + T.eq(wrappedCallbacks,1,'observer teardown preserves a native continuation held by a peer wrapper') + heldNative();T.eq(wrappedCallbacks,1,'a retained native wrapper cannot replay its continuation') + local retiredCallbacks=0 + Host.currentMod='retired-source-owner' + Source.start(8,{},function()retiredCallbacks=retiredCallbacks+1 end) + local retired=game.stack:top();Source.release();Host.currentMod=nil + retired:complete(true) + T.eq(retiredCallbacks,0,'teardown cannot revive an explicitly source-owned continuation') + retiredCallbacks=0 + Host.currentMod='retired-source-owner' + Source.start(8,{},function()retiredCallbacks=retiredCallbacks+1 end) + local retiredWrapped=game.stack:top();local retiredBase=retiredWrapped.onDone + retiredWrapped.onDone=function()return retiredBase()end + Source.release();Host.currentMod=nil;retiredWrapped:complete(true) + T.eq(retiredCallbacks,0,'a peer-held source continuation stays retired after observer teardown') + retiredCallbacks=0 + Host.currentMod='covered-source-owner' + Source.start(8,{},function()retiredCallbacks=retiredCallbacks+1 end) + local covered=game.stack:top();local coveredBase=covered.onDone + covered.onDone=function()return coveredBase()end + local cover={};game.stack:push(cover) + Source.cancelOwner('covered-source-owner');Host.currentMod=nil + T.eq(game.stack:top(),cover,'retiring a covered owner preserves the covering native screen') + game.stack:pop();covered:complete(true) + T.eq(retiredCallbacks,0,'a covered retired owner cannot complete after the covering screen closes') + for _,retirement in ipairs({'release','cancelOwner'})do + local weak=setmetatable({},{__mode='v'}) + local function capture() + local payload={owner='retired-capture'};weak[1]=payload + Host.currentMod='capture-owner' + Source.start(8,{},function()return payload end) + local admission=game.stack:top();local held=admission.onDone + if retirement=='release'then Source.release();admission:complete(false) + else Source.cancelOwner('capture-owner')end + Host.currentMod=nil + return held + end + local peer={capture()};collectgarbage('collect') + T.eq(type(peer[1]),'function','a peer still retains the continuation wrapper') + T.eq(weak[1],nil,'retired source callback captures are released on '..retirement) + end + local customDraws=0 + game.data.transitions={native_peer_style={kind='wipe',frames=2,draw=function()customDraws=customDraws+1 end}} + local peer=Native.new(game,nil,{style='native_peer_style'});game.stack:push(peer) + peer.phase='wipe';peer.t=1;peer:draw() + T.eq(customDraws,1,'a registered foreground style draws once') + T.eq(Source._compositeWorld({worldOverride={}}),false,'foreground styles retain the native UI pass') + T.eq(customDraws,1);peer:complete(false) + local oldNotice=package.loaded['src.ui.game3.presentation'].notify + package.loaded['src.ui.game3.presentation'].notify=function()error('intentional presentation failure')end + local survived=0 + local finish=Native.new(game,function()survived=survived+1 end,{style=Catalog.new(edition).styles[8]}) + game.stack:push(finish);finish:finish() + T.eq(survived,1,'a failed presentation notification cannot strand native battle continuation') + T.eq(game.stack:top(),f.world) + package.loaded['src.ui.game3.presentation'].notify=oldNotice + local weak=setmetatable({},{__mode='v'}) + do + local payload={owned='completed battle payload'};weak[1]=payload + local callback=(function(value)return function()T.eq(value.owned,'completed battle payload')end end)(payload) + local completed=Native.new(game,callback,{style=Catalog.new(edition).styles[8]}) + game.stack:push(completed);completed:finish() + end + collectgarbage('collect');collectgarbage('collect') + T.eq(weak[1],nil,'retained transition observations release the completed callback payload') + -- Source-owned teardown is restricted to its own admission and owner. + Host.currentMod='transition-owner';Source.start(8,{},function()error('cancelled source callback ran')end) + Host.currentMod='transition-peer';Source.cancelOwner('transition-peer');T.eq(Source.isActive(),true) + Host.currentMod='transition-owner';Source.cancelOwner('transition-owner');Host.currentMod=nil + T.eq(Source.isActive(),false) + -- No partially completed script can finish a replacement save/loader. + T.eq(Source.start(8,{},function()error('stale callback ran')end),true) + local stale=game.stack:top();local frame=stale.frame + game.save={};stale:update(1/60) + T.eq(stale.frame,frame);T.eq(Source.isActive(),false);T.eq(Source.finish(),false) + T.eq(game.stack:top(),stale);T.eq(Source._opts,nil) + game.stack:pop() + for k,v in pairs(initial)do Source[k]=v end + Source.release() +end +package.loaded['src.ui.game3.presentation']=nil +-- Family tables are facts, with distinct RS enum limits and names. +local fr=Catalog.new('firered');local em=Catalog.new('emerald');local ru=Catalog.new('ruby') +T.eq(fr.ID.SPIRAL,17);T.eq(fr.ID.AQUA,nil);T.eq(em.ID.FRONTIER_SQUARES_SPIRAL,33) +T.eq(ru.ID.SYDNEY,12);T.eq(ru.ID.STEVEN,16);T.eq(ru.ID.AQUA,nil) +Version.set('crystal');T.eq(Source.isActive(),false) +T.finish('native transition phases and ownership') diff --git a/tools/transition-runtime/generation-check.lua b/tools/transition-runtime/generation-check.lua new file mode 100644 index 000000000..8b73d8176 --- /dev/null +++ b/tools/transition-runtime/generation-check.lua @@ -0,0 +1,22 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local V=require('src.core.GameVersion') +local Native=require('src.render.BattleTransition') +local Source=require('src.core.game3.battle_transition') +local game={data={maps={},gen5_encounter_effects={}},save={},stack={top=function()end}} +for _,edition in ipairs({'red','crystal'})do + V.set(edition);T.eq(getmetatable(Native.new(game,nil,{})),Native,'classic admission remains native') + T.eq(Source.isActive(),false,'GBA observations decline another generation') +end +V.set('platinum') +T.eq(getmetatable(Native.new(game,nil,{})),require('src.render.Gen4BattleTransition'),'real Gen4 constructor still receives its admission') +for _,edition in ipairs({'black','white'})do + V.set(edition) + local state=Native.new(game,nil,{species=494}) + T.eq(getmetatable(state),require('src.render.Gen5BattleTransition'),'real Gen5 constructor remains authoritative') + T.eq(state.frame,0);T.eq(Source.tick(),nil);T.eq(state.frame,0,'source queries do not tick a Gen5 admission') + T.eq(Source.isActive(),false) + T.eq(getmetatable(Native.new(game,nil,{forceClassic=true})),Native,'explicit classic mode remains available') +end +T.finish('native transition generation delegation') diff --git a/tools/transition-runtime/host/conf.lua b/tools/transition-runtime/host/conf.lua new file mode 100644 index 000000000..ec1b4e6b9 --- /dev/null +++ b/tools/transition-runtime/host/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY'));assert(t.identity:match('^oasc%-gen2recomp%-qa%-transition%-gpu%-')) + t.version='12.0';t.gammacorrect=false + t.window.width,t.window.height=320,240;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/transition-runtime/host/main.lua b/tools/transition-runtime/host/main.lua new file mode 100644 index 000000000..1ac8b78e6 --- /dev/null +++ b/tools/transition-runtime/host/main.lua @@ -0,0 +1,92 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('TRANSITION_ENGINE'));local baseline=assert(os.getenv('TRANSITION_BASELINE')) +package.path=root..'/?.lua;'..root..'/?/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 g=love.graphics;local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'));assert(renderer:lower():find(backend,1,true)) + local identity=assert(os.getenv('POKEPORT_IDENTITY'));assert(love.filesystem.getIdentity()==identity) + local T=require('tests.harness').suite('native transition GPU bridge') + local Source=require('src.core.game3.battle_transition') + local Native=require('src.render.BattleTransition');local Renderer=require('src.render.Renderer') + local Old=assert(loadfile(baseline..'/src/render/BattleTransition.lua'))() + local V=require('src.core.GameVersion');local Stack=require('src.core.StateStack') + local readbacks,cases=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 canvas(w,h)return g.newCanvas(w,h,{format='rgba8',dpiscale=1,msaa=0})end + local function pattern(c,mirror) + local w,h=c:getDimensions();g.push('all');g.origin();g.setCanvas(c);g.setShader();g.setScissor();g.setBlendMode('replace','premultiplied') + g.clear(.13,.2,.37,1) + for y=0,h-1 do + local row=mirror and h-y-1 or y + g.setColor((row%17)/17,(math.floor(row/17)%13)/13,(row%29)/29,1);g.rectangle('fill',0,y,w,1) + end + g.setColor(1,.1,.2,1);g.rectangle('fill',0,mirror and h-7 or 0,w,7) + g.setColor(.1,.2,1,1);g.rectangle('fill',0,mirror and 0 or h-7,w,7);g.pop() + end + local function copy(a,b) + g.push('all');g.origin();g.setCanvas(b);g.setShader();g.setScissor();g.setColor(1,1,1,1) + g.setBlendMode('replace','premultiplied');g.draw(a,0,0);g.pop() + end + for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + V.set(edition);Source.release() + local game={data={maps={}},save={},mods={},renderer={}} + game.stack=setmetatable({states={{isOverworld=true}}},{__index=Stack});package.loaded['src.core.Game']=game + local styles={};local seen={} + for _,style in pairs(require('src.compat.gen1.TransitionCatalog').new(edition).styles)do + if not seen[style]and not Native.STYLES[style].draw then seen[style]=true;styles[#styles+1]=style end + end + table.sort(styles) + for _,style in ipairs(styles)do + local state=Native.new(game,nil,{style=style});game.stack:push(state) + local old=Old.new(game,nil,{}) + old.style,old.def,old.wipeLen=state.style,state.def,state.wipeLen + for _,size in ipairs({{240,160},{320,200},{480,320}})do + local w,h=size[1],size[2];local source=canvas(w,h);pattern(source) + local original=read(source);local expected,actual=canvas(w,h),canvas(w,h) + for _,frame in ipairs({0,1,math.floor(state.wipeLen/2),state.wipeLen-1,state.wipeLen})do + state.phase,old.phase='wipe','wipe';state.t,old.t=frame,frame + state.frame=frame + copy(source,expected);copy(source,actual) + local packet={};game.renderer=packet + g.push('all');g.origin();g.setCanvas(expected);g.setShader();g.setScissor();g.setBlendMode('alpha','alphamultiply') + old:draw() + if packet.battleWipe then Renderer.drawBattleWipe(packet,packet.battleWipe,w,h,math.floor((w-240)/2),math.floor((h-160)/2),240,160,1,1,source)end + if packet.screenVeil then local v=packet.screenVeil;g.setColor(v[1],v[1],v[1],v[2]);g.rectangle('fill',0,0,w,h)end + g.pop() + g.push('all');g.origin();g.setCanvas(actual);g.setShader();g.setBlendMode('alpha','alphamultiply') + T.eq(Source.drawWorld(actual,w,h),true);g.pop() + T.eq(read(actual),read(expected),edition..' '..style..' '..w..'x'..h..' frame '..frame) + cases=cases+1 + end + T.eq(read(source),original,'native world inputs remain unchanged') + source:release();expected:release();actual:release() + end + state:complete(false);collectgarbage('collect') + end + -- Explicitly marked legacy orientation is normalized once before hooks. + local state=Native.new(game,nil,{style=require('src.compat.gen1.TransitionCatalog').new(edition).styles[8]}) + game.stack:push(state);state.phase='wipe';state.t=7;state.frame=7 + local upright,mirrored,expected=canvas(320,200),canvas(320,200),canvas(320,200) + pattern(upright);pattern(mirrored,true);copy(upright,expected) + g.push('all');g.setBlendMode('alpha','alphamultiply');Source.drawWorld(expected,320,200);g.pop() + local r={worldOverride=mirrored,worldOverrideMirrored=true,setWorldOverride=Renderer.setWorldOverride} + g.push('all');g.setBlendMode('alpha','alphamultiply');T.eq(Source._compositeWorld(r),true);g.pop() + T.eq(read(r.worldOverride),read(expected),'one explicit orientation correction') + local owned=r.worldOverride + local negative={worldOverride=mirrored,worldOverrideMirrored=false,setWorldOverride=Renderer.setWorldOverride} + Source._compositeWorld(negative) + T.check(read(negative.worldOverride)~=read(expected),'orientation negative must distinguish the fixture') + Source.release();T.raises(function()owned:getDimensions()end,'released','owned transition targets retire') + T.eq(mirrored:getWidth(),320,'borrowed input survives teardown') + game.stack:pop();upright:release();mirrored:release();expected:release() + end + T.finish() + local result={status='pass',checks=T.checks,frameComparisons=cases,readbacks=readbacks,backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, + identity=identity,playerProfilesUsed=false,originalTaskParity=false,fullRendererAcceptance=false} + local f=assert(io.open(assert(os.getenv('TRANSITION_OUT'))..'/result.json','w'));f:write(require('src.link.Json').encode(result));f:close() + print('GPU transition pass',cases,readbacks);love.event.quit(0) +end diff --git a/tools/transition-runtime/selection-oracle.lua b/tools/transition-runtime/selection-oracle.lua new file mode 100644 index 000000000..567f119c9 --- /dev/null +++ b/tools/transition-runtime/selection-oracle.lua @@ -0,0 +1,43 @@ +local code,mode,out=assert(arg[1]),assert(arg[2]),assert(arg[3]) +package.path=code..'/?.lua;'..code..'/?/init.lua;'..package.path +local J=require('src.link.Json') +local cases={} +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + for terrain=0,3 do for _,levels in ipairs({{3,5},{5,5},{8,5}})do + for _,kind in ipairs({'wild','trainer'})do + for _,cls in ipairs({0,3,9,11,13,24,31,32,38,49,53,87,90})do + for _,id in ipairs({0,261,262,263,264,335,410,411,412,413,735,736,737,738,1024})do + cases[#cases+1]={edition=edition,kind=kind,opts={terrain=terrain,mapBehavior=0,mapType=0, + playerLevel=levels[2],enemyLevel=levels[1],trainerClass=cls,trainerId=id}} + end + end + end + end end + for _,cls in ipairs({31,38,87,90})do for _,flag in ipairs({'isLorelei','isBruno','isAgatha','isLance','isBlue','isChampion','isRival','trainerTower','eReader'})do + for _,kind in ipairs({'wild','trainer'})do + local opts={trainerClass=cls,mapBehavior=0,mapType=0};opts[flag]=true + cases[#cases+1]={edition=edition,kind=kind,opts=opts} + end + end end +end +if mode=='source'then + local V=require('src.core.GameVersion') + local fr=require('src.core.game3.battle_transition_ids_frlg') + local rse=require('src.core.game3.battle_transition_ids_rse') + for _,case in ipairs(cases)do + V.set(case.edition) + local ids=(case.edition=='firered'or case.edition=='leafgreen')and fr or rse.forVersion(case.edition) + case.expected=(case.kind=='wild'and ids.pickWild or ids.pickTrainer)(case.opts) + end + local f=assert(io.open(out,'w'));f:write(J.encode(cases));f:close() + print('independent source selections',#cases) +else + local f=assert(io.open(out));cases=J.decode(f:read('*a'));f:close() + local C=require('src.compat.gen1.TransitionCatalog');local failures={} + for _,case in ipairs(cases)do + local got=C.pick(case.edition,case.opts,case.kind=='trainer') + if got~=case.expected and #failures<20 then failures[#failures+1]={case=case,got=got}end + end + print('native selections',#cases,'different',#failures) + assert(#failures==0,J.encode(failures)) +end