From 80359ce95312d1cfde1b9505c008006421d8a69d Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 18:39:01 +0100 Subject: [PATCH 01/10] Connect compatibility player updates to the native fixed-step owner --- docs/native-player-runtime.md | 56 ++++++++++ src/compat/gen1/NativeSession.lua | 35 +++++++ src/compat/gen1/PlayerRuntime.lua | 65 ++++++++++++ src/core/game3/player.lua | 140 +++++++++++++++++++++++++ src/mods/Loader.lua | 11 ++ src/world/OverworldController.lua | 66 +++++++----- src/world/Player.lua | 21 +++- tests/engine/native_player_runtime.lua | 1 + tools/player-runtime/check.lua | 105 +++++++++++++++++++ tools/player-runtime/fixture.lua | 45 ++++++++ 10 files changed, 517 insertions(+), 28 deletions(-) create mode 100644 docs/native-player-runtime.md create mode 100644 src/compat/gen1/NativeSession.lua create mode 100644 src/compat/gen1/PlayerRuntime.lua create mode 100644 src/core/game3/player.lua create mode 100644 tests/engine/native_player_runtime.lua create mode 100644 tools/player-runtime/check.lua create mode 100644 tools/player-runtime/fixture.lua diff --git a/docs/native-player-runtime.md b/docs/native-player-runtime.md new file mode 100644 index 000000000..3d2f1023f --- /dev/null +++ b/docs/native-player-runtime.md @@ -0,0 +1,56 @@ +# Native player phase compatibility + +The compatibility player module operates on the live native GBA actor. It does +not create a second avatar or copy the source movement/event simulation. + +Importing it alone leaves ordinary native input unchanged. A wrapper of its +`update(game,input)` entry point is dispatched from the real overworld player +phase. The base update accepts an input proxy, preserving the native field +handler's interaction, movement, collision, bike and movement-hook ownership. +An idle `tick` is deferred until a wrapper has had the opportunity to submit +its `forcedStep(direction,1)` arrival. There is at most one native player clock +advance in that phase; the shared native `finishPlayerStep` runs synchronously +before the wrapper resumes. Repeated ticks cannot duplicate arrivals. Scripted +NPC/player ordering, including a script queue draining on its landing frame, +continues through the original native path. + +Position, movement and elevation fields address the current native actor; +fractional body coordinates are preserved. Bike state addresses the native +in-memory save. Running eligibility asks the existing native rules without +requiring the B button to be held. Position synchronization changes only the +in-memory native player record and never calls persistence. Input proxies are +restored even when a handler raises. Menu/script/transition ownership prevents +additional input, and actual loader teardown unwinds wrappers and retires phase +observations without bootstrapping compatibility modules on native-only boots. + +## Verification + +`luajit tools/player-runtime/check.lua` runs 261 ROM-free checks across all five +GBA editions and checks the three native GBC editions keep their original phase. +It uses the real native player clock, input handler and arrival bookkeeping on +small synthetic fields, with authored event handlers replaced by counting +callbacks. Coverage includes fractional writes, forced arrivals, repeat ticks, +running gates, surf bob, script queue drain ordering, input exceptions, world +replacement and real loader teardown. It is also registered in the engine tier. + +The composed fork passes 330/330 engine suites, SDK 3/3 and Dramatic Shape B30. +A read-only SDK probe of the full unchanged 1.0.9 source package now reaches +the missing `src.core.game3.collision` interface on all five editions. A previous +reduced source fixture lacks vendor metadata after this point and is excluded +from that result. These are loader/CPU checks, not GPU or gameplay acceptance. + +## Remaining contracts + +This is the player phase foundation, not complete mod compatibility. The +distinct Game3 session facade, collision/field services, full scripted avatar +operations and pose/atlas resources still need integration. In particular, do +not claim all surf/dive/ledge/bike transitions or unchanged rendering have been +accepted from these synthetic checks. Real maps, actual events and visuals +must be verified after the remaining modules allow the package to initialize. + +The standalone upstream branch runs 108 checks on its two registered GBA +editions; LeafGreen, Ruby and Sapphire are explicitly skipped there until +cartridge PRs #183, #186 and #187 are integrated. All five run in the composed +fork. On pristine upstream main `13af468b`, the engine tier has 10 existing +failures (64/74 suites). The player branch has the same 10 failures (65/75), +with no newly failing suite. The composed fork includes the existing fixes. diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua new file mode 100644 index 000000000..0adebf322 --- /dev/null +++ b/src/compat/gen1/NativeSession.lua @@ -0,0 +1,35 @@ +-- Resolve the live owner without bootstrapping Game or keeping a retired world. +local Version = require('src.core.GameVersion') +local M = {} +function M.resolve(game) + local facade = package.loaded['src.core.Game3'] + if type(facade) == 'table' and type(facade.native) == 'function' then + game = facade.native(game) + end + game = game or package.loaded['src.core.Game'] + local info = Version.info(Version.get()) + if type(game) ~= 'table' or not info or info.generation ~= 3 then + return nil, 'no native GBA session' + end + local states = game.stack and game.stack.states + local world + if type(states) == 'table' then + for i = #states, 1, -1 do + if states[i].isOverworld then world = states[i]; break end + end + else + world = game.overworld + end + if not (world and world.isOverworld and world.player and world.map) then + return nil, 'no active native player' + end + return game, world, world.player +end +function M.acceptsInput(game, world, player) + local owner, current, actor = M.resolve(game) + if owner ~= game or current ~= world or actor ~= player then return false end + local states = game.stack and game.stack.states + if states and states[#states] ~= world then return false end + return not world.battleActive and not (world.busy and world:busy()) +end +return M diff --git a/src/compat/gen1/PlayerRuntime.lua b/src/compat/gen1/PlayerRuntime.lua new file mode 100644 index 000000000..0a22347c0 --- /dev/null +++ b/src/compat/gen1/PlayerRuntime.lua @@ -0,0 +1,65 @@ +-- One compatibility call scope inside the native overworld player phase. +-- An idle tick is deferred: free-body wrappers may then submit their one +-- cell arrival without advancing animation, bobbing or events a second time. +local Session = require('src.compat.gen1.NativeSession') +local M = {} +function M.new() + local active + local records = setmetatable({},{__mode='k'}) + local runtime = {} + function runtime:scope() return active end + function runtime:record(player) + local record=records[player] + if not record then record={stepSerial=0};records[player]=record end + return record + end + function runtime:tick(game) + local c = active + if not c or (game and game ~= c.game and game ~= c.facade) then + return nil, 'player ticks belong to the native fixed-step phase' + end + if c.advanced then return false end + local _, world, player = Session.resolve(c.game) + if world ~= c.world or player ~= c.player then return false end + if not c.finishing and not player.moving then return false end + c.advanced = true + local x,y=player.cellX,player.cellY + if not player.moving and player.compatWalkInPlace and not (player.bumpFrames and player.bumpFrames>0) then + player.animClock=(player.animClock or 0)+1 + end + local stepped = player:update() + if stepped then + local record=self:record(player) + record.prevCellX,record.prevCellY=x,y;record.stepSerial=record.stepSerial+1 + end + c.world:finishPlayerStep(stepped, false) + return stepped + end + function runtime:input(input) + local c = active + if not c or c.inputPolled or not Session.acceptsInput(c.game,c.world,c.player) then return end + c.inputPolled = true + local previous = c.game.input + c.game.input = input or previous + local ok, result = xpcall(function() return c.world:handleInput() end, debug.traceback) + c.game.input = previous + if not ok then error(result,0) end + return result + end + function runtime:run(game, world, facade, input, update) + assert(not active, 'nested native player phase') + local c = {game=game,world=world,player=world.player,facade=facade} + active = c + local ok, why = xpcall(function() + update(facade,input) + if active ~= c then return end -- teardown during a callback retires the scope + c.finishing = true + self:tick(game) + end, debug.traceback) + active = nil + if not ok then error(why,0) end + end + function runtime:release() active = nil;records=setmetatable({},{__mode='k'})end + return runtime +end +return M diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua new file mode 100644 index 000000000..3574c304f --- /dev/null +++ b/src/core/game3/player.lua @@ -0,0 +1,140 @@ +-- Compatibility avatar operations on the live native actor. No second player, +-- collision simulator, event runner or on-disk save writer lives here. +local Session = require('src.compat.gen1.NativeSession') +local runtime = require('src.compat.gen1.PlayerRuntime').new() +local Player = {} +local directions = {up={0,-1},down={0,1},left={-1,0},right={1,0}} +local direct = {} +for name in ('cellX cellY px py facing moving progress targetX targetY turnTimer turnArmed animClock running surfing fishing spinning moveDir facingLocked'):gmatch('%S+') do direct[name]=name end +direct.currentElevation='elevation'; direct.elevation='elevation' +direct.walkInPlace='compatWalkInPlace';direct.walkInPlaceFast='compatWalkInPlaceFast' +local function owner(game) + local c=runtime:scope() + if not game and c then game=c.game end + return Session.resolve(game) +end +local function inputOwner(game) + local g,w,p=owner(game) + if not g or not Session.acceptsInput(g,w,p) then return nil,'native field input is locked' end + return g,w,p +end +local function finite(n)return type(n)=='number' and n==n and math.abs(n)0 then return nil end + return p:walkPhase() +end +function Player.drawFlip() + local g,_,p=owner();return g and math.floor((p.animClock or 0)/16)%2==1 or false +end +function Player.release()runtime:release()end +setmetatable(Player,{ + __index=function(_,key) + local g,w,p=owner();if not g then return nil end + if direct[key]then return p[direct[key]]end + if key=='stepFrames'then return p.stepFramesCur or p.stepFrames end + if key=='biking'then return g.save and g.save.onBike==true end + if key=='bikeType'then return p.acroBike and 'acro' or(g.save and g.save.bikeType)end + if key=='underwater'then return w.map.def.mapType=='UNDERWATER'end + if key=='jumping'then return (p.hopFrames or 0)>0 end + if key=='visible'then return Player.isVisible()end + if key=='fieldMoveAnim'then return p.fieldMove and(p.fieldMoveFrames or 1)or 0 end + if key=='stepSerial'or key=='prevCellX'or key=='prevCellY'then return runtime:record(p)[key]end + end, + __newindex=function(t,key,value) + if not(direct[key]or key=='stepFrames'or key=='biking'or key=='visible')then rawset(t,key,value);return end + local g,_,p=owner();if not g then error('no active native player for '..key,2)end + if type(value)=='number' and not finite(value)then error('non-finite player '..key,2)end + if direct[key]then p[direct[key]]=value + elseif key=='stepFrames'then + assert(finite(value)and value>=1 and value%1==0,'invalid player step frames');p.stepFramesCur=value + elseif key=='biking'then g.save.onBike=value==true + elseif key=='visible'then Player.setVisible(value)end + end, +}) +return Player diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..bd6e95cf3 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1309,12 +1309,22 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Retire compatibility scopes after wrapper unwind, without loading them +-- on ordinary native boots. Map observations may be supplied by another PR. +local function retireCompatibilityMaps() + for _, name in ipairs({"src.core.game3.map", "src.core.game3.player"}) do + local module = package.loaded[name] + if type(module) == "table" and type(module.release) == "function" then module.release() end + end +end + function Loader:load(data) -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- 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() + retireCompatibilityMaps() 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 +1664,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()) + retireCompatibilityMaps() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..1f9a47be9 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3962,6 +3962,34 @@ function OverworldState:update(dt) UG.tickSparkles(Game, self) end -- every body on screen, home and foreign alike, in this map's cells + self:stepBody(scriptJustEnded) + + self:followCamera() + + -- pan_camera offset rides on top of the follow; the ramp resumes its + -- runner when it lands + local pan = self.cameraPan + if pan then + if pan.frames then + pan.t = pan.t + 1 + local k = math.min(1, pan.t / pan.frames) + pan.ox = pan.fromX + (pan.toX - pan.fromX) * k + pan.oy = pan.fromY + (pan.toY - pan.fromY) * k + if pan.t >= pan.frames then + pan.frames = nil + local done = pan.onDone + pan.onDone = nil + if done then done() end + end + end + self.camera.x = self.camera.x + pan.ox + self.camera.y = self.camera.y + pan.oy + end +end + +-- Native actor/input/player phase. Camera follow and scene clocks stay +-- in update; both movement interfaces use the same native arrival owner. +function OverworldState:stepBody(scriptJustEnded) self:updateCast() local cast = self.cast or self.entities for _, npc in ipairs(self.npcs) do @@ -4050,8 +4078,13 @@ function OverworldState:update(dt) or self.engaging or self.emote or self.teleportOut or self.whirlSpin or self.player.stairExit end + local compatibilityHandled = false if (not scripted and not self.transitioning) or gen2BootBedroom then - self:handleInput() + local avatar = GameVersion.isGen3() and package.loaded["src.core.game3.player"] + if playerStepped == nil and type(avatar) == "table" and type(avatar._dispatch) == "function" then + compatibilityHandled = avatar._dispatch(Game, self, Game.input) + end + if not compatibilityHandled then self:handleInput() end self.lockedFrames = nil else -- THE WATCHDOG, because "I could not walk" is the one bug report this @@ -4130,8 +4163,14 @@ function OverworldState:update(dt) -- above carries its result down here instead of being stepped again, which -- would hand the player the one-frame advantage this fix took away from the -- NPCs. + if compatibilityHandled then return end local stepped = playerStepped if stepped == nil then stepped = self.player:update() end + self:finishPlayerStep(stepped, scripted) +end + +-- Run synchronously when a compatibility wrapper submits its native arrival. +function OverworldState:finishPlayerStep(stepped, scripted) -- the warp-arrival cell goes stale the instant the player's real cell -- leaves it, scripted walk-outs included -- pokered re-checks warps -- after simulated steps too (CheckWarpsNoCollision), so a forced @@ -4199,27 +4238,6 @@ function OverworldState:update(dt) self:onStepComplete() end - self:followCamera() - - -- pan_camera offset rides on top of the follow; the ramp resumes its - -- runner when it lands - local pan = self.cameraPan - if pan then - if pan.frames then - pan.t = pan.t + 1 - local k = math.min(1, pan.t / pan.frames) - pan.ox = pan.fromX + (pan.toX - pan.fromX) * k - pan.oy = pan.fromY + (pan.toY - pan.fromY) * k - if pan.t >= pan.frames then - pan.frames = nil - local done = pan.onDone - pan.onDone = nil - if done then done() end - end - end - self.camera.x = self.camera.x + pan.ox - self.camera.y = self.camera.y + pan.oy - end end -- any direction currently held (hJoyHeld & PAD_CTRL_PAD) @@ -6033,7 +6051,7 @@ function OverworldState:noteRunGate(why) return why end -function OverworldState:runFrames() +function OverworldState:runFrames(ignoreButton) local frames = FieldDefaults.world(Game.data, "runStepFrames") if type(frames) ~= "number" then self:noteRunGate("this dataset has no run speed at all") @@ -6138,7 +6156,7 @@ function OverworldState:runFrames() return nil end if self.player and self.player.surfing then return nil end - if not (Game.input and Game.input:isDown("b")) then return nil end + if not ignoreButton and not (Game.input and Game.input:isDown("b")) then return nil end self:noteRunGate("running") -- playing AS a Pokemon is Prism's own refusal and does not apply to Hoenn if not gen3 then diff --git a/src/world/Player.lua b/src/world/Player.lua index ba64f60a1..fd8e0f1c0 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -708,6 +708,16 @@ function Player:freeWalk(vx, vy, map, entities) return self.cellX ~= startX or self.cellY ~= startY end +-- Gen3 render hooks may replace the native body draw. Keep its surf bob on +-- the fixed update clock so repeated observations/draws cannot drive or stop it. +local function advanceSurfBob(self) + if GameVersion.isGen3() and self.surfing + and not (self.hopFrames and self.hopFrames > 0) + and not (self.acroBike and self.acroTrick == "hop") then + self.bobTimer = ((self.bobTimer or 0) + 1) % 32 + end +end + function Player:update() -- A PENDING FREE-WALK IS THIS FRAME'S STEP. -- @@ -727,6 +737,7 @@ function Player:update() -- a ledge hopped in third or first person froze its arc mid-air and the -- player stood floating until the next grid step. if self.hopFrames and self.hopFrames > 0 then self.hopFrames = self.hopFrames - 1 end + advanceSurfBob(self) return self:freeWalk(free[1], free[2], free[3], free[4]) end -- LEAVING FREE MODE PUTS THE BODY BACK ON THE GRID. @@ -750,6 +761,7 @@ function Player:update() if self.hopFrames and self.hopFrames > 0 then self.hopFrames = self.hopFrames - 1 end + advanceSurfBob(self) if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end @@ -850,7 +862,7 @@ function Player:walkPhase() -- moving, the land-frame after a completed step, or an active wall-bonk -- (issue #230) animate; a standing sprite otherwise if not self.moving and not self.stepLanded and not self.stairExit - and not self.freeWalking + and not self.freeWalking and not self.compatWalkInPlace and not (self.bumpFrames and self.bumpFrames > 0) then return 0 end @@ -869,7 +881,8 @@ local SPIN_ORDER = { "down", "left", "up", "right" } -- The last return says the player is mid-ledge-hop, which is what the 2D -- path draws the ground shadow from and a 3D path turns into vertical lift. -- --- This ADVANCES the surf-bob and spinner timers, so exactly one of pose() +-- This advances legacy GB surf-bob and spinner timers (Gen3 surf bob is +-- update-owned), so exactly one of pose() -- and draw() may run per frame -- and draw() is written in terms of pose() -- to keep that true by construction. (hopFrames counts down in -- Player:update, on the fixed step, so it is safe to read here.) @@ -937,8 +950,8 @@ function Player:pose() end py = py - math.floor(4 * math.sin(self.hopClock / 8 * math.pi) + 0.5) elseif self.surfing then - self.bobTimer = ((self.bobTimer or 0) + 1) % 32 - py = py + (self.bobTimer < 16 and 0 or 1) + if not GameVersion.isGen3() then self.bobTimer = ((self.bobTimer or 0) + 1) % 32 end + py = py + ((self.bobTimer or 0) < 16 and 0 or 1) end local facing = self.facing local phase = self:walkPhase() diff --git a/tests/engine/native_player_runtime.lua b/tests/engine/native_player_runtime.lua new file mode 100644 index 000000000..69e36203f --- /dev/null +++ b/tests/engine/native_player_runtime.lua @@ -0,0 +1 @@ +dofile('tools/player-runtime/check.lua') diff --git a/tools/player-runtime/check.lua b/tools/player-runtime/check.lua new file mode 100644 index 000000000..882de897d --- /dev/null +++ b/tools/player-runtime/check.lua @@ -0,0 +1,105 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local F=require('tools.player-runtime.fixture') +local P=require('src.core.game3.player') +local base=P.update +T.eq(package.loaded['src.core.Game'],nil,'import does not bootstrap Game') +T.eq(P.cellX,nil,'there is no synthetic player before a session') +T.eq(P.tick(),nil,'out-of-phase ticks cannot run a second simulation') +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + if require('src.core.GameVersion').info(edition) then + local f=F.new(edition);local w,p,g=f.world,f.player,f.game + T.eq(P.cellX,4);P.px=64.25;T.eq(p.px,64.25,'fractional body writes reach native actor') + P.currentElevation=6;T.eq(p.elevation,6);P.currentElevation=3 + P.biking=true;T.eq(g.save.onBike,true);P.biking=false + P.setVisible(false);T.eq(p.hidden,true);T.eq(P.isVisible(),false);P.visible=true + T.eq(P.canDash(),true,'native shoes policy can be queried without a held B') + g.save.flags.FLAG_G3_0001=nil;T.eq(P.canDash(),false);g.save.flags.FLAG_G3_0001=true + f.map.noRunning=true;T.eq(P.runningDisallowed(4,4),true);T.eq(P.canDash(),false);f.map.noRunning=false + T.raises(function()P.px=0/0 end,'non-finite','invalid body writes are rejected') + T.eq(P._dispatch(g,w,f.input),false,'mere import preserves native input path') + -- The actual source wrapper pattern: idle tick, fractional body proposal, + -- forcedStep(1), synchronous tick, then restore the fractional body. + P.update=function(game,input) + base(game,{isDown=function()return false end,wasPressed=function()return false end}) + P.px=71.5 + T.eq(P.forcedStep('right',1),true) + T.eq(P.tick(game),true) + T.eq(w.arrivals,1,'native arrival completes before wrapper resumes') + T.eq(P.forcedStep('right',1),false,'one phase cannot submit a second arrival') + T.eq(P.tick(game),false,'repeat ticks do not advance clocks') + P.px=72.5 + end + w:stepBody(false) + T.eq(p.cellX,5);T.eq(p.px,72.5);T.eq(p.animClock,1);T.eq(w.arrivals,1);T.eq(w.casts,1) + T.eq(P.stepSerial,1);T.eq(P.prevCellX,4) + -- A callback can acquire UI ownership while an arrival is being consumed. + local seen=false + w.onArrival=function()g.stack.states[2]={isMenu=true};seen=true end + P.update=function(game,input) + base(game,input);T.eq(P.forcedStep('right',1),true);P.tick(game) + T.eq(P.tryMove('right',game),nil,'a newly opened menu blocks more input') + end + w:stepBody(false);T.eq(seen,true);T.eq(w.arrivals,2);T.eq(p.animClock,2) + g.stack.states[2]=nil;w.onArrival=nil + -- Input proxies are scoped, including errors from the real native handler. + local actual=w.handleInput + w.handleInput=function()error('input probe failure')end + local proxy={} + P.update=function(game)base(game,proxy)end + T.raises(function()w:stepBody(false)end,'input probe failure','native input error propagates') + T.eq(g.input,f.input,'input owner is restored on failure') + w.handleInput=actual;P.update=function(game,input)return base(game,input)end + w:stepBody(false);T.eq(p.animClock,2,'failure leaves no stale scope or extra walk tick') + -- Real native input + real native player clock; one arrival at 16 ticks. + f.input.held.right=true;p.px=p.cellX*16 + local initial=p.cellX;local arrivals=w.arrivals + for i=1,16 do w:stepBody(false)end + T.eq(p.cellX,initial+1);T.eq(w.arrivals,arrivals+1);T.eq(p.animClock,18) + f.input.held.right=false + P.syncSavePosition(g);T.eq(g.save.player.x,p.cellX);T.eq(g.save.player.map,w.map.id) + -- Scripted steps bypass wrappers and keep the early native NPC/player order. + local wrapped=0;P.update=function()wrapped=wrapped+1 end + w.scriptMoves={{}};p.moving=true;p.targetX=p.cellX+1;p.targetY=p.cellY;p.progress=0;p.stepFramesCur=1 + arrivals=w.arrivals;w:stepBody(false) + T.eq(wrapped,0);T.eq(w.arrivals,arrivals,'scripted arrivals do not run ordinary step events') + T.eq(p.moving,false);w.scriptMoves={} + -- A scripted landing can empty its queue before the input gate; it must + -- still retain the earlier actor result instead of dispatching a second tick. + w.scriptMoves={{}};p.moving=true;p.targetX=p.cellX+1;p.targetY=p.cellY;p.progress=0 + w.updateScriptMoves=function(self)self.scriptMoves={}end + local clock=p.animClock;w:stepBody(false) + T.eq(wrapped,0,'queue-drain frame retains the already executed native tick') + T.eq(p.animClock,clock+1) + -- Surf bob and in-place animation each advance once, even if an unchanged + -- wrapper calls tick twice and renders the phase several times. + p.surfing=true;p.bobTimer=0;P.walkInPlace=true + P.update=function(game,input)base(game,input);P.tick(game);P.walkPhase();P.walkPhase()end + clock=p.animClock;w:stepBody(false) + T.eq(p.bobTimer,1);T.eq(p.animClock,clock+1) + local _,_,_,_,phase,flip=p:pose() + T.eq(P.walkPhase(),phase);T.eq(P.drawFlip(),flip,'pose alternation follows the native animation clock') + p:pose();p:pose();T.eq(p.bobTimer,1,'repeated native poses cannot advance the GBA surf clock') + P.walkInPlace=false;p.surfing=false + -- A replacement native world becomes authoritative immediately. + local held=p;local other=F.new(edition) + P.px=99.5;T.eq(other.player.px,99.5);T.check(held.px~=99.5) + local seams=require('src.mods.ModSeams') + P.update=base + seams.run('player_lifecycle_probe',function()P.update=function(game,input)return base(game,input)end end) + require('src.mods.Loader').teardown() + T.eq(P.update,base,'actual mod teardown unwinds player wrappers') + T.eq(P.stepSerial,0,'retired observations do not follow a new actor') + P.update=base;P.release() + else print('SKIP unregistered edition: '..edition..' (separate cartridge support PR)') end +end +-- Other generations never dispatch source wrappers. +for _,edition in ipairs({'gold','silver','crystal'})do + local f=F.new(edition);local calls=0 + P.update=function()calls=calls+1 end + -- No held input: leave version-specific movement unaltered. + f.world:stepBody(false);T.eq(calls,0,edition..' keeps its native player phase') + P.update=base;P.release() +end +T.finish('native compatibility player runtime') diff --git a/tools/player-runtime/fixture.lua b/tools/player-runtime/fixture.lua new file mode 100644 index 000000000..2391b5a3b --- /dev/null +++ b/tools/player-runtime/fixture.lua @@ -0,0 +1,45 @@ +local F={} +function F.new(edition) + require('src.core.GameVersion').set(edition) + local Game=require('src.core.Game') + local Native=require('src.world.Player') + local OW=require('src.world.OverworldController') + -- enter() normally binds this shared upvalue while loading a full world. + -- This ROM-free fixture exercises the real phase without loading assets. + for i=1,100 do + local name=debug.getupvalue(OW.stepBody,i) + if not name then break end + if name=='Game'then debug.setupvalue(OW.stepBody,i,Game);break end + end + local input={held={},edges={}} + function input:isDown(key)return self.held[key]==true end + function input:wasPressed(key)return self.edges[key]==true end + function input:wasReleased()return false end + local map={id='TEST',def={mapType='ROUTE',allowRunning=true},blocked={}} + function map:inBounds(x,y)return x>=0 and y>=0 and x<20 and y<20 end + function map:isWalkableCell(x,y)return not self.blocked[x..':'..y]end + function map:cellElevation()return 3 end + function map:runningBlockedAt(x,y)return self.noRunning or false end + local player=setmetatable({cellX=4,cellY=4,px=64,py=64,facing='right',moving=false, + progress=0,stepFrames=16,bikeStepFrames=4,turnTimer=0,turnArmed=true, + elevation=3,animClock=0,stepFlip=false},Native) + local ow=setmetatable({isOverworld=true,player=player,map=map,npcs={},entities={},ghosts={},scriptMoves={}, + runner={isRunning=function()return false end},arrivals=0,casts=0,inputs=0},{__index=OW}) + function ow:updateCast()self.casts=self.casts+1 end + function ow:updateScriptMoves()end + function ow:checkTrainerSight()end + function ow:onStepComplete()self.arrivals=self.arrivals+1;if self.onArrival then self:onArrival()end end + for _,name in ipairs({'checkGen2Whirlpool','checkHgssLadder','checkGen2CarpetExit','checkGen3ArrowWarp', + 'checkGen4EntranceWarp','checkEdgeExit','checkLedgeHop','checkBoulderPush','checkGen3Gate', + 'checkAcroSideJump','canCollisionWarp','checkGen2Stairs','orbitalGroundFor','frlgPullDownHere'})do + ow[name]=function()return false end + end + function ow:walkDirection(dir)return dir end + function ow:handleInput()self.inputs=self.inputs+1;return OW.handleInput(self)end + function ow:noteRunGate()end + Game.data={maps={TEST=map.def},field={},constants={world={runStepFrames=8},gen3RunningShoesFlag=1}} + Game.save={player={},flags={FLAG_G3_0001=true},party={},onBike=false} + Game.input=input;Game.overworld=ow;Game.stack={states={ow}} + return {game=Game,world=ow,player=player,input=input,map=map} +end +return F From b2f56730f511d09c1e91c3f69ca4f7fc6647687f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 18:48:16 +0100 Subject: [PATCH 02/10] Reject native actors retained from an earlier imported dataset --- src/compat/gen1/NativeSession.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua index 0adebf322..a81e9a5fa 100644 --- a/src/compat/gen1/NativeSession.lua +++ b/src/compat/gen1/NativeSession.lua @@ -23,6 +23,10 @@ function M.resolve(game) if not (world and world.isOverworld and world.player and world.map) then return nil, 'no active native player' end + if not (game.data and game.data.maps and world.map.def + and game.data.maps[world.map.id] == world.map.def) then + return nil, 'native world belongs to a retired dataset' + end return game, world, world.player end function M.acceptsInput(game, world, player) From 15adc6b48b1334f76963f1277426061f264be3f3 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 20:33:14 +0100 Subject: [PATCH 03/10] Resolve native data before player initialization for resource preparation --- src/compat/gen1/NativeSession.lua | 15 ++++++++++++++- tools/player-runtime/check.lua | 13 +++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua index a81e9a5fa..4bc3d12fd 100644 --- a/src/compat/gen1/NativeSession.lua +++ b/src/compat/gen1/NativeSession.lua @@ -1,7 +1,7 @@ -- Resolve the live owner without bootstrapping Game or keeping a retired world. local Version = require('src.core.GameVersion') local M = {} -function M.resolve(game) +function M.game(game) local facade = package.loaded['src.core.Game3'] if type(facade) == 'table' and type(facade.native) == 'function' then game = facade.native(game) @@ -11,6 +11,19 @@ function M.resolve(game) if type(game) ~= 'table' or not info or info.generation ~= 3 then return nil, 'no native GBA session' end + return game +end +function M.data(game) + local owner,why=M.game(game);if not owner then return nil,why end + local data=owner.data + if type(data)~='table'or type(data.tilesets)~='table'or type(data.map_tilesets)~='table' + or type(data.constants)~='table'or type(data.constants.gen3Layout)~='table'then + return nil,'native GBA tileset data unavailable' + end + return owner,data +end +function M.resolve(game) + local why;game,why=M.game(game);if not game then return nil,why end local states = game.stack and game.stack.states local world if type(states) == 'table' then diff --git a/tools/player-runtime/check.lua b/tools/player-runtime/check.lua index 882de897d..3d76c14fd 100644 --- a/tools/player-runtime/check.lua +++ b/tools/player-runtime/check.lua @@ -7,6 +7,19 @@ local base=P.update T.eq(package.loaded['src.core.Game'],nil,'import does not bootstrap Game') T.eq(P.cellX,nil,'there is no synthetic player before a session') T.eq(P.tick(),nil,'out-of-phase ticks cannot run a second simulation') +-- Atlas preparation can resolve data before an overworld/player exists. +local Session=require('src.compat.gen1.NativeSession') +require('src.core.GameVersion').set('firered') +local early={data={tilesets={},map_tilesets={},constants={gen3Layout={}}}} +T.eq(Session.game(early),early,'prewarm resolves explicit native owner') +local owner,data=Session.data(early) +T.eq(owner,early);T.eq(data,early.data) +T.eq(Session.resolve(early),nil,'prewarm cannot invent a player') +early.data.map_tilesets=nil +T.eq(Session.data(early),nil,'incomplete imports decline before resource preparation') +T.eq(package.loaded['src.core.Game'],nil,'data observation never bootstraps Game') +require('src.core.GameVersion').set('gold') +T.eq(Session.data(early),nil,'other generations never expose GBA resources') for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do if require('src.core.GameVersion').info(edition) then local f=F.new(edition);local w,p,g=f.world,f.player,f.game From 0ab4c26a8c7d628ec440cd1d1f524ca7c2532b6d Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 21:40:41 +0100 Subject: [PATCH 04/10] Reject explicitly retired game facades before native owner fallback --- src/compat/gen1/NativeSession.lua | 2 ++ tools/player-runtime/check.lua | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua index 4bc3d12fd..3382fd8f7 100644 --- a/src/compat/gen1/NativeSession.lua +++ b/src/compat/gen1/NativeSession.lua @@ -2,9 +2,11 @@ local Version = require('src.core.GameVersion') local M = {} function M.game(game) + local explicit = game ~= nil local facade = package.loaded['src.core.Game3'] if type(facade) == 'table' and type(facade.native) == 'function' then game = facade.native(game) + if explicit and game == nil then return nil, 'native Game3 facade retired' end end game = game or package.loaded['src.core.Game'] local info = Version.info(Version.get()) diff --git a/tools/player-runtime/check.lua b/tools/player-runtime/check.lua index 3d76c14fd..817b3cdb4 100644 --- a/tools/player-runtime/check.lua +++ b/tools/player-runtime/check.lua @@ -115,4 +115,11 @@ for _,edition in ipairs({'gold','silver','crystal'})do f.world:stepBody(false);T.eq(calls,0,edition..' keeps its native player phase') P.update=base;P.release() end +-- An explicitly retired facade must never resolve to the currently loaded Game. +local previousFacade=package.loaded['src.core.Game3'];local retired={} +package.loaded['src.core.Game3']={native=function(value)if value~=retired then return value end end} +require('src.core.GameVersion').set('firered') +T.eq(Session.game(retired),nil,'explicit retired owner cannot fall through to the current native Game') +T.eq(Session.game(package.loaded['src.core.Game']),package.loaded['src.core.Game']) +package.loaded['src.core.Game3']=previousFacade T.finish('native compatibility player runtime') From 02afbab599eed94d8e98d6a99af33848b9471278 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Mon, 26 Oct 2026 01:30:07 +0100 Subject: [PATCH 05/10] Expose the actual entering native world through scoped observations --- src/compat/gen1/NativeSession.lua | 18 +++++++++++++++--- tools/player-runtime/entry-scope-check.lua | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tools/player-runtime/entry-scope-check.lua diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua index 3382fd8f7..21df9e2e2 100644 --- a/src/compat/gen1/NativeSession.lua +++ b/src/compat/gen1/NativeSession.lua @@ -1,6 +1,18 @@ -- Resolve the live owner without bootstrapping Game or keeping a retired world. local Version = require('src.core.GameVersion') local M = {} +local entering=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.withWorld(game,world,fn,...) + local owner,why=M.game(game);assert(owner,why) + assert(type(world)=='table'and world.isOverworld and world.map and world.player + and owner.data and owner.data.maps and owner.data.maps[world.map.id]==world.map.def,'foreign native world scope') + local previous=entering[owner];entering[owner]=world + local result=pack(pcall(fn,...));entering[owner]=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end function M.game(game) local explicit = game ~= nil local facade = package.loaded['src.core.Game3'] @@ -27,12 +39,12 @@ end function M.resolve(game) local why;game,why=M.game(game);if not game then return nil,why end local states = game.stack and game.stack.states - local world - if type(states) == 'table' then + local world=entering[game] + if not world and type(states) == 'table' then for i = #states, 1, -1 do if states[i].isOverworld then world = states[i]; break end end - else + elseif not world then world = game.overworld end if not (world and world.isOverworld and world.player and world.map) then diff --git a/tools/player-runtime/entry-scope-check.lua b/tools/player-runtime/entry-scope-check.lua new file mode 100644 index 000000000..6780a2a9d --- /dev/null +++ b/tools/player-runtime/entry-scope-check.lua @@ -0,0 +1,22 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Native=require('src.compat.gen1.NativeSession') +T.eq(package.loaded['src.core.Game'],nil) +love=require('tests.love_stub') +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local f=require('tools.player-runtime.fixture').new(edition) + f.game.stack={states={}} + local nested={isOverworld=true,map=f.map,player=f.player} + local first,second,third=Native.withWorld(f.game,f.world,function() + T.eq(select(2,Native.resolve()),f.world) + Native.withWorld(f.game,nested,function()T.eq(select(2,Native.resolve()),nested)end) + T.eq(select(2,Native.resolve()),f.world) + return 1,nil,3 + end) + T.eq(first,1);T.eq(second,nil);T.eq(third,3) + T.eq(Native.resolve(),nil,'entry scope ends before the world is published') + T.raises(function()Native.withWorld(f.game,f.world,function()error('entry failed')end)end,'entry failed') + T.eq(Native.resolve(),nil,'failed entry cannot leak a native-world scope') + T.raises(function()Native.withWorld(f.game,{isOverworld=true,map={id='OTHER',def={}},player=f.player},function()end)end,'foreign native world') +end +T.finish('native entry-world scopes') From 9947962b938feaff40d7eb9a00afee553b6cbfb9 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Mon, 26 Oct 2026 01:30:29 +0100 Subject: [PATCH 06/10] Keep standalone entry-scope checks independent of cartridge registrations --- tools/player-runtime/entry-scope-check.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/player-runtime/entry-scope-check.lua b/tools/player-runtime/entry-scope-check.lua index 6780a2a9d..e4d2197f1 100644 --- a/tools/player-runtime/entry-scope-check.lua +++ b/tools/player-runtime/entry-scope-check.lua @@ -3,7 +3,7 @@ local T=require('tests.harness') local Native=require('src.compat.gen1.NativeSession') T.eq(package.loaded['src.core.Game'],nil) love=require('tests.love_stub') -for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do +for _,edition in ipairs({'firered','emerald'})do local f=require('tools.player-runtime.fixture').new(edition) f.game.stack={states={}} local nested={isOverworld=true,map=f.map,player=f.player} From 4e528bdb7ad7e6eded0ca74e6e5502024e8f3f63 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:31:31 +0100 Subject: [PATCH 07/10] Resolve admitted persistent worlds without giving covering UI input away --- docs/native-session-world-admission.md | 19 ++++++++ src/compat/gen1/NativeSession.lua | 8 +++- .../native_session_persistent_world.lua | 47 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 docs/native-session-world-admission.md create mode 100644 tests/engine/native_session_persistent_world.lua diff --git a/docs/native-session-world-admission.md b/docs/native-session-world-admission.md new file mode 100644 index 000000000..55b734c2e --- /dev/null +++ b/docs/native-session-world-admission.md @@ -0,0 +1,19 @@ +# Persistent native world admission + +Native session observations resolve an explicit entry-world scope first, +then a world stored in the physical state list, then that stack's admitted +`worldState`. A persistent world is independent of the overlay list; an empty +list or a covering menu does not remove its map/player observations. + +Input still belongs to the native stack's active state. An uncovered +persistent world accepts input; a covering menu keeps it, including when +that menu is hidden only for rendering. Dataset/map identity checks remain +required. A modern empty stack cannot revive an unadmitted `game.overworld` +pointer. Legacy tool hosts without a physical state list keep their explicit +world fallback. + +The five-edition regression reproduces 50 failures before the correction and +passes 150 checks afterwards. It uses the actual native StateStack and player +with synthetic map data, including native field-move entry/retirement over a +persistent world, covering UI, stale datasets and failed entry scopes. This +does not establish original mod initialization, full rendering or gameplay. diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua index 21df9e2e2..5d4b2a35f 100644 --- a/src/compat/gen1/NativeSession.lua +++ b/src/compat/gen1/NativeSession.lua @@ -44,6 +44,9 @@ function M.resolve(game) for i = #states, 1, -1 do if states[i].isOverworld then world = states[i]; break end end + -- The physical list can contain overlays only. Its persistent world is + -- still a real native admission, independently of the convenience pointer. + if not world then world=game.stack.worldState end elseif not world then world = game.overworld end @@ -60,7 +63,10 @@ function M.acceptsInput(game, world, player) local owner, current, actor = M.resolve(game) if owner ~= game or current ~= world or actor ~= player then return false end local states = game.stack and game.stack.states - if states and states[#states] ~= world then return false end + if type(states)=='table' then + local active=type(game.stack.active)=='function' and game.stack:active() or states[#states] + if active~=world then return false end + end return not world.battleActive and not (world.busy and world:busy()) end return M diff --git a/tests/engine/native_session_persistent_world.lua b/tests/engine/native_session_persistent_world.lua new file mode 100644 index 000000000..a4550fdec --- /dev/null +++ b/tests/engine/native_session_persistent_world.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 Session=require('src.compat.gen1.NativeSession') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local fixture=require('tools.session-runtime.fixture').new(edition) + local game,world,player=fixture.game,fixture.world,fixture.player + world.busy=function()return false end + local g,w,p=Session.resolve();T.eq(g,game);T.eq(w,world);T.eq(p,player) + T.eq(Session.acceptsInput(game,world,player),true,'ordinary native stack remains eligible') + local stack=game.stack;stack.states={};stack:setWorldOwner({}) + world.enter=false -- avoid importing a map in this native admission fixture + stack:push(world) + T.eq(#stack.states,0,'persistent world is not a physical overlay') + T.eq(stack.worldState,world);T.eq(stack:active(),world) + g,w,p=Session.resolve() + T.eq(g,game,'persistent native world resolves the current game') + T.eq(w,world,'the admitted persistent world supplies observations') + T.eq(p,player,'persistent world resolves the actual native player') + T.eq(Session.acceptsInput(game,world,player),true,'active persistent world accepts native input') + local cover={};stack:push(cover) + g,w,p=Session.resolve();T.eq(g,game);T.eq(w,world);T.eq(p,player) + T.eq(Session.acceptsInput(game,world,player),false,'a covering native UI still owns input') + stack:pop();T.eq(Session.acceptsInput(game,world,player),true) + game.data.pokemon={[1]={spriteFront='assets/generated/persistent-world-fixture.png'}} + local show=require('src.core.game3.field_move_show_mon') + T.eq(show.start(1,{pose=true}),true,'native field-move requests reach an admitted persistent world') + T.eq(stack.worldState,world);T.eq(#stack.states,1) + T.eq(player.fieldMove,true);show.reset() + T.eq(stack.worldState,world);T.eq(#stack.states,0) + T.eq(Session.acceptsInput(game,world,player),true,'retiring a native cut-in returns input to the persistent world') + game.overworld={isOverworld=true,player={},map=fixture.map} + T.eq(select(2,Session.resolve()),world,'an unadmitted convenience pointer cannot replace the native world') + game.overworld=world + local original=game.data + game.data={maps={TEST={}},constants={}} + T.eq(Session.resolve(),nil,'persistent worlds from a retired dataset remain unavailable') + T.eq(Session.acceptsInput(game,world,player),false) + game.data=original;stack.worldState=nil + T.eq(Session.resolve(),nil,'a modern empty stack cannot revive an unadmitted overworld pointer') + stack.worldState=world + T.raises(function()Session.withWorld(game,world,function()error('scope probe')end)end,'scope probe','native world scopes restore after failure') + T.eq(select(2,Session.resolve()),world) + game.stack=nil + T.eq(select(2,Session.resolve()),world,'legacy tool hosts without a state list retain their explicit world fallback') +end +T.finish('native persistent-world resolution and input ownership') From df0da96f0142cfa96cee6c446f4d46424e313415 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:24:57 +0100 Subject: [PATCH 08/10] Preserve contribution credit and upstream integration permission --- LICENSE-ROXAS2712.md | 88 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 LICENSE-ROXAS2712.md 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. From 1146e6060e1add0c28552aad0e3fceaef548f575 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:36:26 +0100 Subject: [PATCH 09/10] Keep persistent-world qualification explicit about its required native host --- docs/native-session-world-admission.md | 7 +++++++ .../session-runtime/persistent-world-check.lua | 0 2 files changed, 7 insertions(+) rename tests/engine/native_session_persistent_world.lua => tools/session-runtime/persistent-world-check.lua (100%) diff --git a/docs/native-session-world-admission.md b/docs/native-session-world-admission.md index 55b734c2e..247ed2afd 100644 --- a/docs/native-session-world-admission.md +++ b/docs/native-session-world-admission.md @@ -17,3 +17,10 @@ passes 150 checks afterwards. It uses the actual native StateStack and player with synthetic map data, including native field-move entry/retirement over a persistent world, covering UI, stale datasets and failed entry scopes. This does not establish original mod initialization, full rendering or gameplay. + +The manual `tools/session-runtime/persistent-world-check.lua` requires the +native persistent-world StateStack protocol, the session fixture and native +field-move presentation. The current upstream API-2 prerequisite composition +does not yet provide that StateStack protocol. Its ordinary physical-stack +path is qualified separately; the 150 persistent-world checks above refer +to the composed native fork, not that older host. diff --git a/tests/engine/native_session_persistent_world.lua b/tools/session-runtime/persistent-world-check.lua similarity index 100% rename from tests/engine/native_session_persistent_world.lua rename to tools/session-runtime/persistent-world-check.lua From 9c7df0482f7eb4e097d5bc154a2c2d1877aa68ff Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:42:30 +0100 Subject: [PATCH 10/10] Keep native persistent-world test counts scoped to the expanded fork suite --- docs/native-session-world-admission.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/native-session-world-admission.md b/docs/native-session-world-admission.md index 247ed2afd..a1a4d5e00 100644 --- a/docs/native-session-world-admission.md +++ b/docs/native-session-world-admission.md @@ -12,8 +12,8 @@ required. A modern empty stack cannot revive an unadmitted `game.overworld` pointer. Legacy tool hosts without a physical state list keep their explicit world fallback. -The five-edition regression reproduces 50 failures before the correction and -passes 150 checks afterwards. It uses the actual native StateStack and player +The initial five-edition regression reproduced 50 failed assertions. The +expanded suite passes 150 checks. It uses the actual native StateStack and player with synthetic map data, including native field-move entry/retirement over a persistent world, covering UI, stale datasets and failed entry scopes. This does not establish original mod initialization, full rendering or gameplay.