From e157d273bb5cc951b2e6cc12f338c7aad18289a0 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 01:12:21 +0200 Subject: [PATCH 01/34] Keep native Gen2 world ownership below the overlay stack --- src/core/Game.lua | 75 ++++++++++++++++------------ src/core/StateStack.lua | 54 +++++++++++++++++++- src/world/OverworldController.lua | 27 +++++++++- src/world/WorldAPI.lua | 1 + tests/engine/gen2_menu_readiness.lua | 19 +++++++ tests/engine/gen2_world_stack.lua | 42 ++++++++++++++++ 6 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 tests/engine/gen2_menu_readiness.lua create mode 100644 tests/engine/gen2_world_stack.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..bfbbaa255 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -17,6 +17,13 @@ local Screens = require("src.ui.Screens") local Game = {} +-- Internal native input includes the persistent world when no overlay owns it. +local function activeTop(stack) + if not stack then return nil end + return stack.active and stack:active() or stack:top() +end + + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -98,6 +105,7 @@ function Game:load() self.stack = StateStack StateStack:init() + if require("src.core.GameVersion").isGen2() then StateStack:setWorldOwner(self) end self.save = SaveData.newGame(self:bootConfig()) -- seed=true keeps what entry chunks wrote through mod.save before any @@ -239,6 +247,7 @@ function Game:makeTitleState() local title = factory.new(self, { onNewGame = function() while self.stack:top() do self.stack:pop() end + if self.stack.clearWorld then self.stack:clearWorld() end if self.overworld then self.overworld.fadeOverlay = nil end -- New Game keeps the standalone options.lua preferences self.save = SaveData.newGame(self:bootConfig()) @@ -299,6 +308,7 @@ end function Game:returnToTitle() require("src.core.Music").stop() while self.stack:top() do self.stack:pop() end + if self.stack.clearWorld then self.stack:clearWorld() end -- THE OVERWORLD OUTLIVES THE STACK, and a fade it had up is popped with -- everything else while `fadeOverlay` still names it. The next script fade -- then found an overlay, did not push it, and waited on a screen that never @@ -454,7 +464,7 @@ local function lookPointerWanted(self) -- player's camera was set to first or third person if self.launcherPreview then return false end if not self:freeView() then return false end - if (self.stack and self.stack:top()) ~= self.overworld then return false end + if (self.stack and activeTop(self.stack)) ~= self.overworld then return false end if love.window and love.window.hasFocus then local ok, focused = pcall(love.window.hasFocus) return ok and focused or false @@ -588,7 +598,7 @@ end function Game:update(dt) require('src.pokemon.Gen4PoketchState').tick(self, dt) local panel=self.secondScreenPointerOwner - local top=self.stack and self.stack.top and self.stack:top() + local top=self.stack and self.stack.top and activeTop(self.stack) if panel and panel~=top and panel.updatePresentation then panel:updatePresentation(dt) end @@ -1005,15 +1015,16 @@ function Game:toggleFrameProfile() end function Game:_draw() + local scene = self.stack.sceneStack and self.stack:sceneStack() or self.stack -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear - local base = self.stack:visibleBase() - local worldBelow = self.stack.states[base] == self.overworld + local base = scene:visibleBase() + local worldBelow = scene.states[base] == self.overworld -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's - local drawFrom = Game.drawBaseInStack(self.stack, base) - local worldDrawn = self.stack.states[drawFrom] == self.overworld + local drawFrom = Game.drawBaseInStack(scene, base) + local worldDrawn = scene.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay @@ -1069,8 +1080,8 @@ function Game:_draw() Renderer.uiCentered = not Game.dynamicUI(self.save) Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) Renderer:beginFrame(worldBelow) - for i = drawFrom, #self.stack.states do - local state = self.stack.states[i] + for i = drawFrom, #scene.states do + local state = scene.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() -- A STATE THAT ASKED FOR THIS SURFACE DRAWS IN IT DIRECTLY; everything @@ -1112,8 +1123,8 @@ function Game:_draw() -- screen (overlays like text boxes inherit from what's beneath them); -- the overworld's world pass colors each visible map area separately local zones, worldZones, zoneOwner - for i = #self.stack.states, 1, -1 do - local s = self.stack.states[i] + for i = #scene.states, 1, -1 do + local s = scene.states[i] if s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s @@ -1182,9 +1193,9 @@ end -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out function Game:zoomStep(delta) local view=self:zoomView() - if view and self.stack:top()==self.overworld then view:zoomBy(-delta);return end + if view and activeTop(self.stack)==self.overworld then view:zoomBy(-delta);return end local Zoom = require("src.render.Zoom") - if not Zoom.gateOK(self.stack:top(), self.overworld) then return end + if not Zoom.gateOK(activeTop(self.stack), self.overworld) then return end local offset = Zoom.step(delta, Renderer:fitScale()) if self.save and self.save.options then self.save.options.zoom = offset @@ -1241,7 +1252,8 @@ end function Game:ensureStack() local stack = self.stack if not (stack and stack.states) or stack.states[1] ~= nil then return false end - local world = self.overworld + if stack.worldState and stack.worldState.map then return false end + local world = not stack.worldOwner and self.overworld if world and world.map then Logger.warn("the state stack was emptied and nothing pushed anything back " .. "-- with no state there is nothing to update and nothing to draw, " @@ -1263,7 +1275,7 @@ end -- stacking another. This is the route to turning a misbehaving mod OFF, which -- is why it must not be swallowable. function Game:toggleModManager() - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if top and top.screenId == "ManagerState" then self.stack:pop() else @@ -1290,7 +1302,7 @@ function Game:unstick() local stack = self.stack if not (stack and stack.states) then return end Logger.warn("unstick (F9): %s", stack:describeAll()) - local top = stack:top() + local top = activeTop(stack) if not top then Logger.warn("unstick: the stack is empty; nothing to pop") Logger.flush() @@ -1349,8 +1361,8 @@ function Game:keypressed(key) if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end if key == "f10" then return self:toggleModManager() end - if self.stack and self.stack:top() and self.stack:top().onKeyPressed then - self.stack:top():onKeyPressed(key) + if self.stack and activeTop(self.stack) and activeTop(self.stack).onKeyPressed then + activeTop(self.stack):onKeyPressed(key) return end if devMode and key == "f5" then @@ -1365,7 +1377,7 @@ function Game:keypressed(key) -- QUICK SAVE ONLY FROM FREE ROAM. Mid-battle (or under any menu or -- cutscene) the save table is half way through a change, and a frozen -- battle is exactly when a player mashes keys. - if self.overworld and self.stack:top() == self.overworld then + if self.overworld and activeTop(self.stack) == self.overworld then self:writeSave() end return @@ -1385,7 +1397,7 @@ function Game:keypressed(key) -- hold it while a warp/transition or an on-screen scripted cutscene is -- driving the overworld rather than tear the escort's NPCs out mid-move local ow = self.overworld - local top = self.stack:top() + local top = activeTop(self.stack) local busy = ow and (ow.transitioning or (top == ow and ( (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) @@ -1400,7 +1412,7 @@ function Game:keypressed(key) elseif key == "3" then -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only local Tilt = require("src.render.Tilt") - if Tilt.gateOK(self.stack:top(), self.overworld) then + if Tilt.gateOK(activeTop(self.stack), self.overworld) then -- ON A SINNOH MAP THIS KEY MOVES THE GEN 4 CAMERA INSTEAD. -- -- There are two tilts in this engine and they are not alternatives: the @@ -1449,7 +1461,7 @@ function Game:keypressed(key) elseif key == "4" then -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) local Zoom = require("src.render.Zoom") - if Zoom.gateOK(self.stack:top(), self.overworld) then + if Zoom.gateOK(activeTop(self.stack), self.overworld) then self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) self:writeOptions() end @@ -1468,7 +1480,7 @@ function Game:keypressed(key) -- pipelines). syncOptions writes the whole ladder back, including the -- tilt exclusion a world pipeline forces. local Pipelines = require("src.render.Pipelines") - if Pipelines.hotkey(key, self.stack:top(), self.overworld) then + if Pipelines.hotkey(key, activeTop(self.stack), self.overworld) then Pipelines.syncOptions(self.save.options) require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() @@ -1478,7 +1490,7 @@ function Game:keypressed(key) end function Game:textinput(text) - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if top and top.onTextInput then top:onTextInput(text) end @@ -1512,7 +1524,7 @@ end -- commit a capture on the key-up (#589). function Game:keyreleased(key) Input:keyreleased(key) - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if top and top.onKeyReleased then top:onKeyReleased(key) end end @@ -1521,7 +1533,7 @@ function Game:gamepadpressed(joystick, button) -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() -- BindingsMenu's pad capture rides the same top-state routing as keys - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if top and top.onGamepadPressed then top:onGamepadPressed(button) return @@ -1532,7 +1544,7 @@ end function Game:gamepadreleased(joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if top and top.onGamepadReleased then top:onGamepadReleased(button) end end @@ -1542,7 +1554,7 @@ function Game:gamepadaxis(joystick, axis, value) -- the triggers and the right stick as bindable pseudo-buttons (Input -- AXIS_BUTTONS): a press goes to BindingsMenu's capture when one is armed, -- the same routing as a real pad button - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) for _, t in ipairs(Input:axisTransitions(axis, value)) do local name, down = t[1], t[2] if down then @@ -1584,7 +1596,7 @@ end function Game:joystickpressed(joystick, button) if isAccelerometer(joystick) then return end TouchControls:noteGamepad() - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if isRawStick(joystick) and top and top.onJoystickPressed then top:onJoystickPressed(button) return @@ -1598,7 +1610,7 @@ function Game:joystickreleased(joystick, button) -- capture watches the release, it never owns it, so a held-state flag -- Input saw go down before the capture armed cannot be stranded Input:joystickreleased(joystick, button) - local top = self.stack and self.stack:top() + local top = self.stack and activeTop(self.stack) if isRawStick(joystick) and top and top.onJoystickReleased then top:onJoystickReleased(button) end @@ -1726,7 +1738,7 @@ local function offerPointer(self, method, id, x, y) if method=='touchreleased' then self.pointerOwners[id]=nil end return true end - local top = self.stack and self.stack.top and self.stack:top() + local top = self.stack and self.stack.top and activeTop(self.stack) if offer(top) then return true end -- The always-present Android lower-panel watch is drawn outside the stack. -- Its coordinate mapper rejects main-window input in display mode. @@ -1752,7 +1764,7 @@ function Game:hasPointerScreen() -- (e.g., Poketch visible but not actively interacting with UI) if self:freeView() then return false end if self.pointerOwners and self.pointerOwners.mouse then return true end - local top=self.stack and self.stack.top and self.stack:top() + local top=self.stack and self.stack.top and activeTop(self.stack) return top and type(top.touchpressed)=='function' or false end @@ -1981,6 +1993,7 @@ function Game:restoreSave(loaded, recovered) -- rebuild the state stack from the save (and drop a fade the overworld -- still names from before -- see returnToTitle) while self.stack:top() do self.stack:pop() end + if self.stack.clearWorld then self.stack:clearWorld() end if self.overworld then self.overworld.fadeOverlay = nil end self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, loaded.player.facing) diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..3ae562a17 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -81,12 +81,58 @@ local deepSaid = {} function StateStack:init() self.states = {} + self.worldState, self.worldOwner, self._sceneStack = nil, nil, nil end +-- Gen2's persistent world is not a screen. The public stack contains only +-- overlays; the native update and scene-render paths still own that world. +function StateStack:setWorldOwner(owner) + self:clearWorld() + self.worldOwner = owner + if owner then owner.world = nil end +end + +function StateStack:clearWorld() + local world = self.worldState + self.worldState = nil + if self.worldOwner then self.worldOwner.world = nil end + if world and type(world.exit) == "function" then world:exit() end +end + +function StateStack:active() + return self:top() or self.worldState +end + +-- A scene view includes the persistent world for native composition. Its +-- states are separate from the real overlay stack: drawing the overlay stack +-- from a mod cannot re-enter the world's render pipeline recursively. +function StateStack:sceneStack() + if not self.worldState then return self end + local view = self._sceneStack + if not view then + view = { states = {}, visibleBase = StateStack.visibleBase } + view.renderVisible = function(_, state) return not self.renderVisible or self:renderVisible(state) end + self._sceneStack = view + end + local states = view.states + states[1] = self.worldState + for i, state in ipairs(self.states) do states[i + 1] = state end + for i = #self.states + 2, #states do states[i] = nil end + return view +end + + + -- screen.pushed/popped fire after enter/exit so listeners observe the -- settled state; the wants guard keeps the no-listener path allocation-free function StateStack:push(state, ...) + if self.worldOwner and state.isOverworld then + if self.worldState and self.worldState ~= state then self:clearWorld() end + self.worldState, self.worldOwner.world = state, state + if type(state.enter) == "function" then state:enter(...) end + return + end table.insert(self.states, state) if state.enter then state:enter(...) end local depth = #self.states @@ -113,7 +159,7 @@ function StateStack:pop() -- breath; everywhere else it is one pop too many, and the thing it leaves -- behind -- a screen that never changes, with no error -- gives the reader -- nothing to search for. So it is said in words, once per culprit. - if state ~= nil and #self.states == 0 then + if state ~= nil and #self.states == 0 and not self.worldState then Logger.warn("state stack: popping %s emptied the stack -- nothing will " .. "update or draw until something is pushed back", StateStack.describe(state)) @@ -139,6 +185,10 @@ end -- does not emit screen.pushed, because nothing was entered. function StateStack:restore(state) if type(state) ~= "table" then return false end + if self.worldOwner and state.isOverworld then + self.worldState, self.worldOwner.world = state, state + return true + end table.insert(self.states, state) Logger.warn("state stack: restored %s (depth %d)", StateStack.describe(state), #self.states) @@ -173,7 +223,7 @@ end -- second one on the same press) and it must not push, pop or finish. A state -- that wants both puts the motion in animate and calls it from update. function StateStack:update(dt) - local top = self:top() + local top = self:active() if top and top.update then top:update(dt) end for i = self:visibleBase(), #self.states - 1 do local state = self.states[i] diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..cca84e3ba 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -5031,6 +5031,25 @@ function OverworldState:gen3DecorateInput() return true end +-- Public Gen2 menu predicates use the native script/transition owners. They +-- do not infer readiness merely from a visible map or an empty overlay stack. +function OverworldState:scriptRunning() + return (self.runner and self.runner:isRunning()) and true or false +end + +function OverworldState:busy() + return (self:scriptRunning() or (self.scriptMoves and #self.scriptMoves > 0) + or self.engaging or self.emote or self.teleportOut or self.whirlSpin + or self.gen4Waterfall or self.transitioning or self.flyAnim or self.fishing + or (self.player and (self.player.stairExit or self.player.inputLocked)) + or (self.pendingScripts and self.pendingScripts[1])) and true or false +end + +function OverworldState:acceptsMenuInput() + return not self.battleActive and not self:busy() + and not (self.player and self.player.moving) and not self.iceSlide +end + function OverworldState:handleInput() local input = Game.input if self.gen3Decorate then return self:gen3DecorateInput() end @@ -11694,7 +11713,7 @@ end function OverworldState:checkTrainerSight() if self.player.moving or self.engaging then return end - if Game.stack:top() ~= self then return end + if (Game.stack.active and Game.stack:active() or Game.stack:top()) ~= self then return end local p = self.player -- A TRAINER WHOSE APPROACH ENDED WITHOUT A BATTLE MUST NOT RE-APPROACH -- FROM THE SAME TILE. @@ -16069,6 +16088,12 @@ end -- -- Zoom is folded in, so this tracks the survey/diorama ladder rather than the -- fixed fit scale. Returns nil before the renderer exists (headless, boot). +-- Screen units per native UI pixel, matching the renderer's DPI conversion. +function OverworldState:fitScale() + if not (Game and Game.renderer and Game.renderer.drawScale) then return nil end + return Game.renderer:drawScale() +end + function OverworldState:zoomScale() if not (Game and Game.renderer and Game.renderer.fitScale) then return nil end local ok, fit = pcall(Game.renderer.fitScale, Game.renderer) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..4a95dc807 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -25,6 +25,7 @@ end function WorldAPI:overworld() local game = self.game local stack = game and game.stack + if stack and stack.worldOwner then return stack.worldState end local states = stack and stack.states if states then for i = #states, 1, -1 do diff --git a/tests/engine/gen2_menu_readiness.lua b/tests/engine/gen2_menu_readiness.lua new file mode 100644 index 000000000..cb08f244e --- /dev/null +++ b/tests/engine/gen2_menu_readiness.lua @@ -0,0 +1,19 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local World=require('src.world.OverworldController') +local running=false +local w=setmetatable({runner={isRunning=function()return running end},scriptMoves={},player={}}, {__index=World}) +T.eq(w:scriptRunning(),false,'idle script owner') +T.eq(w:busy(),false,'idle world not busy');T.eq(w:acceptsMenuInput(),true,'idle world accepts menu') +running=true;T.eq(w:scriptRunning(),true,'actual native runner owns script');T.eq(w:acceptsMenuInput(),false,'scripts block menu');running=false +for _,key in ipairs({'engaging','emote','teleportOut','whirlSpin','gen4Waterfall','transitioning','flyAnim','fishing'})do + w[key]={};T.eq(w:busy(),true,key..' busy');T.eq(w:acceptsMenuInput(),false,key..' blocks menu');w[key]=nil +end +w.scriptMoves[1]={};T.eq(w:acceptsMenuInput(),false,'script movement blocks menu');w.scriptMoves={} +w.pendingScripts={{}};T.eq(w:acceptsMenuInput(),false,'pending map setup blocks menu');w.pendingScripts=nil +w.player.moving=true;T.eq(w:busy(),false,'ordinary step is not a script');T.eq(w:acceptsMenuInput(),false,'mid-step menu deferred');w.player.moving=nil +w.player.inputLocked=true;T.eq(w:acceptsMenuInput(),false,'input owner lock retained');w.player.inputLocked=nil +w.iceSlide='left';T.eq(w:acceptsMenuInput(),false,'ice slide blocks menu');w.iceSlide=nil +w.battleActive=true;T.eq(w:acceptsMenuInput(),false,'battle blocks world menus');w.battleActive=nil +T.eq(w:acceptsMenuInput(),true,'readiness recovers after owners release') +T.finish('native Gen2 menu readiness') diff --git a/tests/engine/gen2_world_stack.lua b/tests/engine/gen2_world_stack.lua new file mode 100644 index 000000000..66dad0968 --- /dev/null +++ b/tests/engine/gen2_world_stack.lua @@ -0,0 +1,42 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Stack=require('src.core.StateStack') +local Game=require('src.core.Game') +local WorldAPI=require('src.world.WorldAPI') +local function stack()local s=setmetatable({}, {__index=Stack});s:init();return s end +local s=stack();local owner={};s:setWorldOwner(owner) +local entered,exited,updated,worldDraws,overlayDraws=0,0,0,0,0 +local world={isOverworld=true,isOpaque=true,map={id='NEW_BARK_TOWN'}, + enter=function(_,id,x)entered=entered+1;T.eq(id,'NEW_BARK_TOWN','world entry map');T.eq(x,6,'world entry position')end, + exit=function()exited=exited+1 end,update=function()updated=updated+1 end,draw=function()worldDraws=worldDraws+1 end} +owner.overworld=world;owner.stack=s +s:push(world,'NEW_BARK_TOWN',6) +T.eq(owner.world,world,'engine owns live world') +T.eq(s:top(),nil,'free roam has no overlay') +T.eq(#s.states,0,'world not stored as a menu') +T.eq(s:active(),world,'native input still has active world') +T.eq(WorldAPI.new(owner,'probe'):overworld(),world,'public world query resolves persistent world') +s:update(1/60);T.eq(updated,1,'free roam ticks once') +s:draw();T.eq(worldDraws,0,'drawing overlays never recursively draws world') +local scene=s:sceneStack();T.eq(scene.states[1],world,'native scene contains world') +T.eq(scene:visibleBase(),1,'world is scene base');T.eq(Game.ensureStack(owner),false,'empty overlay stack is healthy') +local overlay={draw=function()overlayDraws=overlayDraws+1 end,update=function()end} +s:push(overlay);T.eq(s:top(),overlay,'menu owns public stack') +s:update(1/60);T.eq(updated,1,'menu blocks world input and movement') +s:draw();T.eq(overlayDraws,1,'overlay rendered once');T.eq(worldDraws,0,'overlay rendering excludes world') +scene=s:sceneStack();T.eq(#scene.states,2,'native scene contains both layers');T.eq(scene:visibleBase(),1,'transparent menu keeps world below') +local opaque={isOpaque=true};s:push(opaque);scene=s:sceneStack() +T.eq(scene:visibleBase(),3,'opaque screen suppresses world in native draw') +s:pop();s:pop();T.eq(s:top(),nil,'closing menus returns to free roam') +s:update(1/60);T.eq(updated,2,'world resumes after menu') +scene=s:sceneStack();T.eq(#scene.states,1,'scene view sheds popped states') +s:restore(world);T.eq(entered,1,'restoring persistent world never re-enters map') +s:clearWorld();T.eq(owner.world,nil,'title/restore can release world') +T.eq(s:active(),nil,'released world cannot read input');T.eq(exited,1,'world exits exactly once') +T.eq(WorldAPI.new(owner,'probe'):overworld(),nil,'title cannot expose stale overworld') +local legacy=stack();legacy:push(world,'NEW_BARK_TOWN',6) +T.eq(legacy:top(),world,'legacy generations retain original stack contract') +T.eq(legacy:sceneStack(),legacy,'legacy scene has no adapter') +legacy:draw();T.eq(worldDraws,1,'legacy stack still draws world normally') +legacy:pop();T.eq(exited,2,'legacy world exit retained') +T.finish('native Gen2 world and overlay ownership') From c695161317a4d50d371127d99037402958f6f86d Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 03:47:29 +0100 Subject: [PATCH 02/34] Expose native Gen2 movement state and consumed actor/input phases --- src/world/Gen2PlayerState.lua | 44 +++++++++++ src/world/OverworldController.lua | 103 ++++++++++++++++++++------ tests/engine/gen2_movement_phases.lua | 75 +++++++++++++++++++ tests/engine/gen2_player_state.lua | 72 ++++++++++++++++++ 4 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 src/world/Gen2PlayerState.lua create mode 100644 tests/engine/gen2_movement_phases.lua create mode 100644 tests/engine/gen2_player_state.lua diff --git a/src/world/Gen2PlayerState.lua b/src/world/Gen2PlayerState.lua new file mode 100644 index 000000000..db5bae93e --- /dev/null +++ b/src/world/Gen2PlayerState.lua @@ -0,0 +1,44 @@ +-- Gen2's public movement names over the native mover/save flags. This is +-- derived on demand: scripted surf/bike changes must not leave a second, +-- copied movement byte behind for observers. +local State = {} + +State.PLAYER_NORMAL = "normal" +State.PLAYER_BIKE = "bike" +State.PLAYER_SURF = "surf" +State.PLAYER_SURF_PIKA = "surf_pika" + +function State.isBiking(state) + return state == State.PLAYER_BIKE +end + +function State.isSurfing(state) + return state == State.PLAYER_SURF or state == State.PLAYER_SURF_PIKA +end + +function State.read(player, save) + if not player or not save then return nil end + if player.surfing then + return player.surfingPikachu and State.PLAYER_SURF_PIKA or State.PLAYER_SURF + end + if save.onBike then return State.PLAYER_BIKE end + return State.PLAYER_NORMAL +end + +-- Low-level state adoption, like the reference's applyPlayerState. Native +-- action eligibility, animations and music still belong to their callers. +function State.apply(player, save, state) + state = state or State.PLAYER_NORMAL + if state ~= State.PLAYER_NORMAL and state ~= State.PLAYER_BIKE + and not State.isSurfing(state) then + return nil, "unknown Gen2 player state" + end + if not player or not save then return nil, "no native player/save" end + player.surfing = State.isSurfing(state) + player.surfingPikachu = state == State.PLAYER_SURF_PIKA + save.onBike = State.isBiking(state) + player.onBike = save.onBike + return true +end + +return State diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index cca84e3ba..848a6329b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -68,6 +68,34 @@ local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +function OverworldState:movementState() + if not GameVersion.isGen2() then return nil end + return require("src.world.Gen2PlayerState").read(self.player, Game and Game.save) +end + +function OverworldState:applyPlayerState(state) + if not GameVersion.isGen2() then return nil, "not Gen2" end + return require("src.world.Gen2PlayerState").apply(self.player, Game and Game.save, state) +end + +-- This engine's live world is the module itself (StateStack.push enters it +-- directly). A computed property keeps every native script/warp change +-- observable immediately without maintaining another saved state field. +setmetatable(OverworldState, { + __index = function(world, key) + if key == "playerState" then return world:movementState() end + end, + __newindex = function(world, key, value) + if key == "playerState" and GameVersion.isGen2() then + local ok, reason = world:applyPlayerState(value) + if not ok then error(reason, 2) end + else + rawset(world, key, value) + end + end, +}) + + -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a -- pcall frame and a package.loaded lookup for every entity on the map, sixty @@ -3962,6 +3990,33 @@ 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; outer update retains clocks, runners and camera. +function OverworldState:stepBody(scriptJustEnded) self:updateCast() local cast = self.cast or self.entities for _, npc in ipairs(self.npcs) do @@ -4199,27 +4254,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) @@ -5038,6 +5072,15 @@ function OverworldState:scriptRunning() end function OverworldState:busy() + -- Gen2's world lives below overlays. A movement wrapper runs after the + -- native body, including a START/A press that just opened a screen: it + -- must not move behind that new owner on the same tick. + if GameVersion.isGen2() then + if self.battleActive then return true end + local stack = Game and Game.stack + local top = stack and stack.top and stack:top() + if top and top ~= self then return true end + end return (self:scriptRunning() or (self.scriptMoves and #self.scriptMoves > 0) or self.engaging or self.emote or self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.transitioning or self.flyAnim or self.fishing @@ -5050,6 +5093,17 @@ function OverworldState:acceptsMenuInput() and not (self.player and self.player.moving) and not self.iceSlide end +function OverworldState:pollInput(input) + local dir + if input then + if input:isDown("up") then dir = "up" + elseif input:isDown("down") then dir = "down" + elseif input:isDown("left") then dir = "left" + elseif input:isDown("right") then dir = "right" end + end + self.heldDir = dir +end + function OverworldState:handleInput() local input = Game.input if self.gen3Decorate then return self:gen3DecorateInput() end @@ -5264,8 +5318,11 @@ function OverworldState:handleInput() return end + local gen2Intent = GameVersion.isGen2() + if gen2Intent then self:pollInput(input) end for _, pressed in ipairs({ "up", "down", "left", "right" }) do - if input:isDown(pressed) then + if (gen2Intent and self.heldDir == pressed) + or (not gen2Intent and input:isDown(pressed)) then -- WHAT THAT PRESS MEANS UNDER A SWUNG CAMERA. -- -- Everything below this line -- turning, stepping, ledges, warps, the @@ -5273,7 +5330,7 @@ function OverworldState:handleInput() -- always been handed the key that was pressed, because until there was -- a camera that could turn, those were the same thing. Rotating here -- and nowhere else means not one of them learns about cameras. - local dir = self:walkDirection(pressed) + local dir = gen2Intent and pressed or self:walkDirection(pressed) -- .Normal and .Surf both `call .CheckTile` straight after .GetAction -- and `ret c`, so the eddy pre-empts turning, stepping, ledges and -- warps alike -- and, being a bump, it never lets the player in. diff --git a/tests/engine/gen2_movement_phases.lua b/tests/engine/gen2_movement_phases.lua new file mode 100644 index 000000000..2fcf89deb --- /dev/null +++ b/tests/engine/gen2_movement_phases.lua @@ -0,0 +1,75 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local oldVersion=V.get();V.set('crystal') +local W=require('src.world.OverworldController') +local Screens=require('src.ui.Screens');local oldPush=Screens.push +local Sound=require('src.core.Sound');local oldPlay=Sound.play;Sound.play=function()end +local game={input={},data={field={forcedMovement={}}},save={flags={}},stack={states={}}} +game.stack.top=function(self)return self.states[#self.states]end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.handleInput,i);if not name then break end;if name=='Game'then slot,oldGame=i,value;break end end +assert(slot);debug.setupvalue(W.handleInput,slot,game) +local down,press={},{} +function game.input:isDown(key)return down[key]or false end +function game.input:wasPressed(key)return press[key]or false end +local calls={};local function note(x)calls[#calls+1]=x end +local w=setmetatable({map={id='ROAD',def={generation=2}},player={facing='right'},entities={},runner={isRunning=function()return false end},scriptMoves={}}, {__index=W}) +game.overworld=w +w.orbitalGroundFor=function()return nil end +w.dirHeld=function()return false end +w.walkDirection=function(_,d)note('rotate:'..d);return 'left'end +for _,name in ipairs({'checkGen2Whirlpool','checkHgssLadder','checkGen2CarpetExit','checkGen3ArrowWarp','checkGen4EntranceWarp','checkEdgeExit','checkLedgeHop','checkBoulderPush','checkGen3Gate','checkAcroSideJump'})do + w[name]=function(_,dir)note(name..':'..dir);return false end +end +w.player.tryMove=function(_,dir)note('move:'..dir);return 'moved'end +w.pollInput=function(self,input)W.pollInput(self,input);note('poll:'..tostring(self.heldDir));self.heldDir='right'end +-- Cardinal key -> wrapper's world-space direction -> real native dispatch. +down.up=true;W.handleInput(w) +T.eq(calls[1],'poll:up','actual native input path calls the overridable poll') +T.eq(calls[2],'checkGen2Whirlpool:right','terrain checks consume remapped world-space intent') +T.eq(calls[#calls],'move:right','native mover consumes the same remapped intent') +T.eq(table.concat(calls,','):find('rotate:',1,true),nil,'camera intent is not rotated twice') +calls={};w.pollInput=function(self,input)W.pollInput(self,input);self.heldDir=nil end +W.handleInput(w) +T.eq(#calls,0,'suppressed Gen2 intent cannot fall back to held raw d-pad') +T.eq(w.player.turnArmed,true,'no-direction native turn rearming is retained') +-- Poll preserves the original raw-key priority and clears released keys. +w.pollInput=W.pollInput;down={up=true,down=true,left=true,right=true};w:pollInput(game.input) +T.eq(w.heldDir,'up','native priority up') +down.up=nil;w:pollInput(game.input);T.eq(w.heldDir,'down','native priority down') +down.down=nil;w:pollInput(game.input);T.eq(w.heldDir,'left','native priority left') +down={};w:pollInput(game.input);T.eq(w.heldDir,nil,'release clears old intent') +w:pollInput(nil);T.eq(w.heldDir,nil,'missing input clears old intent') +-- Other generations retain their raw-key + walkDirection path. +V.set('red');down.up=true;calls={};W.handleInput(w) +T.eq(calls[1],'rotate:up','other-generation camera direction remains native') +T.eq(calls[#calls],'move:left','other-generation mover retains its rotation result') +V.set('crystal');down={};press.start=true +Screens.push=function(_,name)game.stack.states[1]={screen=name}end +W.handleInput(w) +T.eq(game.stack:top().screen,'StartMenu','real native START branch acquires an overlay owner') +T.eq(w:busy(),true,'same-tick menu ownership blocks a post-body movement wrapper') +game.stack.states={};press={};T.eq(w:busy(),false,'closing overlay restores free-roam readiness') +game.stack.states={w};T.eq(w:busy(),false,'the world itself on a legacy stack is not an overlay owner');game.stack.states={} +w.battleActive=true;T.eq(w:busy(),true,'battle owner also blocks post-body movement');w.battleActive=nil +-- The actual extracted body retains actor/input/player/landing ordering. +local ticks={};local scripted=false +local body=setmetatable({map={id='ROAD',def={}},npcs={},ghosts={},entities={},scriptMoves={},player={cellX=1,cellY=1},runner={isRunning=function()return scripted end}}, {__index=W}) +body.updateCast=function()ticks[#ticks+1]='cast'end +body.npcs={{update=function()ticks[#ticks+1]='npc'end}} +body.updateScriptMoves=function()ticks[#ticks+1]='script-moves'end +body.checkTrainerSight=function()ticks[#ticks+1]='trainer'end +body.handleInput=function()ticks[#ticks+1]='input'end +body.player.update=function()ticks[#ticks+1]='player';return true end +body.onStepComplete=function()ticks[#ticks+1]='landing'end +W.stepBody(body,false) +T.eq(table.concat(ticks,','),'cast,npc,script-moves,trainer,input,player,landing','real actor/body phase keeps native landing order') +ticks={};body.scriptMoves={{}};body.player.moving=true +body.updateScriptMoves=function()ticks[#ticks+1]='script-moves';body.scriptMoves={};body.player.moving=false end +W.stepBody(body,false) +local count=0;for _,x in ipairs(ticks)do if x=='player'then count=count+1 end end +T.eq(count,1,'scripted moving player advances once while its queue drains') +ticks={};scripted=true;W.stepBody(body,false) +T.eq(table.concat(ticks,','),'cast,npc,script-moves,player','script owner prevents input and unscripted landing dispatch') +Sound.play=oldPlay;Screens.push=oldPush;debug.setupvalue(W.handleInput,slot,oldGame);V.set(oldVersion) +T.finish('native input intent, body phases and overlay movement ownership') diff --git a/tests/engine/gen2_player_state.lua b/tests/engine/gen2_player_state.lua new file mode 100644 index 000000000..afa36f31b --- /dev/null +++ b/tests/engine/gen2_player_state.lua @@ -0,0 +1,72 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local oldVersion=Version.get() +local State=require('src.world.Gen2PlayerState') +local World=require('src.world.OverworldController') +local Flags=require('src.script.Flags');local G2Flags=require('src.script.Gen2Flags') +local oldPlayer,oldMap=rawget(World,'player'),rawget(World,'map') +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.movementState,i) + if not name then break end + if name=='Game'then gameSlot,oldGame=i,value;break end +end +assert(gameSlot,'real native world Game binding') +T.eq(State.read(nil,{}),nil,'absent native player is not advertised as normal movement') +T.eq(State.read({},nil),nil,'absent native save is not a movement state') +for _,version in ipairs({'gold','silver','crystal','prism'})do + Version.set(version) + local save={onBike=false,flags={},playerState='bike'} + local game={save=save,data={maps={ROAD={id='ROAD',environment=2}}}} + debug.setupvalue(World.movementState,gameSlot,game) + World.player={surfing=false,onBike=true};World.map={id='ROAD'} + T.eq(World.playerState,'normal',version..' reads save bike flag rather than stale player copy/save state string') + save.onBike=true + T.eq(World.playerState,'bike','scripted save flag is visible before another world tick') + World.player.surfing=true + T.eq(World.playerState,'surf','actual surfing takes precedence over a stale bicycle flag') + World.player.surfingPikachu=true + T.eq(World.playerState,'surf_pika','actual Pikachu surf sheet is exposed') + World.player.surfing=false;save.onBike=false + T.eq(World.playerState,'normal','native dismount is immediately observable') + Flags.set(save,G2Flags.bikeFlag('bike')) + World:applyForcedBike() + T.eq(World.playerState,'bike','real forced-bike map handler updates the observed state') + T.eq(World:applyPlayerState('surf'),true,'native low-level state adoption succeeds') + T.eq(save.onBike,false,'surf adoption clears native bicycle state') + T.eq(World.player.onBike,false,'surf adoption clears mover bicycle copy') + T.eq(World.player.surfing,true,'surf adoption changes native collision state') + T.eq(World.playerState,'surf','adopted state is derived from native flags') + World.playerState='surf_pika' + T.eq(World.player.surfingPikachu,true,'property adoption chooses actual Pikachu sprite flag') + T.eq(rawget(World,'playerState'),nil,'property adoption never creates a shadow state byte') + local ok,reason=World:applyPlayerState('unsupported') + T.eq(ok,nil,'unknown movement state is refused') + T.check(type(reason)=='string','invalid adoption reports why') + T.eq(World.playerState,'surf_pika','invalid state cannot partially mutate native flags') + T.eq(pcall(function()World.playerState='unsupported'end),false,'invalid direct adoption is refused too') + World.playerState=nil + T.eq(World.playerState,'normal','nil property adoption restores actual walking') + T.eq(State.isSurfing(World.playerState),false,'walking is not a surf state') + World.playerState='bike' + T.eq(State.isBiking(World.playerState),true,'bike observer agrees with native adoption') + local replacement={onBike=false,flags={}} + game.save=replacement + T.eq(World.playerState,'normal','save adoption does not retain old bicycle/save reference') + T.eq(save.onBike,true,'observing a replacement save leaves the old save untouched') + World.player=nil + T.eq(World.playerState,nil,'world teardown has no fictitious normal movement state') +end +for _,version in ipairs({'red','emerald','firered','platinum'})do + Version.set(version) + local save={onBike=false};debug.setupvalue(World.movementState,gameSlot,{save=save}) + World.player={surfing=true} + T.eq(World.playerState,nil,'other generations do not acquire Gen2 state names: '..version) + T.eq(World:applyPlayerState('bike'),nil,'Gen2 state adoption refuses other generations') + T.eq(save.onBike,false,'refused adoption preserves other-generation save') + T.eq(World.player.surfing,true,'refused adoption preserves other-generation mover') +end +rawset(World,'player',oldPlayer);rawset(World,'map',oldMap) +debug.setupvalue(World.movementState,gameSlot,oldGame) +Version.set(oldVersion) +T.finish('live native Gen2 movement state and atomic adoption') From 5c00bade63b26b91061ee1a4f67d616206e9db78 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 04:22:57 +0100 Subject: [PATCH 03/34] Give completed Gen2 landings native trainer priority --- src/world/OverworldController.lua | 25 ++++++-- .../engine/gen2_trainer_landing_priority.lua | 59 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_trainer_landing_priority.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 848a6329b..a74e3cd0f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -11769,8 +11769,9 @@ local function canFieldTwo(save) end function OverworldState:checkTrainerSight() - if self.player.moving or self.engaging then return end - if (Game.stack.active and Game.stack:active() or Game.stack:top()) ~= self then return end + if self.player.moving or self.engaging then return false end + if (Game.stack.active and Game.stack:active() or Game.stack:top()) ~= self then return false end + if GameVersion.isGen2() and #(Game.save.party or {}) == 0 then return false end local p = self.player -- A TRAINER WHOSE APPROACH ENDED WITHOUT A BATTLE MUST NOT RE-APPROACH -- FROM THE SAME TILE. @@ -11962,15 +11963,23 @@ function OverworldState:checkTrainerSight() if aloneIsDouble or #spotted >= 2 or not pairUp then break end end end - if #spotted == 0 then return end + if #spotted == 0 then return false end -- Both walk up; the first one spotted leads, and its script is the one -- that runs -- which on the cartridge is the synthesized approach that -- sets trainer A and then trainer B. if #spotted >= 2 then self:startTrainerApproach(spotted[1].npc, spotted[1].dist, spotted[2].npc) - return + return self.engaging and true or false end self:startTrainerApproach(spotted[1].npc, spotted[1].dist) + return self.engaging and true or false +end + +-- A completed-cell handoff must report whether the native approach acquired +-- control. It cannot start an unscripted trainer event under another owner. +function OverworldState:checkTrainerBattle() + if not (self.map and self.player and self.runner and Game and Game.save) or self:busy() then return false end + return self:checkTrainerSight() end -- data/trainers/encounter_types.asm @@ -12922,6 +12931,14 @@ function OverworldState:onStepComplete() -- this after the frame's earlier noteCellChange; leaving the old serial -- here made the next idle check treat the same switch as a second visit. self:noteCellChange() + if GameVersion.isGen2() then + p.inGrass = self.map ~= nil and self.map:isGrassCell(p.cellX, p.cellY) or false + p.grassShake = p.inGrass or nil + -- PlayerEvents checks trainer sight before the tile's warp/coord event + -- or step counters. The earlier input scan ran while this step was still + -- moving, so it cannot stand in for this completed-cell check. + if self:checkTrainerBattle() then return true end + end self:gen4BankStep() -- Gen 4's elevated-bridge bit (Gen4CyclingRoad: the Cycling Road roll) if GameVersion.isGen4() then pcall(require("src.world.Gen4CyclingRoad").stepEnded, self) end diff --git a/tests/engine/gen2_trainer_landing_priority.lua b/tests/engine/gen2_trainer_landing_priority.lua new file mode 100644 index 000000000..54ad6296f --- /dev/null +++ b/tests/engine/gen2_trainer_landing_priority.lua @@ -0,0 +1,59 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local oldVersion=V.get();V.set('crystal') +local W=require('src.world.OverworldController');local Map=require('src.world.Map') +local Music=require('src.core.Music');local oldPlay=Music.play;Music.play=function()end -- audio device only +local data=T.fixtures.fresh();local def={id='TRAINER_ROAD',label='TrainerRoad',tileset='FIX_OUT',width=4,height=4,generation=2,environment=2,blocks={}} +for i=1,16 do def.blocks[i]=1 end;data.maps[def.id]=def +data.tilesets.FIX_OUT.walkable={0,1,2};data.tilesets.FIX_OUT._walkableSet=nil -- native importer list format +data.tilesets.FIX_OUT.collision={};for i=1,16 do data.tilesets.FIX_OUT.collision[i]=0 end +data.tilesets.FIX_OUT.collision[16]=0x72;def.blocks[6]=3 -- actual Gen2 stair collision only on landing quadrant +def.warps={{x=3,y=3,destMap='OTHER',destWarp=1}} +local npc={id=def.id..'_obj_1',cellX=3,cellY=1,facing='down',def={index=1,trainerClass='OPP_LASS',trainerObject=true,sightRange=4}} +local p={cellX=3,cellY=3,facing='down',moving=false} +local world=setmetatable({map=Map.new(def,data.tilesets.FIX_OUT),player=p,npcs={npc},entities={npc,p},scriptMoves={},runner={isRunning=function()return false end}}, {__index=W}) +local save={flags={},defeatedTrainers={},party={{hp=10}}} +local game={data=data,save=save,overworld=world,stack={}} +game.stack.top=function()return world end;game.stack.active=game.stack.top +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.onStepComplete,i);if not name then break end;if name=='Game'then slot,oldGame=i,value;break end end +assert(slot);debug.setupvalue(W.onStepComplete,slot,game) +local advanced=false +world.gen4BankStep=function()advanced=true;error('downstream landing boundary reached')end +T.check(require('src.world.Warp').onArrive(world.map,3,3,'down')~=nil,'positive control: this real landing would otherwise activate a native warp') +W.checkTrainerSight(world) +T.eq(world.engaging,true,'positive control: real geometry/visibility/header scan can start the approach') +world.engaging,world.emote=nil,nil;npc.frozen=nil +local ok=W.onStepComplete and pcall(W.onStepComplete,world) +T.eq(ok,true,'real Gen2 landing is claimed by actual trainer sight before downstream step/warp/coord work') +T.eq(advanced,false,'no later landing phase runs after the trainer claims the cell') +T.eq(world.engaging,true,'actual native approach owns the player') +T.eq(npc.frozen,true,'actual spotted trainer is frozen for the approach') +T.eq(world.emote and world.emote.npc,npc,'native approach queues the correct trainer alert') +local function reset() + world.engaging,world.emote=nil,nil;npc.frozen=false;advanced=false +end +reset();save.defeatedTrainers[npc.id]=true +pcall(W.onStepComplete,world) +T.eq(advanced,true,'beaten trainer leaves downstream native landing phases available') +T.eq(world.engaging,nil,'beaten trainer does not restart an approach') +save.defeatedTrainers={};reset();save.party={} +-- Direct native sight checks must also retain the cartridge's no-party gate. +W.checkTrainerSight(world) +T.eq(world.engaging,nil,'no-party player cannot be forced into a trainer battle') +reset();save.party={{hp=10}};npc.def.sightRange=0 +pcall(W.onStepComplete,world) +T.eq(advanced,true,'talk-only trainer cannot swallow a landing') +T.eq(world.engaging,nil,'zero sight range does not engage') +reset();npc.def.sightRange=4;p.moving=true +T.eq(W.checkTrainerSight(world),false,'mid-step scan reports no claimed event') +p.moving=false +reset();world.runner.isRunning=function()return true end +local guarded=W.checkTrainerBattle and W.checkTrainerBattle(world) +T.eq(guarded,false,'public trainer landing seam refuses a running native script') +T.eq(world.engaging,nil,'scripted movement cannot start an unscripted approach') +world.runner.isRunning=function()return false end +reset();world.npcs={} +T.eq(W.checkTrainerSight(world),false,'empty sight scan returns a useful event verdict') +Music.play=oldPlay;debug.setupvalue(W.onStepComplete,slot,oldGame);V.set(oldVersion) +T.finish('native Gen2 trainer priority at a completed landing') From 838cc8c2b90b9536960f0deb13586bd9a8c857d2 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 04:31:35 +0100 Subject: [PATCH 04/34] Check trainer landing priority across native Gen2 versions --- tests/engine/gen2_trainer_landing_priority.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen2_trainer_landing_priority.lua b/tests/engine/gen2_trainer_landing_priority.lua index 54ad6296f..469868fac 100644 --- a/tests/engine/gen2_trainer_landing_priority.lua +++ b/tests/engine/gen2_trainer_landing_priority.lua @@ -1,8 +1,10 @@ package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') -local V=require('src.core.GameVersion');local oldVersion=V.get();V.set('crystal') +local V=require('src.core.GameVersion');local oldVersion=V.get() local W=require('src.world.OverworldController');local Map=require('src.world.Map') local Music=require('src.core.Music');local oldPlay=Music.play;Music.play=function()end -- audio device only +for _,version in ipairs({'gold','silver','crystal','prism'})do +V.set(version) local data=T.fixtures.fresh();local def={id='TRAINER_ROAD',label='TrainerRoad',tileset='FIX_OUT',width=4,height=4,generation=2,environment=2,blocks={}} for i=1,16 do def.blocks[i]=1 end;data.maps[def.id]=def data.tilesets.FIX_OUT.walkable={0,1,2};data.tilesets.FIX_OUT._walkableSet=nil -- native importer list format @@ -55,5 +57,7 @@ T.eq(world.engaging,nil,'scripted movement cannot start an unscripted approach') world.runner.isRunning=function()return false end reset();world.npcs={} T.eq(W.checkTrainerSight(world),false,'empty sight scan returns a useful event verdict') -Music.play=oldPlay;debug.setupvalue(W.onStepComplete,slot,oldGame);V.set(oldVersion) +debug.setupvalue(W.onStepComplete,slot,oldGame) +end +Music.play=oldPlay;V.set(oldVersion) T.finish('native Gen2 trainer priority at a completed landing') From 8c7c892f9a978678903618e1be990a90c270df90 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 04:38:55 +0100 Subject: [PATCH 05/34] Share native arrival warp phase and positional cooldown --- src/world/OverworldController.lua | 132 +++++++++++++---------- tests/engine/gen2_arrival_warp_phase.lua | 78 ++++++++++++++ 2 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 tests/engine/gen2_arrival_warp_phase.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a74e3cd0f..a9a331cd7 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -12919,6 +12919,71 @@ function OverworldState.gen4EmoteSpot(ground, npc, cam, record, age) top - (record.height or 16) - Gen4Emotes.bounce(age), rise end +-- Arrival suppression is the native destination cell, not a frame counter or +-- a second compatibility-only cooldown. Reading it does not consume it. +function OverworldState:warpsSuppressed() + local entry, p = self.warpEntryCell, self.player + return (entry and p and p.cellX == entry.x and p.cellY == entry.y) and true or false +end + +function OverworldState:clearWarpCooldownIfLeft() + local entry, p = self.warpEntryCell, self.player + if entry and p and (p.cellX ~= entry.x or p.cellY ~= entry.y) then + self.warpEntryCell = nil + end +end + +-- Shared native arrival phase. Directional Gen2 carpets stay on the input +-- side (checkGen2CarpetExit); a side-step onto one must leave its coord event +-- reachable. A true verdict means a real resolved warp acquired control. +function OverworldState:checkWarpOnArrive() + local p = self.player + if not (p and self.map and Game and Game.data and Game.save) then return false end + if GameVersion.isGen2() and (p.moving or self:busy()) then return false end + -- The arrival disable is POSITIONAL: warpEntryCell above is the whole + -- test. Consuming a completed step with a one-shot "just warped" counter + -- instead swallowed the warp under the player's feet, which is why a + -- second ladder one cell from the first did nothing (Seafoam B3F has warp + -- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter: + -- every completed step runs CheckWarpsNoCollision (home/overworld.asm). + -- That same step is where BIT_STANDING_ON_WARP is maintained: cleared + -- before the check (home/overworld.asm:324), set again while standing on a + -- warp square, then cleared once more when the square is a warp-activating + -- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile). + self:refreshStandingOnWarp() + if self:warpsSuppressed() then + -- still standing on the warp we arrived through; do not re-trigger it + else + -- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise + -- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP + -- is set (Seafoam B3F currents -- home/overworld.asm). + -- ...and the direction goes to THIS one too. It was stated at the + -- ordering check above and not at the call that actually takes the warp + -- once before -- that is exactly how the Gen 2 doormat became a trapdoor + -- (see the comment on Warp.onArrive), so both sites pass it. + local w = Warp.onArrive(self.map, p.cellX, p.cellY, p.facing) + -- ...and ExtraWarpCheck is a GEN 1 routine with no Gen 2 counterpart. + -- Gen 2's CheckTileEvent runs CheckWarpTile and nothing else; the only + -- other way a warp fires there is DoPlayerMovement .CheckWarp, which is + -- the directional carpet and is handled on the input side. + -- + -- Leaving this on would put the corner mats back: ExtraWarpCheck falls + -- back to "is the player facing the map edge", so a LEFT-facing carpet + -- sitting on the bottom row would still warp somebody who walked down + -- along it -- the same wrong exit, through a different door. + local gen2Classes = GameVersion.isGen2() + and self.map.speaksGen2Collision and self.map:speaksGen2Collision() + if not w and not gen2Classes and (self:dirHeld() or self.forcedWarp) then + w = Warp.onCollision(self.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, p.facing) + end + if w then + return self:takeWarp(w.def) and true or false + end + end + return false +end + function OverworldState:onStepComplete() local p = self.player -- THE DISTORTION WORLD'S COORDINATE EVENTS fire where a step ends @@ -13348,60 +13413,8 @@ function OverworldState:onStepComplete() self.boulderTried = nil -- a completed step ends any armed boulder push - -- arriving on a door/warp tile warp; a non-door warp square also fires - -- when the extra check passes and the d-pad is held - -- (CheckWarpsNoCollision) - -- The cell we warped in on is inert until we step off it: standing on it, - -- or being walked back onto it before leaving, does not re-fire (see - -- warpEntryCell where it is set). Once we are on any other cell it clears - -- and every warp is live again. - local entry = self.warpEntryCell - if entry and (p.cellX ~= entry.x or p.cellY ~= entry.y) then - self.warpEntryCell = nil - entry = nil - end - -- The arrival disable is POSITIONAL: warpEntryCell above is the whole - -- test. Consuming a completed step with a one-shot "just warped" counter - -- instead swallowed the warp under the player's feet, which is why a - -- second ladder one cell from the first did nothing (Seafoam B3F has warp - -- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter: - -- every completed step runs CheckWarpsNoCollision (home/overworld.asm). - -- That same step is where BIT_STANDING_ON_WARP is maintained: cleared - -- before the check (home/overworld.asm:324), set again while standing on a - -- warp square, then cleared once more when the square is a warp-activating - -- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile). - self:refreshStandingOnWarp() - if entry then - -- still standing on the warp we arrived through; do not re-trigger it - else - -- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise - -- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP - -- is set (Seafoam B3F currents -- home/overworld.asm). - -- ...and the direction goes to THIS one too. It was stated at the - -- ordering check above and not at the call that actually takes the warp - -- once before -- that is exactly how the Gen 2 doormat became a trapdoor - -- (see the comment on Warp.onArrive), so both sites pass it. - local w = Warp.onArrive(self.map, p.cellX, p.cellY, p.facing) - -- ...and ExtraWarpCheck is a GEN 1 routine with no Gen 2 counterpart. - -- Gen 2's CheckTileEvent runs CheckWarpTile and nothing else; the only - -- other way a warp fires there is DoPlayerMovement .CheckWarp, which is - -- the directional carpet and is handled on the input side. - -- - -- Leaving this on would put the corner mats back: ExtraWarpCheck falls - -- back to "is the player facing the map edge", so a LEFT-facing carpet - -- sitting on the bottom row would still warp somebody who walked down - -- along it -- the same wrong exit, through a different door. - local gen2Classes = GameVersion.isGen2() - and self.map.speaksGen2Collision and self.map:speaksGen2Collision() - if not w and not gen2Classes and (self:dirHeld() or self.forcedWarp) then - w = Warp.onCollision(self.map, Game.data.field.warpCarpets, - p.cellX, p.cellY, p.facing) - end - if w then - self:takeWarp(w.def) - return - end - end + self:clearWarpCooldownIfLeft() + if self:checkWarpOnArrive() then return end if Game.save.repelSteps and Game.save.repelSteps > 0 then Game.save.repelSteps = Game.save.repelSteps - 1 @@ -14584,7 +14597,7 @@ function OverworldState:takeWarp(warpDef) -- door naming the dynamic-warp placeholder before anything set it. Standing -- still with a line in the log beats handing MapLoader an id that does not -- exist, which raises under the player's feet. - if not destMap then return end + if not destMap then return false end -- GEN 4'S LIFTS REMEMBER THE FLOOR YOU GOT ON AT, and the door you are -- taking is the only place that knows it. Stepping into a lift car -- overwrites the special location with the floor being left @@ -14622,17 +14635,17 @@ function OverworldState:takeWarp(warpDef) self.player.spinTimer = 0 self.arriveWarp = "teleport" self:startWarpTo(destMap, x, y, facing) - return + return true elseif pad == "hole" then -- falling through a hole: no door SFX, no walk-out step. Lavaridge's two -- are holes with an animation of their own -- see startLavaridgeWarp local lav = self.map.lavaridgeWarpAt and self.map:lavaridgeWarpAt(self.player.cellX, self.player.cellY) if lav and self:startLavaridgeWarp(lav, destMap, x, y, facing) then - return + return true end self:startWarpTo(destMap, x, y, facing) - return + return true end -- THE ESCALATORS. All 34 of them already carried a warp event, so the -- player was being moved between floors correctly -- through the DOOR path, @@ -14650,10 +14663,11 @@ function OverworldState:takeWarp(warpDef) self.arriveWarp = "escalator" self.escalatorWay = escalator self:startWarpTo(destMap, x, y, facing) - return + return true end self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out self:startWarpTo(destMap, x, y, facing) + return true end -- wDigWarpNumber / wDigMapGroup / wDigMapNumber (EnterMapWarp.SaveDigWarp). diff --git a/tests/engine/gen2_arrival_warp_phase.lua b/tests/engine/gen2_arrival_warp_phase.lua new file mode 100644 index 000000000..f03c245fd --- /dev/null +++ b/tests/engine/gen2_arrival_warp_phase.lua @@ -0,0 +1,78 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map') +local Stack=require('src.core.StateStack');local Transition=require('src.render.Transition') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device only +local slot,oldGame +for i=1,100 do local n,v=debug.getupvalue(W.takeWarp,i);if not n then break end;if n=='Game'then slot,oldGame=i,v;break end end +assert(slot) +local scriptSlot,oldScripts +for i=1,100 do local n,v=debug.getupvalue(W.onStepComplete,i);if not n then break end;if n=='mapScripts'then scriptSlot,oldScripts=i,v;break end end +assert(scriptSlot);debug.setupvalue(W.onStepComplete,scriptSlot,require('src.script.MapScripts')) +for _,version in ipairs({'gold','silver','crystal','prism'})do + V.set(version) + local data=T.fixtures.fresh() + local ts={id='STAIRS',blockCells=2,collision={255,255,255,255,0x72,0x72,0x72,0x72},walkable={0,0x72},waterTiles={},grassTiles={}} + local from={id='ARRIVAL_FROM',generation=2,environment=3,width=1,height=1,blocks={1},warps={{x=0,y=0,destMap='ARRIVAL_TO',destWarp=1}}} + local dest={id='ARRIVAL_TO',generation=2,environment=3,width=1,height=1,blocks={1},warps={{x=0,y=0,destMap=from.id,destWarp=1}}} + data.maps[from.id]=from;data.maps[dest.id]=dest + local p={cellX=0,cellY=0,facing='up',moving=false} + local w=setmetatable({map=Map.new(from,ts),player=p,scriptMoves={},entities={p},npcs={},runner={isRunning=function()return false end}}, {__index=W}) + local game={data=data,save={flags={},party={}},overworld=w,stack=setmetatable({states={}}, {__index=Stack})} + debug.setupvalue(W.takeWarp,slot,game) + local builds=0 + -- Native fade, destination lookup, event and arrival guard are real. Only + -- map materialization is replaced; this test has no imported graphics. + function w:setMap(id,x,y,facing,opts) + builds=builds+1;self.map=Map.new(data.maps[id],ts);p.cellX,p.cellY,p.facing=x,y,facing + end + T.eq(w:checkWarpOnArrive(),true,version..' real stair landing hands control to native warp') + local fade=game.stack:top();T.eq(getmetatable(fade),Transition,'native fade is really pushed') + T.eq(w.transitioning,true,'transition owns input before map materialization') + T.eq(w:checkWarpOnArrive(),false,'same-frame reentry cannot start another warp') + T.eq(#game.stack.states,1,'reentry keeps a single native transition') + for _=1,fade.frames+fade.framesIn+2 do if game.stack:top()==fade then fade:update(1/60)end end + T.eq(builds,1,'actual native midpoint changes map once') + T.eq(w.map.id,dest.id,'native transition reaches resolved destination') + T.eq(w.transitioning,false,'native fade releases control') + T.eq(w:warpsSuppressed(),true,'native transition installs the arrival-cell guard') + T.eq(w:checkWarpOnArrive(),false,'arrival stair cannot bounce back') + w:clearWarpCooldownIfLeft();T.eq(w:warpsSuppressed(),true,'reading or clearing without leaving preserves the guard') + p.cellX=1;w:clearWarpCooldownIfLeft();T.eq(w.warpEntryCell,nil,'physical cell change clears the actual native guard') + p.cellX=0;T.eq(w:warpsSuppressed(),false,'returning after leaving does not revive stale suppression') + -- These are native COLL_WARP_CARPET_* bytes, not guessed tile IDs. + for _,coll in ipairs({0x70,0x76,0x78,0x7E})do + for i=5,8 do ts.collision[i]=coll end + w.map=Map.new(from,ts);p.facing='down';w.heldDir='down';w.forcedWarp=true + T.eq(w:checkWarpOnArrive(),false,'directional carpet is not an immediate arrival or Gen1 extra warp') + T.eq(#game.stack.states,0,'carpet leaves native coord-event phase available') + end + for i=5,8 do ts.collision[i]=0x72 end;w.map=Map.new(from,ts);w.forcedWarp=nil + game.stack:push({});T.eq(w:checkWarpOnArrive(),false,'overlay cannot be bypassed by public arrival phase');game.stack:pop() + w.runner.isRunning=function()return true end;T.eq(w:checkWarpOnArrive(),false,'native script retains ownership');w.runner.isRunning=function()return false end + p.moving=true;T.eq(w:checkWarpOnArrive(),false,'mid-step coordinates cannot take an arrival warp');p.moving=false + -- Exercise the shared phase from the real completed-step chain as well. + w.warpEntryCell={x=1,y=0};game.save.party={};game.save.defeatedTrainers={} + local nativeArrival=W.checkWarpOnArrive;local arrivals=0 + function w:checkWarpOnArrive()arrivals=arrivals+1;return nativeArrival(self)end + W.onStepComplete(w) + T.eq(arrivals,1,'real completed-step chain consumes the shared native arrival phase once') + T.eq(w.transitioning,true,'native completed landing actually starts the warp') + T.eq(#game.stack.states,1,'shared native step and arrival create only one transition') + game.stack:pop();w.transitioning=false + w.map=nil;T.eq(w:checkWarpOnArrive(),false,'missing map cannot fabricate an arrival') + w.player=nil;T.eq(w:warpsSuppressed(),false,'missing player has no observed arrival-cell position') +end +-- The native resolver can genuinely refuse a Gen3 dynamic placeholder. That +-- is not an accepted transition and must not masquerade as a taken warp. +V.set('emerald') +local data=T.fixtures.fresh();local map=Map.new({id='DYNAMIC_FROM',generation=3,width=1,height=1,blocks={0},warps={}}, {id='DYNAMIC',blockCells=1,collision={0},walkable={0}}) +local w=setmetatable({map=map,player={cellX=0,cellY=0,facing='down'}},{__index=W}) +local game={data=data,save={},stack=setmetatable({states={}}, {__index=Stack})} +debug.setupvalue(W.takeWarp,slot,game) +T.eq(w:takeWarp({destMap='MAP_G127_N127',destWarp=1}),false,'unresolved dynamic destination returns real refusal') +T.eq(#game.stack.states,0,'refused destination never pushes native fade') +T.eq(w.backupWarp,nil,'refused destination cannot overwrite backup warp') +debug.setupvalue(W.takeWarp,slot,oldGame);debug.setupvalue(W.onStepComplete,scriptSlot,oldScripts);Sound.play=oldSound;V.set(original) +T.finish('shared native arrival warp phase') From 6ad524691e818771c100a4df8a0b76529d346b73 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 04:49:23 +0100 Subject: [PATCH 06/34] Share native Gen2 coordinate dispatch and consume claimed landings once --- src/world/OverworldController.lua | 67 +++++++++++- tests/engine/gen2_coordinate_phase.lua | 141 +++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_coordinate_phase.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a9a331cd7..133528fe4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1023,6 +1023,9 @@ function OverworldState:enter(mapId, x, y, facing) self.camera = Camera.new() self.runner = ScriptRunner.new(Game, self) self.scriptMoves = {} + -- A fresh world entry may reuse this singleton and the same save/data. + -- It starts a new coordinate visit; a same-map graphics rebuild does not. + self.gen2CoordVisit, self.lastCellMapId = nil, nil self.pendingScripts = {} self.parallelRunners = {} self.parallelQueue = {} @@ -2802,8 +2805,11 @@ end function OverworldState:noteCellChange() local p = self.player if not (p and p.cellX and p.cellY) then return end - if self.lastCellX ~= p.cellX or self.lastCellY ~= p.cellY then + local mapId = GameVersion.isGen2() and self.map and self.map.id or nil + if self.lastCellX ~= p.cellX or self.lastCellY ~= p.cellY + or (GameVersion.isGen2() and self.lastCellMapId ~= mapId) then self.lastCellX, self.lastCellY = p.cellX, p.cellY + if GameVersion.isGen2() then self.lastCellMapId = mapId end self.cellSerial = (self.cellSerial or 0) + 1 if self.gen5ArrivalMap and (p.cellX ~= self.gen5ArrivalX or p.cellY ~= self.gen5ArrivalY) then self.gen5ArrivalMap = nil @@ -12695,6 +12701,56 @@ function OverworldState:hatchEgg(hatched) return true end +-- Gen2's native coord scripts and authored tile events share a completed-cell +-- handoff. Both native step completion and deferred/scripted-arrival checks +-- use this phase; polling an already claimed visit cannot replay its script. +function OverworldState:tryCoordScript() + local p, map = self.player, self.map + if not GameVersion.isGen2() or not (p and map and mapScripts and self.runner + and Game and Game.data and Game.save) or p.moving or self:busy() then return false end + self:noteCellChange() + local old = self.gen2CoordVisit + if old and old.mapId == map.id and old.save == Game.save + and old.data == Game.data and old.serial == self.cellSerial then return false end + -- Immediate native warps retain priority; a directional carpet still falls + -- through to its scene event, and an inert arrival cell stays inert. + if not self:warpsSuppressed() + and Warp.onArrive(map, p.cellX, p.cellY, p.facing) then return false end + local visit = { mapId = map.id, data = Game.data, save = Game.save, serial = self.cellSerial } + local events = map.def and map.def.events + if type(events) == "table" then + for _, ev in ipairs(events) do + if ev.replaces and ev.x == p.cellX and ev.y == p.cellY + and type(ev.script) == "table" and #ev.script > 0 then + self.gen2CoordVisit = visit + self.runner:run(ev.script, { event = ev.id }) + return true + end + end + end + local hooks = mapScripts.get(map.id) + if hooks and hooks.onStep then + -- Install before dispatch: a synchronous script/hook may call back into + -- this phase. A non-matching hook does not consume the visit. + self.gen2CoordVisit = visit + if hooks.onStep(Game, self, p.cellX, p.cellY) then return true end + if self.map ~= map or self.player ~= p or Game.save ~= visit.save + or Game.data ~= visit.data or self:busy() then return true end + if self.gen2CoordVisit == visit then self.gen2CoordVisit = nil end + end + if type(events) == "table" then + for _, ev in ipairs(events) do + if ev.x == p.cellX and ev.y == p.cellY + and type(ev.script) == "table" and #ev.script > 0 then + self.gen2CoordVisit = visit + self.runner:run(ev.script, { event = ev.id }) + return true + end + end + end + return false +end + -- Run the map's step trigger for the cell the player is standing on, once. -- -- `onStep` is the same hook onStepComplete uses; what is different here is @@ -12715,6 +12771,7 @@ end -- fired at the cell the player is standing on, so a row cannot run twice -- while the player stands there however often it is asked. function OverworldState:checkCoordEventHere(again) + if GameVersion.isGen2() then return self:tryCoordScript() end local hooks = mapScripts.get(self.map and self.map.id) if not (hooks and hooks.onStep) then return false end local p = self.player @@ -13209,6 +13266,8 @@ function OverworldState:onStepComplete() end end + if GameVersion.isGen2() and self:tryCoordScript() then return end + -- AN AUTHORED EVENT THAT DECLARES ITSELF A REPLACEMENT, ahead of the hooks. -- -- The default below is that the cartridge's own scenes own their squares, @@ -13226,7 +13285,7 @@ function OverworldState:onStepComplete() -- STILL AFTER THE WARP. That half of the ordering is not about authorship -- -- it is CheckTileEvent testing the warp tile first -- and an authored event -- that swallowed a door would strand the player just as surely. - if not warpFirst and not self.runner:isRunning() then + if not GameVersion.isGen2() and not warpFirst and not self.runner:isRunning() then local events = self.map and self.map.def and self.map.def.events if type(events) == "table" then for _, ev in ipairs(events) do @@ -13313,7 +13372,7 @@ function OverworldState:onStepComplete() -- hand-ported step triggers (Pallet intro, Saffron gate guards, ...) local hooks = mapScripts.get(self.map.id) - if not warpFirst and hooks and hooks.onStep then + if not GameVersion.isGen2() and not warpFirst and hooks and hooks.onStep then if hooks.onStep(Game, self, p.cellX, p.cellY) then return end @@ -13334,7 +13393,7 @@ function OverworldState:onStepComplete() -- second interpreter. Whether the event repeats is the SCRIPT's business: -- it opens with its own `check_flag`/`jump_if_true` when the author asked -- for once-only, which is the same mechanism they use for everything else. - if not warpFirst and not self.runner:isRunning() then + if not GameVersion.isGen2() and not warpFirst and not self.runner:isRunning() then local events = self.map and self.map.def and self.map.def.events if type(events) == "table" then for _, ev in ipairs(events) do diff --git a/tests/engine/gen2_coordinate_phase.lua b/tests/engine/gen2_coordinate_phase.lua new file mode 100644 index 000000000..d71b62a2e --- /dev/null +++ b/tests/engine/gen2_coordinate_phase.lua @@ -0,0 +1,141 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local originalVersion=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map') +local Scripts=require('src.script.MapScripts');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local slots={} +for i=1,100 do local n,v=debug.getupvalue(W.onStepComplete,i);if not n then break end;if n=='Game'or n=='mapScripts'then slots[n]={i,v}end end +assert(slots.Game and slots.mapScripts) +debug.setupvalue(W.onStepComplete,slots.mapScripts[1],Scripts) +for _,version in ipairs({'gold','silver','crystal','prism'})do + V.set(version) + local id='NATIVE_COORD_'..version:upper();local data=T.fixtures.fresh() + local def={id=id,generation=2,environment=3,width=1,height=1,blocks={1},warps={}} + local other={id=id..'_OTHER',generation=2,environment=3,width=1,height=1,blocks={1},warps={}} + data.maps[id]=def;data.maps[other.id]=other + data.map_scripts={source='RomExtractorGen2',scripts={ + CELL={{'setval',42},{'end'}},NEXT={{'setval',20},{'end'}},ANY={{'setval',90},{'end'}},WAIT={{'setval',77},{'pause',2},{'end'}}}, + maps={[id]={coords={{x=0,y=0,scene=0,script='CELL'},{x=0,y=0,scene=1,script='NEXT'},{x=1,y=0,scene=0xFF,script='ANY'}}}}} + T.eq(VM.register(data,'scenes'),1,version..' actual ROM-format coordinate program is compiled and registered') + local ts={id='COORD_FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},waterTiles={},grassTiles={}} + local p={cellX=0,cellY=0,facing='down',moving=false} + local w=setmetatable({map=Map.new(def,ts),player=p,scriptMoves={},entities={p},npcs={}},{__index=W}) + local game={data=data,save={flags={},party={},g2Scenes={[id]=0}},overworld=w,stack=setmetatable({states={}}, {__index=Stack})} + w.runner=Runner.new(game,w);debug.setupvalue(W.onStepComplete,slots.Game[1],game) + local starts,phaseCalls=0,0;local run=w.runner.run;local phase=W.tryCoordScript + function w.runner:run(...)starts=starts+1;return run(self,...)end + function w:tryCoordScript()phaseCalls=phaseCalls+1;return phase(self)end + W.onStepComplete(w) + T.eq(phaseCalls,1,'actual native completed step calls the shared coordinate phase once') + T.eq(starts,1,'actual native VM script starts once at the completed cell') + T.eq(w.runner.ctx.g2Var,42,'native VM bytecode executes through real ScriptRunner commands') + T.eq(w.runner:isRunning(),false,'synchronous native coordinate script really finishes') + T.eq(W.checkCoordEventHere(w,true),false,'idle forced re-ask cannot replay the completed Gen2 visit') + T.eq(w:tryCoordScript(),false,'integration landing re-ask shares the same consumption guard') + T.eq(starts,1,'native and integration consumers do not duplicate the script') + local function leaveAndReturn() + p.cellX=1;p.cellY=1;w:noteCellChange();p.cellX=0;p.cellY=0 + end + leaveAndReturn();T.eq(w:tryCoordScript(),true,'actual physical return rearms the coordinate script') + T.eq(starts,2,'returning after a cell change starts one new script') + game.save.g2Scenes[id]=1;leaveAndReturn();T.eq(w:tryCoordScript(),true,'new landing reads native saved scene') + T.eq(w.runner.ctx.g2Var,20,'scene one selects its own compiled program') + game.save.g2Scenes[id]=2;leaveAndReturn();T.eq(w:tryCoordScript(),false,'nonmatching native scene starts nothing') + local before=starts;game.save.g2Scenes[id]=0 + T.eq(w:tryCoordScript(),true,'a nonmatching ask did not consume a later eligible script') + T.eq(starts,before+1,'eligible retry starts exactly once') + p.cellX=1;game.save.g2Scenes[id]=9 + T.eq(w:tryCoordScript(),true,'native FF scene wildcard is retained') + T.eq(w.runner.ctx.g2Var,90,'wildcard executes its actual VM program') + T.eq(w:tryCoordScript(),false,'wildcard also cannot replay the same visit') + game.save.g2Scenes[id]=0;leaveAndReturn() + def.events={{id='REPLACEMENT',x=0,y=0,replaces=true,script={{'set_flag','AUTHORED_REPLACEMENT'}}}} + before=starts;T.eq(w:tryCoordScript(),true,'explicit authored replacement precedes native coordinate event') + T.eq(starts,before+1,'replacement and native event cannot both run') + T.eq(game.save.flags.AUTHORED_REPLACEMENT,true,'actual authored command updates native save flag') + T.eq(w.runner.ctx.event,'REPLACEMENT','authored event identity reaches native runner context') + def.events={{id='FALLBACK',x=0,y=0,script={{'set_flag','AUTHORED_FALLBACK'}}}} + leaveAndReturn();before=starts;T.eq(w:tryCoordScript(),true,'native event precedes ordinary authored event') + T.eq(starts,before+1,'native winner consumes the phase') + T.eq(game.save.flags.AUTHORED_FALLBACK,nil,'ordinary authored event cannot swallow a native scene') + game.save.g2Scenes[id]=2;leaveAndReturn() + T.eq(w:tryCoordScript(),true,'ordinary authored event runs when native scene does not match') + T.eq(game.save.flags.AUTHORED_FALLBACK,true,'actual authored fallback command runs') + T.eq(w:tryCoordScript(),false,'authored event is consumed through the same guard') + def.events=nil;game.save.g2Scenes[id]=0;leaveAndReturn() + def.warps={{x=0,y=0,destMap=other.id,destWarp=1}} + for i=5,8 do ts.collision[i]=0x72 end;w.map=Map.new(def,ts) + before=starts;T.eq(w:tryCoordScript(),false,'actual immediate staircase warp has priority over coordinate script') + T.eq(starts,before,'warp priority never starts a competing script') + for i=5,8 do ts.collision[i]=0x70 end;w.map=Map.new(def,ts) + T.eq(w:tryCoordScript(),true,'native exit carpet still admits its coordinate scene') + T.eq(w.runner.ctx.g2Var,42,'exit-mat scene reaches actual VM') + def.warps={};for i=5,8 do ts.collision[i]=0 end;w.map=Map.new(def,ts) + T.eq(w:tryCoordScript(),false,'reloading the same physical map does not replay an already claimed visit') + leaveAndReturn();p.moving=true;before=starts + T.eq(w:tryCoordScript(),false,'mid-step coordinates do not dispatch') + p.moving=false;game.stack:push({});T.eq(w:tryCoordScript(),false,'overlay owns input over coordinate polling');game.stack:pop() + T.eq(starts,before,'ineligible ownership checks did not consume or dispatch a visit') + T.eq(w:tryCoordScript(),true,'finished movement and released overlay may dispatch the still-pending visit') + -- A waiting native VM script retains ownership; finishing it does not replay. + leaveAndReturn();Scripts.attachBase(id,{onStep=function(_,world) + world.runner:run(VM.compile(data,'WAIT'));return true + end}) + T.eq(w:tryCoordScript(),true,'real asynchronous script claims its native landing') + T.eq(w.runner:isRunning(),true,'native pause really suspends the script') + T.eq(w:tryCoordScript(),false,'running native script cannot be started twice') + for _=1,4 do w.runner:update()end + T.eq(w.runner:isRunning(),false,'actual script runner completes its wait') + T.eq(w:tryCoordScript(),false,'script completion does not replay its old coordinate event') + -- A synchronous native hook may re-enter through an integration wrapper. + leaveAndReturn();local nested;before=starts + Scripts.attachBase(id,{onStep=function(_,world) + nested=world:tryCoordScript();world.runner:run(VM.compile(data,'CELL'));return true + end}) + T.eq(w:tryCoordScript(),true,'outer synchronous dispatch claims the visit') + T.eq(nested,false,'reentrant coordinate polling cannot recursively dispatch the same visit') + T.eq(starts,before+1,'reentrant integration still executes only one native program') + -- Even a custom hook returning false cannot fall through behind an owner. + leaveAndReturn();Scripts.attachBase(id,{onStep=function(_,world) + world.runner:run(VM.compile(data,'WAIT'));return false + end}) + def.events={{id='AFTER_OWNER',x=0,y=0,script={{'set_flag','AFTER_OWNER'}}}} + T.eq(w:tryCoordScript(),true,'actual running script handoff ends the phase despite a false hook verdict') + T.eq(game.save.flags.AFTER_OWNER,nil,'no authored event runs behind the newly acquired script owner') + for _=1,4 do w.runner:update()end + -- Map replacement during a hook retires the old landing context as well. + leaveAndReturn();Scripts.attachBase(id,{onStep=function(_,world)world.map=Map.new(other,ts);return false end}) + T.eq(w:tryCoordScript(),true,'real map replacement ends processing of the old landing') + T.eq(w.map.id,other.id,'hook really handed off to the other map') + T.eq(game.save.flags.AFTER_OWNER,nil,'old authored events cannot run on the new map') + -- Map changes at identical coordinates must rearm when returning, while a + -- same-map graphics rebuild above must not. The empty intermediate map has + -- no event that could accidentally clear the previous consumption record. + w:noteCellChange();T.eq(w:tryCoordScript(),false,'empty intermediate map has no invented event') + def.events=nil;VM.register(data,'scenes');w.map=Map.new(def,ts) + T.eq(w:tryCoordScript(),true,'returning through another map at identical coordinates rearms native events') + T.eq(w.runner.ctx.g2Var,42,'map return reads native program rather than old dispatch state') + local oldSave=game.save;game.save={flags={},party={},g2Scenes={[id]=0}} + T.eq(w:tryCoordScript(),true,'a replacement save cannot inherit old-session event consumption') + game.save=oldSave + local nextData=T.fixtures.fresh();nextData.maps[id]=def + nextData.map_scripts={source='RomExtractorGen2',scripts={RELOADED={{'setval',43},{'end'}}},maps={[id]={coords={{x=0,y=0,scene=0,script='RELOADED'}}}}} + VM.register(nextData,'scenes');game.data=nextData + T.eq(w:tryCoordScript(),true,'a replacement dataset cannot inherit old-session event consumption') + T.eq(w.runner.ctx.g2Var,43,'replacement dataset executes its actual newly compiled program') + game.data=data;VM.register(data,'scenes');w.map=nil;before=starts + T.eq(w:tryCoordScript(),false,'missing map cannot invent a coordinate event') + w.map=Map.new(def,ts);w.player=nil;T.eq(w:tryCoordScript(),false,'missing player cannot dispatch') + w.player=p;local runner=w.runner;w.runner=nil;T.eq(w:tryCoordScript(),false,'missing native runner cannot dispatch');w.runner=runner + game.save=nil;T.eq(w:tryCoordScript(),false,'missing save cannot dispatch');game.save=oldSave + local oldData=game.data;game.data=nil;T.eq(w:tryCoordScript(),false,'missing dataset cannot dispatch');game.data=oldData + T.eq(starts,before,'missing-context queries leave scripts untouched') + -- Other generations keep their existing explicit re-ask behavior. + V.set('emerald');w.coordFired=nil;before=starts + T.eq(W.checkCoordEventHere(w,true),true,'Gen3 native coordinate re-ask remains available') + T.eq(W.checkCoordEventHere(w,true),true,'Gen3 variable-gated same-cell re-ask is not given a Gen2 latch') + T.eq(starts,before+2,'other-generation dispatch remains on its original hook path') + T.eq(w:tryCoordScript(),false,'Gen2 public landing phase never impersonates another generation') +end +debug.setupvalue(W.onStepComplete,slots.Game[1],slots.Game[2]);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],slots.mapScripts[2]);V.set(originalVersion) +T.finish('native Gen2 coordinate phase and shared landing consumption') From 4e1546bc3a53458cc23d3a6498f139ea898ba1ee Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 05:27:01 +0100 Subject: [PATCH 07/34] Share native Gen2 step counters and encounter phases --- src/world/Gen2PhoneService.lua | 11 ++ src/world/Gen2StepEvents.lua | 28 +++++ src/world/OverworldController.lua | 173 ++++++++++++++++++++------ tests/engine/gen2_count_step.lua | 196 ++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 src/world/Gen2PhoneService.lua create mode 100644 src/world/Gen2StepEvents.lua create mode 100644 tests/engine/gen2_count_step.lua diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/Gen2StepEvents.lua b/src/world/Gen2StepEvents.lua new file mode 100644 index 000000000..2a49dff30 --- /dev/null +++ b/src/world/Gen2StepEvents.lua @@ -0,0 +1,28 @@ +-- Native Gen2 per-footfall state shared by world phases. Save fields remain +-- those consumed by the existing native commands and breeding/poison code. +local Version = require('src.core.GameVersion') +local Flags = require('src.script.Gen2Flags') +local PhoneService = require('src.world.Gen2PhoneService') +local StepEvents = {} +function StepEvents.vanilla() + local id=Version.get() + return id=='gold' or id=='silver' or id=='crystal' +end +function StepEvents.matches(visit,world,game) + return visit and visit.mapId==world.map.id and visit.save==game.save + and visit.data==game.data and visit.serial==world.cellSerial +end +function StepEvents.visit(world,game) + return {mapId=world.map.id,save=game.save,data=game.data,serial=world.cellSerial,blocked=false} +end +function StepEvents.bikeStep(save,state,def) + if not StepEvents.vanilla() or state~='bike' or not PhoneService.available(def) then return false end + local key=Flags.engineFlag(19) -- same named flag native setflag writes (Crystal raw index 20) + if not (save.flags and save.flags[key]) then return false end + save.g2BikeSteps=math.min((save.g2BikeSteps or 0)+1,0xffff) + if save.g2BikeSteps<1024 or save.g2SpecialCall~=nil then return false end + save.g2SpecialCall=6 -- SPECIALCALL_BIKESHOP; dispatched through the imported native phone program + save.flags[key]=nil + return true -- arming is not CountStep's carry; it rings on the following footfall +end +return StepEvents diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 133528fe4..e646c073e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1025,7 +1025,7 @@ function OverworldState:enter(mapId, x, y, facing) self.scriptMoves = {} -- A fresh world entry may reuse this singleton and the same save/data. -- It starts a new coordinate visit; a same-map graphics rebuild does not. - self.gen2CoordVisit, self.lastCellMapId = nil, nil + self.gen2CoordVisit, self.gen2StepVisit, self.gen2EncounterVisit, self.lastCellMapId = nil, nil, nil, nil self.pendingScripts = {} self.parallelRunners = {} self.parallelQueue = {} @@ -3291,23 +3291,24 @@ end -- Poll it here, on an idle frame, and hand the caller script to the same -- pending-script FIFO a warp cutscene uses. function OverworldState:checkSpecialPhoneCall() - if not GameVersion.isGen2() then return end - if Game.save.g2SpecialCall == nil then return end + if not GameVersion.isGen2() then return false end + if Game.save.g2SpecialCall == nil then return false end if self.transitioning or self.runner:isRunning() or #self.scriptMoves > 0 then - return + return false end - if self.player and (self.player.moving or self.player.targetX) then return end + if self.player and (self.player.moving or self.player.targetX) then return false end local outside = self.map and self.map.def and Map.isOutdoor(self.map.def) local Gen2Commands = require("src.script.Gen2Commands") local caller, script = Gen2Commands.pendingSpecialCall(Game.data, Game.save, outside) - if not script then return end + if not script then return false end Game.save.g2SpecialCallActive = Game.save.g2SpecialCall Game.save.g2SpecialCall = nil -- wCurCaller, which the caller script's `readvar 23` reads back Game.save.g2CurCaller = caller self:queueScript(script, { phoneCaller = caller, onDone = function() Game.save.g2CurCaller = nil end }) + return true end -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step @@ -3320,22 +3321,22 @@ end -- "in-game minutes since the last call", and the port has no separate RTC to -- keep them apart. function OverworldState:checkIncomingPhoneCall() - if not GameVersion.isGen2() then return end + if not GameVersion.isGen2() then return false end if self.transitioning or self.runner:isRunning() or #self.scriptMoves > 0 then - return + return false end - if Game.save.g2SpecialCall ~= nil then return end - if self.pendingScripts and self.pendingScripts[1] then return end + if Game.save.g2SpecialCall ~= nil then return false end + if self.pendingScripts and self.pendingScripts[1] then return false end local p = self.player - if not (p and self.map) then return end + if not (p and self.map) then return false end -- CheckStandingOnEntrance: no call while the player is on a door or warp - if self.map:warpAtCell(p.cellX, p.cellY) then return end + if self.map:warpAtCell(p.cellX, p.cellY) then return false end local Gen2Commands = require("src.script.Gen2Commands") local minutes = math.floor( require("src.core.SaveData").playSeconds(Game.save) / 60) local id, script = Gen2Commands.rollIncomingCall( Game.data, Game.save, self.map.def, self:timeOfDay(), minutes, false) - if not script then return end + if not script then return false end Game.save.g2CurCaller = id -- Phone_StartRinging (36:$4337) before the script, Phone_CallEnd after it pcall(function() require("src.core.Sound").play(Game.data, "Call") end) @@ -3343,6 +3344,7 @@ function OverworldState:checkIncomingPhoneCall() Game.save.g2CurCaller = nil pcall(function() require("src.core.Sound").play(Game.data, "Hang_Up") end) end }) + return true end function OverworldState:update(dt) @@ -3675,7 +3677,7 @@ function OverworldState:update(dt) -- a standing instruction ("wPlayerState is PLAYER_BIKE while this is set"), -- not an event, and the check is two flag reads. self:applyForcedBike() - self:checkSpecialPhoneCall() + if not require("src.world.Gen2StepEvents").vanilla() then self:checkSpecialPhoneCall() end self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -12347,14 +12349,28 @@ end -- SFX_POISONED; fainted mons get their message; a whole-party faint -- blacks out like a lost battle. Returns true when the step should -- stop (a text box is up). -function OverworldState:applyFieldPoison() +function OverworldState:advanceFieldPoisonCounter(deferReset) local save = Game.save local emerald = GameVersion.get() == "emerald" -- UpdatePoisonStepCounter does not advance while inside a Secret Base. if emerald and self.map and self.map.def and self.map.def.mapType == "SECRET_BASE" then return false end local interval = FieldDefaults.world(Game.data, "poisonStepInterval") or 4 + if deferReset then + save.poisonSteps=((save.poisonSteps or 0)+1)%256 + return save.poisonSteps>=interval + end save.poisonSteps = ((save.poisonSteps or 0) + 1) % interval - if save.poisonSteps ~= 0 then return false end + return save.poisonSteps == 0 +end + +function OverworldState:applyFieldPoison() + if not self:advanceFieldPoisonCounter() then return false end + return self:applyFieldPoisonDamage() +end + +function OverworldState:applyFieldPoisonDamage() + local save = Game.save + local emerald = GameVersion.get() == "emerald" local damage = FieldDefaults.world(Game.data, "poisonDamage") or 1 -- GEN 4 CHANGED THIS RULE AND THE PORT WAS STILL PLAYING GEN 2'S. -- @@ -13041,6 +13057,67 @@ function OverworldState:checkWarpOnArrive() return false end +-- Native Gen2 CountStep, after warp/coordinate ownership and before encounters. +-- Existing save counters and presentation remain authoritative. A repeat uses +-- the same carry verdict without ticking again; hatch retains pending poison. +function OverworldState:countStep() + local p=self.player + if not GameVersion.isGen2() or not (p and self.map and self.runner and Game and Game.data and Game.save) + or p.moving then return false end + if self:busy() then return true end -- an actual script/overlay already owns this frame + self:noteCellChange() + local Steps=require("src.world.Gen2StepEvents") + if Steps.matches(self.gen2CoordVisit,self,Game) then return true end + if Steps.matches(self.gen2StepVisit,self,Game) then return self.gen2StepVisit.blocked end + local visit=Steps.visit(self,Game);self.gen2StepVisit=visit + local function stop()visit.blocked=true;return true end + if Steps.vanilla() and self:checkSpecialPhoneCall() then return stop() end + local save=Game.save + if Steps.vanilla() and save.repelSteps and save.repelSteps>0 then + save.repelSteps=save.repelSteps-1 + if save.repelSteps==0 then + Game.stack:push(TextBox.new(Game,Strings("REPEL's effect\nwore off."))) + return stop() + end + end + self.todSteps=(self.todSteps or 0)+1 + require("src.world.PikachuFollower").onStep(save) + require("src.world.HgssFollowMon").onStep(Game,self) + if not require("src.pokemon.Gen2Friendship").isVanilla() and self.todSteps%128==0 then + for _,mon in ipairs(save.party or {}) do require("src.pokemon.Evolution").changeHappiness(mon,"WALKING") end + end + if Runtime.wantsHook("world.tod") then self:timeOfDay() end + if self:safariStep() then return stop() end + local due + if Steps.vanilla() then + due=self:advanceFieldPoisonCounter(true) + if self:stepEggs() then return stop() end + end + if save.daycare and save.daycare.mon then + save.daycare.steps=(save.daycare.steps or 0)+(FieldDefaults.world(Game.data,"daycareExpPerStep") or 1) + end + require("src.pokemon.DayCare").step(Game.data,save,FieldDefaults.world(Game.data,"daycareExpPerStep") or 1) + if Steps.vanilla() then + if due then + save.poisonSteps=0 + if self:applyFieldPoisonDamage() then return stop() end + end + Steps.bikeStep(save,self:movementState(),self.map.def) + else + -- Preserve non-vanilla Gen2 datasets' own per-step egg and poison rules. + if self:applyFieldPoison() or self:stepEggs() then return stop() end + if save.repelSteps and save.repelSteps>0 then + save.repelSteps=save.repelSteps-1 + if save.repelSteps==0 then + Game.stack:push(TextBox.new(Game,Strings("REPEL's effect\nwore off."))) + return stop() + end + end + end + if self:checkIncomingPhoneCall() then return stop() end + return false +end + function OverworldState:onStepComplete() local p = self.player -- THE DISTORTION WORLD'S COORDINATE EVENTS fire where a step ends @@ -13145,6 +13222,7 @@ function OverworldState:onStepComplete() p.elevation = self.map:elevationAfter(p.elevation, p.cellX, p.cellY, from and from.x, from and from.y) self.elevationFrom = { map = self.map, x = p.cellX, y = p.cellY } end + if not GameVersion.isGen2() then self.todSteps = (self.todSteps or 0) + 1 -- UpdatePikachuHappinessAndMood rides the step counter (poison.asm) require("src.world.PikachuFollower").onStep(Game.save) @@ -13170,6 +13248,8 @@ function OverworldState:onStepComplete() self:timeOfDay() end + end + -- hot path: the payload is only built when something is listening if Runtime.wants("world.stepped") then Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY, @@ -13221,7 +13301,7 @@ function OverworldState:onStepComplete() self:gen3FieldCallStep() -- CheckPhoneCall rides this same step, right after the warp checks - self:checkIncomingPhoneCall() + if not GameVersion.isGen2() then self:checkIncomingPhoneCall() end -- Route 22 Gate rewrites LAST_MAP by Y before warps/guards fire self:syncLastMapRewrite() @@ -13266,7 +13346,15 @@ function OverworldState:onStepComplete() end end - if GameVersion.isGen2() and self:tryCoordScript() then return end + if GameVersion.isGen2() then + if self:busy() then return true end + self.boulderTried=nil + self:clearWarpCooldownIfLeft() + if self:checkWarpOnArrive() then return true end + if self:tryCoordScript() then return true end + if self:countStep() then return true end + if self:tryWildEncounter() then return true end + end -- AN AUTHORED EVENT THAT DECLARES ITSELF A REPLACEMENT, ahead of the hooks. -- @@ -13443,13 +13531,13 @@ function OverworldState:onStepComplete() if self:checkHgssSpinTiles() then return end -- the Safari game step counter (engine/events/hidden_events/safari_game.asm) - if self:safariStep() then return end + if not GameVersion.isGen2() and self:safariStep() then return end if GameVersion.isGen4() then self:gen4RadarStep() end -- BLACK / WHITE: the phenomena's step roll (src/world/Gen5Phenomena.lua) if GameVersion.isGen5() then require("src.world.Gen5Phenomena").step(self) end -- day-care: the boarded Pokémon gains 1 exp per step (like the original) - if Game.save.daycare and Game.save.daycare.mon then + if not GameVersion.isGen2() and Game.save.daycare and Game.save.daycare.mon then Game.save.daycare.steps = (Game.save.daycare.steps or 0) + (FieldDefaults.world(Game.data, "daycareExpPerStep") or 1) end @@ -13458,18 +13546,19 @@ function OverworldState:onStepComplete() -- and rolls for an egg when the counter's low byte comes round. Without -- this the Route 117 day care took your Pokemon and then stood still -- -- nothing grew a level and no egg was ever laid. - if GameVersion.isGen2() or GameVersion.isGen3() then + if GameVersion.isGen3() then require("src.pokemon.DayCare").step(Game.data, Game.save, FieldDefaults.world(Game.data, "daycareExpPerStep") or 1) end -- out-of-battle poison (engine/events/poison.asm): every 4th step -- each poisoned mon loses 1 HP, with the screen flicker + sound - if self:applyFieldPoison() then return end + if not GameVersion.isGen2() and self:applyFieldPoison() then return end -- an EGG one step from hatching owns the screen the same way - if self:stepEggs() then return end + if not GameVersion.isGen2() and self:stepEggs() then return end + if GameVersion.isGen2() then return end -- shared native count/encounter phases already ran self.boulderTried = nil -- a completed step ends any armed boulder push self:clearWarpCooldownIfLeft() @@ -13499,6 +13588,21 @@ function OverworldState:onStepComplete() end end + return self:tryWildEncounter() +end + +function OverworldState:tryWildEncounter() + local p = self.player + if not (p and self.map and Game and Game.data and Game.save) then return false end + if GameVersion.isGen2() then + if p.moving or self:busy() or not (Game.save.party and #Game.save.party > 0) then return false end + self:noteCellChange() + local Steps = require("src.world.Gen2StepEvents") + if Steps.matches(self.gen2CoordVisit,self,Game) + or (Steps.matches(self.gen2StepVisit,self,Game) and self.gen2StepVisit.blocked) + or Steps.matches(self.gen2EncounterVisit,self,Game) then return false end + self.gen2EncounterVisit=Steps.visit(self,Game) + end -- TryWildEncounter_BugContest (25:$7D64): while the contest is running the -- park rolls its OWN table (ContestMons) and ignores the map's wildmons -- entirely. Guarded on the run being live, so visiting the park outside the @@ -13517,7 +13621,7 @@ function OverworldState:onStepComplete() end self:pushBattle(battle) end - return + return battle ~= nil end end local BugContest = require("src.world.BugContest") @@ -13532,7 +13636,7 @@ function OverworldState:onStepComplete() battle.onFinish = function(result) self:afterBattle(result, battle) end self:pushBattle(battle) end - return + return wild ~= nil end -- wild encounters in grass, on water while surfing, or -- on indoor @@ -13556,9 +13660,9 @@ function OverworldState:onStepComplete() if pyramidWild then encDef = Pyramid.wildTable(Game.save) end -- THE POKE RADAR: stepping into a shaking patch is always an encounter, and -- not one Repel or a roamer can touch (src/world/Gen4Radar.lua) - if GameVersion.isGen4() and self:gen4RadarEncounter(encDef) then return end + if GameVersion.isGen4() and self:gen4RadarEncounter(encDef) then return true end -- BLACK / WHITE: a phenomenon underfoot is its own guaranteed encounter - if GameVersion.isGen5() and require("src.world.Gen5Phenomena").encounter(self, encDef) then return end + if GameVersion.isGen5() and require("src.world.Gen5Phenomena").encounter(self, encDef) then return true end local enc local indoor = Game.data.field.indoorEncounters local env = self.map.def.environment @@ -13618,12 +13722,12 @@ function OverworldState:onStepComplete() if pyramidWild and enc then -- GenerateBattlePyramidWildMon: the slot names an index into the round's list enc = Pyramid.wildFor(Game.data, Game.save, enc) - if not enc then return end + if not enc then return false end elseif pikeWild and enc then -- TryGenerateBattlePikeWildMon(TRUE): its level, its moves, and the lead's -- Keen Eye / Intimidate; no roamer joins enc = Pike.wildFor(Game.data, Game.save, enc) - if not enc then return end + if not enc then return false end elseif enc and GameVersion.isGen3() and (not onWater or GameVersion.get()=="emerald") then -- HOENN'S ROAMER IS ONE, NOT THREE, and the roll is its own: the -- cartridge asks IsRoamerAt first and only then spends a quarter chance, @@ -13705,7 +13809,7 @@ function OverworldState:onStepComplete() local lead = Party.firstHealthy(Game.save.party) or Game.save.party[1] if Game.save.repelSteps and Game.save.repelSteps > 0 and lead and lead.level and enc.level < lead.level then - return + return false end local BattleState = require("src.battle.BattleState") -- BATTLETYPE_ROAMING: Music_SuicuneBattle, the one-turn flee, and the @@ -13735,7 +13839,7 @@ function OverworldState:onStepComplete() Pyramid.afterBattle(Game, self) end self:pushBattle(battle) - return + return true end if pikeWild then Pike.prepareWild(battle, enc) @@ -13744,7 +13848,7 @@ function OverworldState:onStepComplete() self:afterBattle(result, battle) end self:pushBattle(battle) - return + return true end -- BLACK / WHITE: the terrain under the battle, and dark grass's thick- -- grass flag and double (src/world/Gen5WildTables.lua) @@ -13753,12 +13857,13 @@ function OverworldState:onStepComplete() end -- PLATINUM, WALKING WITH A PARTNER: a double, the partner beside you if GameVersion.isGen4() and self:gen4Escorted() and not roamer then - if not self:gen4EscortWild(battle, onWater, land) then return end + if not self:gen4EscortWild(battle, onWater, land) then return false end end battle.onFinish = function(result) self:afterBattle(result, battle) end self:pushBattle(battle) - return + return true end + return false end -- SystemFlag_CheckHasPartner: Cheryl, Mira, Riley, Marley or Buck is walking diff --git a/tests/engine/gen2_count_step.lua b/tests/engine/gen2_count_step.lua new file mode 100644 index 000000000..777791a7a --- /dev/null +++ b/tests/engine/gen2_count_step.lua @@ -0,0 +1,196 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map');local Scripts=require('src.script.MapScripts') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack');local Pokemon=require('src.pokemon.Pokemon') +local DayCare=require('src.pokemon.DayCare');local Sound=require('src.core.Sound');local Music=require('src.core.Music') +local oldSound,oldMusic=Sound.play,Music.play;Sound.play=function()end;Music.play=function()end -- audio devices only +local slots={};for i=1,100 do local n,v=debug.getupvalue(W.onStepComplete,i);if not n then break end;if n=='Game'or n=='mapScripts'then slots[n]={i,v}end end +assert(slots.Game and slots.mapScripts);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],Scripts) + local function fixture() + local data=T.fixtures.fresh();local id='NATIVE_COUNT_'..V.get():upper() + local def={id=id,generation=2,environment=3,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + data.maps[id]=def + local ts={id='COUNT_FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + local p={cellX=0,cellY=0,facing='down',moving=false} + local mon=Pokemon.new(data,'FIXMON_A',10,function(a)return a end);mon.happiness=80 + local save={flags={},party={mon},player={name='TEST',id=1},money=800,inventory={},pokedex={seen={},owned={}}} + local w=setmetatable({map=Map.new(def,ts),player=p,scriptMoves={},entities={p},npcs={},pendingScripts={}},{__index=W}) + local game={data=data,save=save,overworld=w,world=w,stack=setmetatable({states={}}, {__index=Stack})};w.runner=Runner.new(game,w) + debug.setupvalue(W.onStepComplete,slots.Game[1],game) + local pen=DayCare.deposit(save,DayCare.MAN,Pokemon.new(data,'FIXMON_B',10,function(a)return a end)) + local second=DayCare.deposit(save,DayCare.LADY,Pokemon.new(data,'FIXMON_C',10,function(a)return a end)) + -- The two fixture species have no egg-group match, so native pens grow + -- without spending irrelevant breeding RNG in these counter cases. + return w,game,pen,second,mon,ts,def + end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local w,g,pen,second,mon=fixture() + T.eq(w:countStep(),false,version..' ordinary footfall leaves encounter phase available') + T.eq(w.todSteps,1,'actual native walking clock advances once') + T.eq(g.save.g2EggCycleStep,1,'actual existing native egg counter advances') + T.eq(g.save.poisonSteps,1,'actual existing native poison counter advances') + T.eq(pen.steps,1,'real man pen gains one native step') + T.eq(second.steps,1,'real lady pen gains one native step') + T.eq(w:countStep(),false,'repeated normal phase retains its actual non-blocking verdict') + T.eq(pen.steps,1,'repeated phase cannot grow either pen twice') + T.eq(g.save.g2EggCycleStep,1,'repeated phase cannot consume another egg step') + w.player.cellX=1;T.eq(w:countStep(),false,'a physical next cell gets a fresh counter phase') + T.eq(pen.steps,2,'next cell advances real native pen exactly once') + g.save.g2BikeSteps=99 + local Save=require('src.core.SaveData');local restored=assert(Save.decode(Save.encode(g.save))) + T.eq(restored.g2EggCycleStep,2,'native save codec retains the actual egg byte') + T.eq(restored.poisonSteps,2,'native save codec retains the actual poison counter') + T.eq(restored.g2BikeSteps,99,'native save codec retains new bike-shop progress') + T.eq(DayCare.slot(restored,DayCare.MAN).steps,2,'native save codec retains the real day-care pen') + g.save=restored;w.player.cellX=2 + T.eq(w:countStep(),false,'restored native state can count the next physical footfall') + T.eq(DayCare.slot(restored,DayCare.MAN).steps,3,'restored day-care resumes at its previous progress') + w,g,pen,second,mon=fixture();g.save.repelSteps=1;g.save.g2EggCycleStep=127;g.save.poisonSteps=3;mon.status='PSN';local hp=mon.hp + T.eq(w:countStep(),true,'actual Repel expiry owns the footfall') + T.eq(g.save.repelSteps,0,'native Repel counter expires') + T.eq(g.save.g2EggCycleStep,127,'Repel expiry does not advance egg counter') + T.eq(g.save.poisonSteps,3,'Repel expiry does not advance poison counter') + T.eq(pen.steps,0,'Repel expiry does not grow the actual day-care pen') + T.eq(mon.hp,hp,'Repel expiry cannot apply field poison') + T.eq(getmetatable(g.stack:top()),require('src.render.TextBox'),'real native Repel text owns the overlay stack') + T.eq(w:tryWildEncounter(),false,'no encounter runs behind the real Repel owner') + w,g,pen,second,mon=fixture();g.save.repelSteps=2 + T.eq(w:countStep(),false,'non-final Repel footfall still counts') + T.eq(g.save.repelSteps,1,'ordinary Repel decrement remains native') + T.eq(pen.steps,1,'ordinary Repel step grows day-care once') + w,g,pen,second,mon=fixture();mon.status='PSN';g.save.poisonSteps=3;hp=mon.hp + T.eq(w:countStep(),false,'nonlethal native poison leaves encounter phase available') + T.eq(mon.hp,hp-1,'actual native field poison applies one HP') + T.eq(g.save.poisonSteps,0,'poison interval resets after real damage phase') + T.eq(w.poisonFlash,12,'native poison presentation is armed') + T.eq(pen.steps,1,'daycare runs before nonlethal poison') + w,g,pen,second,mon=fixture();mon.status='PSN';mon.hp=1;g.save.poisonSteps=3 + T.eq(w:countStep(),true,'actual poison faint owns the landing') + T.eq(mon.hp,0,'actual poisoned party member faints') + T.eq(mon.status,nil,'native poison faint clears its status') + T.eq(mon.happiness,75,'native friendship penalty is applied') + T.eq(getmetatable(g.stack:top()),require('src.render.TextBox'),'real faint text owns the stack') + T.eq(w:tryWildEncounter(),false,'poison faint cannot also start an encounter') + local faint=g.stack:pop();faint.onDone() + T.eq(getmetatable(g.stack:top()),require('src.render.TextBox'),'actual faint continuation presents native blackout text') + local blackout=g.stack:pop();blackout.onDone() + T.eq(g.save.money,400,'actual native field whiteout halves the existing wallet') + T.eq(mon.hp,mon.stats.hp,'native whiteout heals the actual party member') + T.eq(getmetatable(g.stack:top()),require('src.render.Transition'),'actual whiteout starts the native return transition') + w,g,pen,second,mon=fixture();mon.status='PSN';g.save.poisonSteps=3;g.save.g2EggCycleStep=127;hp=mon.hp + local egg=Pokemon.new(g.data,'FIXMON_A',5,function(a)return a end);egg.isEgg=true;egg.hp=0;egg.eggSteps=256 + local later=Pokemon.new(g.data,'FIXMON_A',5,function(a)return a end);later.isEgg=true;later.hp=0;later.eggSteps=256 + g.save.party={mon,egg,later} + T.eq(w:countStep(),true,'actual native hatch takes priority over poison on the same footfall') + T.eq(egg.isEgg,nil,'real native hatch converts the first ready egg') + T.eq(egg.happiness,120,'native hatch installs its real happiness') + T.eq(egg.ot,'TEST','native hatch uses actual player ownership') + T.eq(later.eggSteps,256,'later eggs wait for the next native phase') + T.eq(mon.hp,hp,'poison is deferred by real hatch presentation') + T.eq(g.save.poisonSteps,4,'pending poison counter is retained across the hatch') + T.eq(pen.steps,0,'hatch event stops before native day-care gains') + T.eq(getmetatable(g.stack:top()),require('src.render.TextBox'),'actual hatch text owns the stack') + g.stack:pop();w.player.cellX=1 + T.eq(w:countStep(),false,'next physical footfall resumes native chain after hatch') + T.eq(mon.hp,hp-1,'pending poison runs on the next counted footfall') + T.eq(g.save.poisonSteps,0,'deferred poison interval then resets') + w,g,pen,second,mon=fixture() + for i=1,512 do w.player.cellX=i%2;w:countStep()end + T.eq(g.save.g2EggCycleStep,0,'actual native egg byte wraps twice over 512 counted footfalls') + T.eq(g.save.g2HappinessStepCount,0,'native happiness phase wraps with the real egg counter') + T.eq(mon.happiness,81,'actual native StepHappiness raises friendship once per 512 steps') + T.eq(pen.steps,512,'native day-care grows once per footfall through both complete cycles') + -- Native special-call programs are compiled and queued by the existing VM. + w,g,pen,second,mon=fixture();g.data.map_scripts={source='RomExtractorGen2',scripts={CALL={{'setval',61},{'end'}}},specialCalls={[6]={caller=9,script='CALL'}}};g.save.g2SpecialCall=6;g.save.repelSteps=1 + T.eq(w:countStep(),true,'real pending special call wins before Repel and counters') + T.eq(g.save.g2SpecialCall,nil,'native pending id is consumed exactly once') + T.eq(g.save.g2SpecialCallActive,6,'native active-call state is retained for readvar') + T.eq(g.save.repelSteps,1,'special call does not decrement Repel') + T.eq(pen.steps,0,'special call does not grow native day-care') + T.eq(#w.pendingScripts,1,'actual compiled phone script is queued') + local queued=table.remove(w.pendingScripts,1);w.runner:run(queued.script or queued[1] or queued.rows,queued.extra or queued[2]) + T.eq(w.runner.ctx.g2Var,61,'queued native phone program really executes') + -- The bike-shop flag is written by the real native setflag compiler; + -- Crystal's raw index differs from Gold/Silver's. + w,g,pen,second,mon=fixture() + local flagIndex=version=='crystal' and 20 or 19 + g.data.map_scripts={source='RomExtractorGen2',scripts={FLAG={{'setflag',flagIndex},{'end'}},CALL={{'setval',61},{'end'}}},specialCalls={[6]={caller=9,script='CALL'}}} + w.runner:run(require('src.script.Gen2ScriptVM').compile(g.data,'FLAG')) + local bikeFlag=require('src.script.Gen2Flags').engineFlag(19) + T.eq(g.save.flags[bikeFlag],true,'actual VM setflag arms the native bike-shop flag') + g.save.onBike=true;g.save.g2BikeSteps=1023;w.map.def.phoneService=false + T.eq(w:countStep(),false,'a no-service bike footfall still counts ordinary native work') + T.eq(g.save.g2BikeSteps,1023,'no-service map cannot count toward bike-shop call') + w.map.def.phoneService=true;w.player.cellX=1 + T.eq(w:countStep(),false,'arming bike-shop call does not consume its threshold footfall') + T.eq(g.save.g2BikeSteps,1024,'native bike counter reaches the actual threshold') + T.eq(g.save.g2SpecialCall,6,'threshold queues the native bike-shop special-call ID') + T.eq(g.save.flags[bikeFlag],nil,'native flag is consumed after its one call is armed') + T.eq(#w.pendingScripts,0,'bike threshold does not ring before the following footfall') + local grew=pen.steps;w.player.cellX=2 + T.eq(w:countStep(),true,'following footfall hands off to the real imported bike-shop call') + T.eq(#w.pendingScripts,1,'actual native phone program is queued on that following footfall') + T.eq(pen.steps,grew,'ringing footfall does not also grow day-care') + -- Native grid completion invokes count/encounter phases rather than its old copies. + w,g,pen,second,mon=fixture();local calls=0;local count=W.countStep + function w:countStep()calls=calls+1;return count(self)end + W.onStepComplete(w) + T.eq(calls,1,'actual native landing invokes shared CountStep once') + T.eq(pen.steps,1,'native pipeline has no old duplicate day-care call') + T.eq(g.save.g2EggCycleStep,1,'native pipeline has no old duplicate egg call') + T.eq(g.save.poisonSteps,1,'native pipeline has no old duplicate poison call') + -- Native warp and coordinate owners precede the actual shared counter. + w,g,pen,second,mon=fixture() + local def=g.data.maps[w.map.id];local to=def.id..'_DEST' + g.data.maps[to]={id=to,generation=2,environment=3,width=1,height=1,blocks={1},warps={{x=0,y=0,destMap=def.id,destWarp=1}}} + def.warps={{x=0,y=0,destMap=to,destWarp=1}} + w.map=Map.new(def,{id='STAIR',blockCells=2,collision={255,255,255,255,0x72,0,0,0},walkable={0,0x72},grassTiles={},waterTiles={}}) + W.onStepComplete(w) + T.eq(getmetatable(g.stack:top()),require('src.render.Transition'),'actual native warp precedes step counters') + T.eq(pen.steps,0,'native warp landing does not also grow day-care') + T.eq(g.save.g2EggCycleStep,nil,'native warp landing does not advance egg counter') + T.eq(g.save.poisonSteps,nil,'native warp landing does not advance poison counter') + w,g,pen,second,mon=fixture();w.map.def.events={{id='COUNTER_COORD',x=0,y=0,script={{'set_flag','COUNTER_COORD'}}}} + W.onStepComplete(w) + T.eq(g.save.flags.COUNTER_COORD,true,'actual authored coordinate script executes at the native landing') + T.eq(pen.steps,0,'coordinate owner takes priority over native day-care') + T.eq(w:countStep(),true,'synchronous coordinate completion cannot let a repeated phase count that visit') + T.eq(pen.steps,0,'public counter also respects the consumed coordinate visit') + T.eq(w:tryWildEncounter(),false,'synchronous coordinate event cannot fall into a wild encounter') + -- A real native encounter is constructed and dispatched through the + -- real battle transition. Only its RNG and audio are controlled. + w,g,pen,second,mon=fixture() + local grass={id='GRASS',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={0},waterTiles={}} + w.map=Map.new(g.data.maps[w.map.id],grass) + g.data.encounters[w.map.id]={grass={rate=256,slots={{level=5,species='FIXMON_C'}}}} + require('src.world.Encounter').load(g.data);require('src.battle.TypeChart').load(g.data) + local rng=love.math.random;love.math.random=function(a,b)if b then return a elseif a then return 1 else return 0 end end + T.eq(w:countStep(),false,'normal counted grass step leaves a real encounter available') + T.eq(w:countStep(),false,'a repeated counter query still leaves the first encounter query available') + T.eq(w:tryWildEncounter(),true,'actual native encounter phase constructs and dispatches a real battle') + T.eq(getmetatable(g.stack:top()),require('src.render.BattleTransition'),'real battle transition owns the stack') + T.eq(w:tryWildEncounter(),false,'an active native battle transition cannot be duplicated') + g.stack:pop() + T.eq(w:tryWildEncounter(),false,'same physical grass visit cannot roll a second encounter') + T.eq(pen.steps,1,'encounter phase never duplicates native step counters') + love.math.random=rng + g.stack:push({});w.player.cellX=1;local before=pen.steps + T.eq(w:countStep(),true,'real overlay ownership stops the public count chain') + T.eq(pen.steps,before,'overlay cannot grow day-care behind itself');g.stack:pop() + w.player.moving=true;T.eq(w:countStep(),false,'unfinished movement cannot invent a completed footfall') +end +for _,version in ipairs({'prism','polishedcrystal'})do + V.set(version) + local w,g,pen,second,mon=fixture() + local egg=Pokemon.new(g.data,'FIXMON_A',5,function(a)return a end);egg.isEgg=true;egg.hp=0;egg.eggSteps=2 + g.save.party={mon,egg} + T.eq(w:countStep(),false,version..' retains its existing per-step fallback') + T.eq(egg.eggSteps,1,'non-vanilla egg counter advances by one per real footfall') + T.eq(g.save.g2EggCycleStep,nil,'non-vanilla fallback is not given a stock GSC egg byte') + T.eq(pen.steps,1,'non-vanilla native pen grows once') + T.eq(g.save.g2BikeSteps,nil,'non-vanilla game is not given a stock bike-shop call counter') +end +V.set(original);Sound.play=oldSound;Music.play=oldMusic +debug.setupvalue(W.onStepComplete,slots.Game[1],slots.Game[2]);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],slots.mapScripts[2]) +T.finish('shared native Gen2 CountStep counters and event priority') From ba87267e92b2743d5dd2bf892f41412671e46b2b Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 05:41:15 +0100 Subject: [PATCH 08/34] Preserve legacy non-Gen2 completed-step return behavior --- src/world/OverworldController.lua | 2 +- tests/engine/gen2_count_step.lua | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e646c073e..5dec898dd 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -13588,7 +13588,7 @@ function OverworldState:onStepComplete() end end - return self:tryWildEncounter() + self:tryWildEncounter() end function OverworldState:tryWildEncounter() diff --git a/tests/engine/gen2_count_step.lua b/tests/engine/gen2_count_step.lua index 777791a7a..6caec2d47 100644 --- a/tests/engine/gen2_count_step.lua +++ b/tests/engine/gen2_count_step.lua @@ -191,6 +191,11 @@ for _,version in ipairs({'prism','polishedcrystal'})do T.eq(pen.steps,1,'non-vanilla native pen grows once') T.eq(g.save.g2BikeSteps,nil,'non-vanilla game is not given a stock bike-shop call counter') end +for _,version in ipairs({'red','firered','emerald'})do + V.set(version);local w,g=fixture() + g.input={isDown=function()return false end,wasPressed=function()return false end} + T.eq(W.onStepComplete(w),nil,version..' retains its legacy native completed-step return contract') +end V.set(original);Sound.play=oldSound;Music.play=oldMusic debug.setupvalue(W.onStepComplete,slots.Game[1],slots.Game[2]);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],slots.mapScripts[2]) T.finish('shared native Gen2 CountStep counters and event priority') From e602ea72509739a2ef091d31f9eb0f0862c18b31 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 06:03:57 +0100 Subject: [PATCH 09/34] Retire earlier Gen2 landing phases after native count and encounters --- src/world/OverworldController.lua | 9 +- .../engine/gen2_landing_phase_retirement.lua | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/engine/gen2_landing_phase_retirement.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 5dec898dd..c22a3daa1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -12725,6 +12725,12 @@ function OverworldState:tryCoordScript() if not GameVersion.isGen2() or not (p and map and mapScripts and self.runner and Game and Game.data and Game.save) or p.moving or self:busy() then return false end self:noteCellChange() + -- A completed later phase retires this landing even when its script only + -- changes the scene. Idle/script-end re-asks must not run backwards through + -- coord events; actual scripted movement still gets a fresh cell serial. + local Steps = require("src.world.Gen2StepEvents") + if Steps.matches(self.gen2StepVisit,self,Game) + or Steps.matches(self.gen2EncounterVisit,self,Game) then return false end local old = self.gen2CoordVisit if old and old.mapId == map.id and old.save == Game.save and old.data == Game.data and old.serial == self.cellSerial then return false end @@ -13067,7 +13073,8 @@ function OverworldState:countStep() if self:busy() then return true end -- an actual script/overlay already owns this frame self:noteCellChange() local Steps=require("src.world.Gen2StepEvents") - if Steps.matches(self.gen2CoordVisit,self,Game) then return true end + if Steps.matches(self.gen2CoordVisit,self,Game) + or Steps.matches(self.gen2EncounterVisit,self,Game) then return true end if Steps.matches(self.gen2StepVisit,self,Game) then return self.gen2StepVisit.blocked end local visit=Steps.visit(self,Game);self.gen2StepVisit=visit local function stop()visit.blocked=true;return true end diff --git a/tests/engine/gen2_landing_phase_retirement.lua b/tests/engine/gen2_landing_phase_retirement.lua new file mode 100644 index 000000000..e7052caa6 --- /dev/null +++ b/tests/engine/gen2_landing_phase_retirement.lua @@ -0,0 +1,97 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map');local Scripts=require('src.script.MapScripts') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local Pokemon=require('src.pokemon.Pokemon');local Sound=require('src.core.Sound');local Music=require('src.core.Music') +local oldSound,oldMusic=Sound.play,Music.play;Sound.play=function()end;Music.play=function()end -- device output only +local slots={} +for i=1,100 do local n,v=debug.getupvalue(W.onStepComplete,i);if not n then break end;if n=='Game'or n=='mapScripts'then slots[n]={i,v}end end +assert(slots.Game and slots.mapScripts);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],Scripts) +local fixtureSerial=0 +local function fixture(grass) + fixtureSerial=fixtureSerial+1 + local id='PHASE_RETIRE_'..V.get():upper()..'_'..fixtureSerial;local data=T.fixtures.fresh() + local def={id=id,generation=2,environment=3,width=2,height=1,blocks={1,1},warps={},phoneService=true,mapPalette=1} + data.maps[id]=def + data.map_scripts={source='RomExtractorGen2',scripts={COORD={{'setval',42},{'end'}},ARM={{'setscene',1},{'end'}},CALL={{'setscene',1},{'pause',2},{'specialphonecall',0},{'end'}}},specialCalls={[6]={caller=2,script='CALL'}},maps={[id]={coords={{x=0,y=0,scene=1,script='COORD'},{x=1,y=0,scene=1,script='COORD'}}}}} + T.eq(VM.register(data,'scenes'),1,'real coordinate program is registered') + local ts={id='PHASE_FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles=grass and {0}or{},waterTiles={}} + local p={cellX=0,cellY=0,facing='down',moving=false} + local w=setmetatable({map=Map.new(def,ts),player=p,scriptMoves={},entities={p},npcs={},pendingScripts={}},{__index=W}) + local g={data=data,save={flags={},party={Pokemon.new(data,'FIXMON_A',10,function(a)return a end)},g2Scenes={[id]=0},player={name='TEST',id=1},money=100,inventory={},pokedex={seen={},owned={}}},world=w,overworld=w,stack=setmetatable({states={}},{__index=Stack})} + w.runner=Runner.new(g,w);debug.setupvalue(W.onStepComplete,slots.Game[1],g) + local starts=0;local run=w.runner.run + function w.runner:run(...)starts=starts+1;return run(self,...)end + local function arm()w.runner:run(VM.compile(data,'ARM'));T.eq(g.save.g2Scenes[id],1,'real native script changes scene at the same cell')end + local function nextCell() + p.cellX=1;T.eq(w:checkCoordEventHere(true),true,'physical movement permits the newly eligible native coordinate event') + T.eq(w.runner.ctx.g2Var,42,'next physical visit executes the real compiled coordinate program') + end + return w,g,arm,nextCell,function()return starts end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local w,g,arm,nextCell,starts=fixture() + g.save.g2SpecialCall=6 + T.eq(W.onStepComplete(w),true,version..' real pending call consumes the completed step') + T.eq(#w.pendingScripts,1,'actual native call is queued') + local queued=table.remove(w.pendingScripts,1);w.runner:run(queued.script,queued.extra) + T.eq(g.save.g2Scenes[w.map.id],1,'actual phone script changes native scene without moving the player') + T.eq(w.runner:isRunning(),true,'actual phone script is suspended at its pause') + T.eq(w:checkCoordEventHere(true),false,'phone script owns the cell while running') + for _=1,4 do w.runner:update()end + T.eq(w.runner:isRunning(),false,'actual native call script finishes') + T.eq(g.save.g2SpecialCallActive,nil,'actual specialphonecall command cleans up its active ID') + local before=starts() + T.eq(w:checkCoordEventHere(true),false,'completed call cannot reopen the earlier coordinate phase on this visit') + T.eq(starts(),before,'no coordinate VM starts after the later phase already owned this landing') + T.eq(g.save.g2EggCycleStep,nil,'phone-owned landing never advances native count work') + nextCell() + w,g,arm,nextCell,starts=fixture() + T.eq(w:tryCoordScript(),false,'original scene is ineligible before native count') + T.eq(w:countStep(),false,'ordinary real native count completes') + T.eq(g.save.g2EggCycleStep,1,'ordinary native egg counter advances once') + arm();before=starts() + T.eq(w:checkCoordEventHere(true),false,'nonblocking count also retires the earlier coordinate phase') + T.eq(starts(),before,'same-cell script completion cannot invent an earlier coordinate landing') + nextCell() + -- Retire a real encounter claim too. Only its RNG/audio are controlled; + -- the existing native BattleState and BattleTransition are constructed. + w,g,arm,nextCell,starts=fixture(true) + g.data.encounters[w.map.id]={grass={rate=256,slots={{level=5,species='FIXMON_C'}}}} + require('src.world.Encounter').load(g.data);require('src.battle.TypeChart').load(g.data) + local rng=love.math.random;love.math.random=function(a,b)if b then return a elseif a then return 1 else return 0 end end + T.eq(w:tryWildEncounter(),true,'real native encounter accepts the completed cell') + T.eq(getmetatable(g.stack:top()),require('src.render.BattleTransition'),'actual battle transition owns the stack') + g.stack:pop() -- release presentation ownership; this is not full battle-return acceptance + arm();before=starts() + T.eq(w:countStep(),true,'public count cannot advance backwards after encounter retirement') + T.eq(g.save.g2EggCycleStep,nil,'encounter-first integration call cannot later tick native counters') + T.eq(w:checkCoordEventHere(true),false,'released encounter cannot reopen the earlier coordinate phase') + T.eq(starts(),before,'encounter-owned visit cannot start a late coordinate program') + love.math.random=rng;nextCell() + w,g,arm,nextCell,starts=fixture(true) + g.data.encounters[w.map.id]={grass={rate=0,slots={{level=5,species='FIXMON_C'}}}} + require('src.world.Encounter').load(g.data) + T.eq(w:tryWildEncounter(),false,'real zero-rate encounter returns without a battle') + arm();before=starts() + T.eq(w:countStep(),true,'a completed nonmatching encounter roll also retires earlier count work') + T.eq(g.save.g2EggCycleStep,nil,'zero-rate encounter cannot count the cell afterwards') + T.eq(w:checkCoordEventHere(true),false,'a completed nonmatching encounter retires earlier coordinate work') + T.eq(starts(),before,'zero-rate encounter cannot reopen a coordinate script') + nextCell() + w,g,arm,nextCell,starts=fixture() + T.eq(w:countStep(),false,'native count initially claims its save context') + arm() + local Save=require('src.core.SaveData');g.save=assert(Save.decode(Save.encode(g.save))) + T.eq(w:checkCoordEventHere(true),true,'actual save-codec replacement retires the old count ownership context') + T.eq(w.runner.ctx.g2Var,42,'replacement save can execute its eligible coordinate program') + -- A nonmatching coordinate query itself is not a completed landing phase. + w,g,arm,nextCell,starts=fixture() + T.eq(w:tryCoordScript(),false,'ineligible coordinate-only query starts no program') + arm();T.eq(w:tryCoordScript(),true,'before later phases claim the visit, eligible coordinate retry remains allowed') + T.eq(w.runner.ctx.g2Var,42,'eligible retry still reaches actual native VM') +end +V.set(original);Sound.play=oldSound;Music.play=oldMusic +debug.setupvalue(W.onStepComplete,slots.Game[1],slots.Game[2]);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],slots.mapScripts[2]) +T.finish('native Gen2 landing phases retire earlier deferred work') From 2cf604ffbe7f491fc64a839a79c688d2b1f0970c Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 06:30:32 +0100 Subject: [PATCH 10/34] Back native Gen2 random encounter gates, script switches and entry grace --- src/script/Gen2Commands.lua | 13 ++- src/script/Gen2ScriptVM.lua | 10 +- src/world/Gen2EncounterRules.lua | 17 ++++ src/world/OverworldController.lua | 52 +++++++++- tests/engine/gen2_wild_encounter_cooldown.lua | 93 ++++++++++++++++++ tests/engine/gen2_wild_encounter_events.lua | 97 +++++++++++++++++++ 6 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 tests/engine/gen2_wild_encounter_cooldown.lua create mode 100644 tests/engine/gen2_wild_encounter_events.lua diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..ed9793149 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -23,6 +23,11 @@ local Strings = require("src.core.Strings") local Gen2Commands = {} +function Commands.g2_wild_encounters(ctx, enabled) + local ow=ctx.overworld + if ow and ow.setWildEncounters then ow:setWildEncounters(enabled) end +end + local function scriptVar(ctx) return ctx.g2Var or 0 end @@ -1768,6 +1773,7 @@ end -- `reloadmapafterbattle`, and branch on the result themselves. The canLose -- guard is belt and braces for a mod that pairs them anyway. function Commands.g2_after_battle(ctx) + if ctx.overworld and ctx.overworld.armWildCooldown then ctx.overworld:armWildCooldown() end if ctx.lastBattleResult == "lose" and not ctx.lastBattleCanLose then return math.huge end @@ -3952,9 +3958,12 @@ function Commands.g2_changemap(ctx, blocks) end end --- refreshmap / reloadmap / newloadmap / reanchormap: redraw the loaded map. -function Commands.g2_refreshmap(ctx) +-- Refresh-only operations redraw without entering the map; setup/reload +-- operations also rearm EnterMap's native random-encounter grace interval. +-- Their existing object/graphics refresh remains the same here. +function Commands.g2_refreshmap(ctx, setup) local ow = ctx.overworld + if setup and ow and ow.armWildCooldown then ow:armWildCooldown() end if ow and ow.map and ow.map.renderer then ow.map.renderer:rebuild() end Commands.reload_map_objects(ctx) end diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..b1a16fe46 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -186,7 +186,6 @@ L.ifequalfwd = compare("eq") -- -- opentext/closetext the port's show_text owns its own box -- waitbutton/promptbutton show_text already waits for A --- wildon/wildoff encounters are gated by the map, not a script flag -- loademote showemote carries the emote id itself -- encountermusic the battle intro picks its own track -- deactivatefacing facing locks are not modelled @@ -194,7 +193,7 @@ L.ifequalfwd = compare("eq") -- delcmdqueue the cmd queue only ever holds the stone table local function noop() end for _, name in ipairs({ - "opentext", "closetext", "waitbutton", "promptbutton", "wildon", "wildoff", + "opentext", "closetext", "waitbutton", "promptbutton", "loademote", "encountermusic", "deactivatefacing", "writeunusedbyte", "delcmdqueue", -- fieldmovepokepic farcalls FieldMovePokepicScript (2c:$5189), which opens @@ -212,6 +211,9 @@ for _, name in ipairs({ L[name] = noop end +L.wildon = function(_,s) emit(s,{"g2_wild_encounters",true}) end +L.wildoff = function(_,s) emit(s,{"g2_wild_encounters",false}) end + -- Script_checkjustbattled: true when the script was re-entered straight out -- of a won battle, which is the same flag g2_endifjustbattled reads. L.checkjustbattled = function(_, s) emit(s, { "g2_check_just_battled" }) end @@ -2226,8 +2228,8 @@ end -- pass, reanchormap re-centres it -- and without them a block a script -- rewrote stayed invisible until the player walked out and back in. L.refreshmap = function(_, s) emit(s, { "g2_refreshmap" }) end -L.reloadmap = L.refreshmap -L.newloadmap = L.refreshmap +L.reloadmap = function(_, s) emit(s, { "g2_refreshmap", true }) end +L.newloadmap = L.reloadmap L.reanchormap = L.refreshmap -- warpcheck re-tests the tile the player is standing on, which is how the -- Ruins of Alph floor opens underfoot rather than on the next step. diff --git a/src/world/Gen2EncounterRules.lua b/src/world/Gen2EncounterRules.lua index 880904a0d..659f248ea 100644 --- a/src/world/Gen2EncounterRules.lua +++ b/src/world/Gen2EncounterRules.lua @@ -3,6 +3,23 @@ local function vanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end +-- CanEncounterWildMon: CheckGrassCollision is skipped in caves/dungeons, +-- but CheckIceTile and STATUSFLAGS_NO_WILD_ENCOUNTERS_F apply everywhere. +-- The live map supplies its imported/authored encounter-class verdict. The +-- default is the cartridge's list for callers holding only a collision byte. +local ENCOUNTER_COLLISION = { + [0x08]=true, [0x18]=true, [0x14]=true, [0x28]=true, [0x29]=true, + [0x48]=true, [0x49]=true, [0x4a]=true, [0x4b]=true, [0x4c]=true, +} +function Rules.canEncounterWildMon(environment, collision, disabled, encounterCollision) + if disabled or type(collision)~="number" or collision<0 then return false end + collision=collision%256 + if collision==0x23 or collision==0x2b then return false end + if environment==4 or environment==7 or environment=="CAVE" or environment=="DUNGEON" then return true end + if encounterCollision~=nil then return encounterCollision==true end + return ENCOUNTER_COLLISION[collision]==true +end + -- ApplyMusicEffectOnEncounterRate precedes Cleanse Tag; SLA wraps a byte. function Rules.rate(data, save, rate, song) if vanilla() then diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index c22a3daa1..ec8f367e8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -78,6 +78,32 @@ function OverworldState:applyPlayerState(state) return require("src.world.Gen2PlayerState").apply(self.player, Game and Game.save, state) end +-- Script_wildon/wildoff change a transient field bit, not a saved event flag. +-- Explicit script-controlled battles do not consult this random-battle gate. +function OverworldState:setWildEncounters(enabled) + if not GameVersion.isGen2() then return nil, "not Gen2" end + if type(enabled) ~= "boolean" then return nil, "expected encounter enable boolean" end + self.noWildEncounters = not enabled + return true +end + +-- EnterMap's five-step counter protects four completed encounter phases; +-- the fifth is allowed to roll. Public encounter polling consumes it only +-- after the physical-visit guard below, so repeats cannot shorten it. +function OverworldState:armWildCooldown() + if not GameVersion.isGen2() then return false end + self.wildCooldown = 5 + return true +end + +function OverworldState:wildCooldownStep() + if not GameVersion.isGen2() then return false end + local left = self.wildCooldown or 0 + if left <= 0 then return false end + self.wildCooldown = left - 1 + return left > 1 +end + -- This engine's live world is the module itself (StateStack.push enters it -- directly). A computed property keeps every native script/warp change -- observable immediately without maintaining another saved state field. @@ -1026,6 +1052,8 @@ function OverworldState:enter(mapId, x, y, facing) -- A fresh world entry may reuse this singleton and the same save/data. -- It starts a new coordinate visit; a same-map graphics rebuild does not. self.gen2CoordVisit, self.gen2StepVisit, self.gen2EncounterVisit, self.lastCellMapId = nil, nil, nil, nil + self.noWildEncounters, self.wildCooldown = nil, nil + if GameVersion.isGen2() then self.noWildEncounters = false end self.pendingScripts = {} self.parallelRunners = {} self.parallelQueue = {} @@ -1194,6 +1222,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) end end self.map = MapLoader.load(Game.data, mapId) + if GameVersion.isGen2() then self:armWildCooldown() end if GameVersion.isGen3() then require("src.world.Gen3SecretBase").restoreEntrance(Game.data, Game.save, self.map) end @@ -13602,13 +13631,20 @@ function OverworldState:tryWildEncounter() local p = self.player if not (p and self.map and Game and Game.data and Game.save) then return false end if GameVersion.isGen2() then - if p.moving or self:busy() or not (Game.save.party and #Game.save.party > 0) then return false end + if p.moving or self:busy() or not self.map:inBounds(p.cellX,p.cellY) then return false end self:noteCellChange() local Steps = require("src.world.Gen2StepEvents") if Steps.matches(self.gen2CoordVisit,self,Game) or (Steps.matches(self.gen2StepVisit,self,Game) and self.gen2StepVisit.blocked) or Steps.matches(self.gen2EncounterVisit,self,Game) then return false end self.gen2EncounterVisit=Steps.visit(self,Game) + if self:wildCooldownStep() then return false end + if not (Game.save.party and #Game.save.party>0) then return false end + if self.noWildEncounters then return false end + if self.map:speaksGen2Collision() + and not require("src.world.Gen2EncounterRules").canEncounterWildMon( + self.map.def.environment, self.map:cellTile(p.cellX,p.cellY), + self.noWildEncounters, self.map:isEncounterCell(p.cellX,p.cellY)) then return false end end -- TryWildEncounter_BugContest (25:$7D64): while the contest is running the -- park rolls its OWN table (ContestMons) and ignores the map's wildmons @@ -13679,8 +13715,17 @@ function OverworldState:tryWildEncounter() local bwDark = GameVersion.isGen5() and encDef and encDef.darkGrass and require("src.world.Gen5FieldRules").isDarkGrass(self.map, p.cellX, p.cellY) or false if bwDark then land = { grass = encDef.darkGrass } end - local onWater = (p.surfing and self.map:isWaterCell(p.cellX, p.cellY)) or false - if pikeWild or pyramidWild then + local nativeGen2 = GameVersion.isGen2() and self.map:speaksGen2Collision() + local onWater = ((nativeGen2 or p.surfing) and self.map:isWaterCell(p.cellX, p.cellY)) or false + if nativeGen2 then + -- CheckOnWater selects the table by permission, independently of the + -- player's sprite/state. Missing water data must not reuse cave grass. + if onWater then + enc = self:rollEncounter(encDef and {grass=encDef.water}, "water") + else + enc = self:rollEncounter(land, CAVE_ENVIRONMENTS[env] and "indoor" or "grass") + end + elseif pikeWild or pyramidWild then -- ...on EVERY tile of the room: StandardWildEncounter's Pike and Pyramid -- arms ask no metatile behaviour, only whether this is a new tile enc = self:rollEncounter(land, "grass") @@ -14507,6 +14552,7 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if GameVersion.isGen2() then self:armWildCooldown() end self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish diff --git a/tests/engine/gen2_wild_encounter_cooldown.lua b/tests/engine/gen2_wild_encounter_cooldown.lua new file mode 100644 index 000000000..3c1634263 --- /dev/null +++ b/tests/engine/gen2_wild_encounter_cooldown.lua @@ -0,0 +1,93 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local versionBefore=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack');local Pokemon=require('src.pokemon.Pokemon') +local Sound=require('src.core.Sound');local Music=require('src.core.Music');local oldSound,oldMusic=Sound.play,Music.play +Sound.play=function()end;Music.play=function()end -- device output only +local slots={} +for i=1,100 do local n,v=debug.getupvalue(W.onStepComplete,i);if not n then break end;if n=='Game'or n=='mapScripts'then slots[n]={i,v}end end +assert(slots.Game and slots.mapScripts);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],require('src.script.MapScripts')) +local randomBefore=love.math.random;local rngCalls=0 +love.math.random=function(a,b)rngCalls=rngCalls+1;if b then return a elseif a then return 1 else return 0 end end +local sequence=0 +local function fixture() + sequence=sequence+1;local data=T.fixtures.fresh();local id='NATIVE_WILD_GRACE_'..sequence + local def={id=id,generation=2,environment=2,width=4,height=1,blocks={1,1,1,1},warps={}} + local ts={id='GRASS',blockCells=2,collision={255,255,255,255,0x18,0x18,0x18,0x18},walkable={0x18},grassTiles={0x18},waterTiles={},shoreTiles={}} + data.maps[id]=def;data.encounters[id]={grass={rate=256,slots={{level=5,species='FIXMON_C'}}}} + data.map_scripts={source='RomExtractorGen2',maps={},scripts={OFF={{'wildoff'},{'end'}},ON={{'wildon'},{'end'}},WAIT={{'pause',2},{'end'}},REFRESH={{'refreshmap'},{'end'}},RELOAD={{'reloadmap'},{'end'}},NEW={{'newloadmap',0xf3},{'end'}},AFTER={{'reloadmapafterbattle'},{'end'}},ANCHOR={{'reanchormap'},{'end'}}}} + local p={cellX=0,cellY=0,facing='down',moving=false} + local w=setmetatable({map=Map.new(def,ts),player=p,scriptMoves={},entities={p},npcs={},pendingScripts={},parallelRunners={}},{__index=W}) + local g={data=data,save={flags={},party={Pokemon.new(data,'FIXMON_A',10,function(a)return a end)},player={name='TEST',id=1},money=100,inventory={},pokedex={seen={},owned={}}},world=w,overworld=w,stack=setmetatable({states={}},{__index=Stack})} + w.runner=Runner.new(g,w);debug.setupvalue(W.onStepComplete,slots.Game[1],g) + require('src.world.Encounter').load(data);require('src.battle.TypeChart').load(data) + local push=W.pushBattle + function w:pushBattle(battle,opts)self.lastNativeBattle=battle;return push(self,battle,opts)end + local function release()while g.stack:top()do g.stack:pop()end end + local function script(key)w.runner:run(VM.compile(data,key))end + return w,g,release,script +end +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + V.set(version) + local w,g,release,script=fixture();w.wildCooldown=5 -- restored native map-entry progress + for i=1,4 do + w.player.cellX=i-1;local before=rngCalls + T.eq(w:tryWildEncounter(),false,version..' native map-entry grace protects each of the first four encounter steps') + T.eq(w.wildCooldown,5-i,'completed native encounter phase consumes exactly one grace step') + T.eq(rngCalls,before,'grace step spends no encounter/Pokemon RNG') + T.eq(w:tryWildEncounter(),false,'same physical visit cannot consume another grace step') + T.eq(w.wildCooldown,5-i,'repeated encounter polling keeps its remaining grace') + release() + end + w.player.cellX=4 + T.eq(w:tryWildEncounter(),true,'fifth native encounter step can dispatch its real battle') + T.eq(w.wildCooldown,0,'fifth step completes the native countdown') + T.eq(getmetatable(g.stack:top()),require('src.render.BattleTransition'),'actual native encounter reaches the real transition') + local battle=w.lastNativeBattle;release();w.wildCooldown=0 + w:afterBattle('run',battle) + T.eq(w.wildCooldown,5,'actual native afterBattle path rearms map-return grace') + T.eq(g.save.wildCooldown,nil,'map-return countdown does not add persistent save state') + -- Scripts and overlays cannot consume a completed encounter step while + -- they own the world; neither can a moving/out-of-bounds player. + for _,owner in ipairs({'script','overlay','moving','bounds'})do + w,g,release,script=fixture();w.wildCooldown=5 + if owner=='script'then script('WAIT')elseif owner=='overlay'then g.stack:push({})elseif owner=='moving'then w.player.moving=true else w.player.cellX=-1 end + T.eq(w:tryWildEncounter(),false,'native '..owner..' context cannot consume a landing') + T.eq(w.wildCooldown,5,'ineligible ownership keeps the real grace byte') + if owner=='script'then for _=1,4 do w.runner:update()end elseif owner=='overlay'then release()elseif owner=='moving'then w.player.moving=false else w.player.cellX=0 end + T.eq(w:tryWildEncounter(),false,'released valid context consumes its first protected landing') + T.eq(w.wildCooldown,4,'deferred eligibility does not lose a grace step') + release() + end + w,g,release,script=fixture();w.wildCooldown=5;script('OFF') + for i=1,5 do + w.player.cellX=i-1;T.eq(w:tryWildEncounter(),false,'wildoff still refuses a real random battle') + T.eq(w.wildCooldown,5-i,'grace drains before the native wildoff gate') + end + script('ON');w.player.cellX=5 + T.eq(w:tryWildEncounter(),true,'wildon after five completed disabled steps needs no second grace interval') + release() + w,g,release,script=fixture();w.wildCooldown=5;local party=g.save.party;g.save.party={} + for i=1,5 do w.player.cellX=i-1;T.eq(w:tryWildEncounter(),false,'no-party footfall cannot start a battle');T.eq(w.wildCooldown,5-i,'grace precedes native party eligibility')end + g.save.party=party;w.player.cellX=5 + T.eq(w:tryWildEncounter(),true,'a newly received party can encounter after the already-drained interval') + release() + w,g,release,script=fixture();w.wildCooldown=5;g.save.repelSteps=1 + T.eq(w:countStep(),true,'real Repel-expiry event owns its native footfall') + T.eq(w:tryWildEncounter(),false,'CountStep owner prevents entering the grace phase') + T.eq(w.wildCooldown,5,'event-owned footfall does not drain random-encounter grace') + release();w.player.cellX=1;T.eq(w:countStep(),false,'ordinary following step reaches encounter phase') + T.eq(w:tryWildEncounter(),false,'next valid footfall is the first protected encounter step') + T.eq(w.wildCooldown,4,'native count/encounter order consumes only that following step') + -- Use the real VM/command dispatcher. Refresh-only operations preserve + -- grace; actual map-entry/reload commands rearm it without changing wildoff. + for _,case in ipairs({{'REFRESH',2},{'ANCHOR',2},{'RELOAD',5},{'NEW',5},{'AFTER',5}})do + w,g,release,script=fixture();script('OFF');w.wildCooldown=2;script(case[1]) + T.eq(w.wildCooldown,case[2],'native '..case[1]..' has the map-entry rather than graphics-only grace behavior') + T.eq(w.noWildEncounters,true,'map reload/refresh preserves the native transient wildoff flag') + T.eq(w.runner:isRunning(),false,'actual native reload command finishes its real program') + end +end +love.math.random=randomBefore;Sound.play=oldSound;Music.play=oldMusic;V.set(versionBefore) +debug.setupvalue(W.onStepComplete,slots.Game[1],slots.Game[2]);debug.setupvalue(W.onStepComplete,slots.mapScripts[1],slots.mapScripts[2]) +T.finish('native Gen2 encounter grace, VM reload and battle-return lifecycle') diff --git a/tests/engine/gen2_wild_encounter_events.lua b/tests/engine/gen2_wild_encounter_events.lua new file mode 100644 index 000000000..ed4d9bd91 --- /dev/null +++ b/tests/engine/gen2_wild_encounter_events.lua @@ -0,0 +1,97 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local versionBefore=V.get() +local W=require('src.world.OverworldController');local Map=require('src.world.Map');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack');local Pokemon=require('src.pokemon.Pokemon') +local Sound=require('src.core.Sound');local Music=require('src.core.Music');local oldSound,oldMusic=Sound.play,Music.play +Sound.play=function()end;Music.play=function()end -- audio output only +local slot,oldGame +for i=1,100 do local n,v=debug.getupvalue(W.tryWildEncounter,i);if not n then break end;if n=='Game'then slot,oldGame=i,v;break end end +assert(slot) +local randomBefore=love.math.random;local rngCalls=0 +love.math.random=function(a,b)rngCalls=rngCalls+1;if b then return a elseif a then return 1 else return 0 end end +local sequence=0 +local function fixture(collision,environment,grass,water) + sequence=sequence+1;local data=T.fixtures.fresh();local id='NATIVE_WILD_GATE_'..sequence + local def={id=id,generation=2,environment=environment,width=2,height=1,blocks={1,1},warps={}} + local ts={id='WILD_GATE',blockCells=2,collision={255,255,255,255,collision,collision,collision,collision},walkable={collision},grassTiles=grass or{},waterTiles=water or{},shoreTiles={}} + data.maps[id]=def + data.encounters[id]={grass={rate=256,slots={{level=5,species='FIXMON_C'}}},water={rate=256,slots={{level=5,species='FIXMON_B'}}}} + data.map_scripts={source='RomExtractorGen2',maps={},scripts={OFF={{'wildoff'},{'end'}},ON={{'wildon'},{'end'}}}} + local p={cellX=0,cellY=0,moving=false,facing='down'} + local w=setmetatable({map=Map.new(def,ts),player=p,scriptMoves={},entities={p},npcs={},pendingScripts={}},{__index=W}) + local g={data=data,save={flags={},party={Pokemon.new(data,'FIXMON_A',10,function(a)return a end)},player={name='TEST',id=1},money=100,inventory={},pokedex={seen={},owned={}}},overworld=w,world=w,stack=setmetatable({states={}},{__index=Stack})} + w.runner=Runner.new(g,w);debug.setupvalue(W.tryWildEncounter,slot,g) + require('src.world.Encounter').load(data);require('src.battle.TypeChart').load(data) + local push=W.pushBattle + function w:pushBattle(battle,opts)self.lastNativeBattle=battle;return push(self,battle,opts)end + local function release()while g.stack:top()do g.stack:pop()end end + local function script(key)w.runner:run(VM.compile(data,key))end + return w,g,release,script,ts +end +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + V.set(version) + for _,environment in ipairs({4,7,'CAVE','DUNGEON'})do + for _,ice in ipairs({0x23,0x2b})do + local w,g,release=fixture(ice,environment,{ice}) + local before=rngCalls + T.eq(w:tryWildEncounter(),false,version..' native cave/dungeon ice cannot roll a random battle') + T.eq(rngCalls,before,'ice gate runs before rate, slot or Pokemon RNG') + T.eq(g.stack:top(),nil,'ice cannot push a battle transition') + T.eq(w.lastNativeBattle,nil,'ice cannot materialize a wild battle') + release() + end + end + local w,g,release,script,ts=fixture(0,4) + T.eq(w:tryWildEncounter(),true,'ordinary native cave floor still dispatches a real wild battle') + T.eq(w.lastNativeBattle.enemy.mon.species,'FIXMON_C','cave land selects its native grass table') + T.eq(getmetatable(g.stack:top()),require('src.render.BattleTransition'),'actual native transition owns the world') + release() + w,g,release,script,ts=fixture(0x29,2,{0x29},{0x29}) + T.eq(w.player.surfing,nil,'water-table probe deliberately has no copied surfing state') + T.eq(w:tryWildEncounter(),true,'native water collision selects an actual encounter') + T.eq(w.lastNativeBattle.enemy.mon.species,'FIXMON_B','water permission selects water table independently of movement state') + release() + w,g,release,script,ts=fixture(0x29,4,{0x29},{0x29});g.data.encounters[w.map.id].water=nil + T.eq(w:tryWildEncounter(),false,'native water without a water table cannot fall through into the cave grass list') + T.eq(w.lastNativeBattle,nil,'missing water table constructs no grass substitute') + release() + w,g,release,script,ts=fixture(0x30,2,{}, {0x30});w.player.surfing=true + local before=rngCalls + T.eq(w:tryWildEncounter(),false,'route current absent from actual encounter-class list does not roll merely because player is surfing') + T.eq(rngCalls,before,'route current consumes no encounter RNG') + release() + w,g,release,script,ts=fixture(0x18,2,{0x18}) + script('OFF');T.eq(w.noWildEncounters,true,'actual native wildoff program disables random encounters') + T.eq(w.runner:isRunning(),false,'native wildoff completes without a phantom overlay') + T.eq(g.save.noWildEncounters,nil,'script switch is transient world state rather than an added save field') + before=rngCalls;T.eq(w:tryWildEncounter(),false,'native script flag stops the real random encounter phase') + T.eq(rngCalls,before,'disabled random encounter consumes no RNG') + T.eq(g.stack:top(),nil,'disabled encounter pushes no transition') + script('ON');T.eq(w.noWildEncounters,false,'actual native wildon re-enables random encounters') + T.eq(w:tryWildEncounter(),false,'same physical visit cannot re-roll after toggling wildon') + w.player.cellX=1 + T.eq(w:tryWildEncounter(),true,'next physical grass visit can start its real native battle') + release() + -- The actual script-controlled battle path is independent of random gates. + w,g,release,script,ts=fixture(0x23,4) + g.data.pokemon.SPECIES_001=g.data.pokemon.FIXMON_C + g.data.map_scripts.scripts.SCRIPTED={{'wildoff'},{'loadwildmon',1,5},{'startbattle'},{'end'}} + w.wildCooldown=5;script('SCRIPTED') + T.eq(w.noWildEncounters,true,'native scripted battle really runs with wildoff set') + T.eq(getmetatable(g.stack:top()),require('src.render.BattleTransition'),'wildoff/ice/grace cannot suppress actual script-controlled battle transition') + T.eq(w.runner:isRunning(),true,'real scripted battle waits for its native completion callback') + release() + -- Adopted behaviour-byte maps must not interpret a Gen3 $23 as Gen2 ICE. + w,g,release,script,ts=fixture(0x23,4,{0x23});ts.behaviourBytes=true;w.map=Map.new(w.map.def,ts) + T.eq(w:tryWildEncounter(),true,'non-Gen2 collision vocabulary retains its existing encounter meaning') + release() +end +for _,version in ipairs({'red','firered','emerald'})do + V.set(version);local w,g,release=fixture(0x23,4,{0x23});w.noWildEncounters=true;w.wildCooldown=5 + T.eq(w:tryWildEncounter(),true,version..' keeps its legacy random battle path rather than Gen2 flags/classes') + T.eq(w.wildCooldown,5,'non-Gen2 encounter does not consume the native Gen2 grace byte') + release() +end +love.math.random=randomBefore;Sound.play=oldSound;Music.play=oldMusic;V.set(versionBefore) +debug.setupvalue(W.tryWildEncounter,slot,oldGame) +T.finish('native Gen2 ice, water-table and wildon/wildoff encounter gates') From d118f7278ccac233585c478137a30bb42f81b248 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 01:46:11 +0200 Subject: [PATCH 11/34] Expose native Gen2 collision classes and bounded terrain queries --- src/world/Map.lua | 17 ++++++- tests/engine/gen2_map_collision_queries.lua | 50 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/engine/gen2_map_collision_queries.lua diff --git a/src/world/Map.lua b/src/world/Map.lua index f8282c8bd..0b5d617e9 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -928,6 +928,13 @@ function Map:isWalkableCell(cx, cy) return self:warpAtCell(cx, cy) ~= nil end +-- Public Gen2 terrain query. Native connection movement may inspect the +-- extended border through isWalkableCell; a free body's current map ends +-- at its actual bounds and must hand off to the connection controller. +function Map:isWalkable(cx, cy) + return self:inBounds(cx, cy) and self:isWalkableCell(cx, cy) +end + function Map:isGrassCell(cx, cy) -- Off-map cells never count as tall grass (issue #217). cellTile -- border-extends out-of-bounds coordinates with the map's borderBlock, @@ -1076,9 +1083,17 @@ function Map:setCellShut(cx, cy, shut) return true end --- What the collision bits say about a cell, with any runtime change applied. +-- The native collision value: Gen2 COLL_* classes or per-cell blocking bits. function Map:cellCollision(cx, cy) local cells = self.def.collisionCells + -- Gen2 callers ask for the COLL_* class, not Gen3's per-cell blocking + -- bits. Read the same live block/quad as the native mover, including + -- changeblock and closed-cell patches. A one-cell metatile or an explicit + -- collisionCells array retains the existing blocking-bit contract. + if not cells and self.blockCells > 1 and self:speaksGen2Collision() then + if not self:inBounds(cx, cy) then return 0xFF end + return self:cellTile(cx, cy) + end if cx < 0 or cy < 0 or cx >= self.def.width or cy >= self.def.height then return cells and 1 or 0 end diff --git a/tests/engine/gen2_map_collision_queries.lua b/tests/engine/gen2_map_collision_queries.lua new file mode 100644 index 000000000..eba6f99e8 --- /dev/null +++ b/tests/engine/gen2_map_collision_queries.lua @@ -0,0 +1,50 @@ +-- Public Gen2 queries must expose the native collision quad, not GBA bits. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local Version = require("src.core.GameVersion") +local original = Version.get() +local function tileset() + return { + id = "NATIVE", blocks = {{}, {}, {}}, + -- Block zero is a sentinel even if its stored bytes happen to be land. + collision = {0,0,0,0, 0,0x18,0x29,0xB2, 0x23,0x71,0xFF,0}, + walkable = {0,0x18,0xB2,0x23,0x71}, waterTiles = {0x29}, shoreTiles = {}, + } +end +for _, version in ipairs(Version.ORDER) do + Version.set(version) + local map = Map.new({id="ROOM",width=2,height=1,blocks={1,2},borderBlock=1}, tileset()) + for _, cell in ipairs({{0,0,0},{1,0,0x18},{0,1,0x29},{1,1,0xB2}, + {2,0,0x23},{3,0,0x71},{2,1,0xFF},{3,1,0}}) do + T.eq(map:cellCollision(cell[1],cell[2]),cell[3],version.." actual COLL_* quad") + T.eq(map:isWalkable(cell[1],cell[2]),map:isWalkableCell(cell[1],cell[2]),"native terrain permission") + end + for _, cell in ipairs({{-1,0},{0,-1},{4,0},{0,2}}) do + T.eq(map:cellCollision(cell[1],cell[2]),0xFF,"off-map is a collision sentinel") + T.eq(map:isWalkable(cell[1],cell[2]),false,"public walkability excludes border") + end + T.eq(map:cellTile(-1,0),0x18,"native connection border sampling is unchanged") + T.eq(map:isWalkable(0,1),false,"land walker rejects water") + map:setBlock(0,0,2) + T.eq(map:cellCollision(0,0),0x23,"changeblock updates the public collision") + map:setBlock(0,0,0) + T.eq(map:cellCollision(0,0),0xFF,"block zero remains a wall") + map:clearBlockPatches() + T.eq(map:cellCollision(0,0),0,"clearing patches restores the actual class") + -- A one-cell GBA grid keeps collision bits separate from behaviour bytes. + local ts = tileset();ts.blockCells=1;ts.blockTiles=2;ts.behaviourBytes=true + ts.collision={0xB2,0x23,0x71} + local gba=Map.new({id="GBA",width=2,height=1,blocks={0,1},collisionCells={0,1}},ts) + T.eq(gba:cellCollision(0,0),0,"GBA clear bit remains zero") + T.eq(gba:cellCollision(1,0),1,"GBA wall bit remains one") + T.eq(gba:cellCollision(-1,0),1,"GBA outside remains blocked") + gba.collisionPatch[2]=0 + T.eq(gba:cellCollision(1,0),0,"GBA runtime bits retain precedence") + T.eq(gba:cellBehaviour(0,0),0xB2,"GBA behaviour is not a Johto collision") + local adopted=tileset();adopted.collision=nil + local g1=Map.new({id="ADOPTED",width=2,height=1,blocks={1,2}},adopted) + T.eq(g1:cellCollision(0,0),0,"an adopted Gen1 tileset does not invent COLL_* bytes") +end +Version.set(original) +T.finish("native Gen2 map collision queries") From 682230131d6bee4af36ffd688ba25358ac677bab Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 04:22:58 +0100 Subject: [PATCH 12/34] Expose live native player collision and grass reads --- src/world/OverworldController.lua | 11 ++++++++ tests/engine/gen2_player_terrain_reads.lua | 32 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/engine/gen2_player_terrain_reads.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ec8f367e8..3d0e497a8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -66,6 +66,17 @@ end local OverworldState = { isOpaque = true, isOverworld = true } +function OverworldState:playerCollision() + local player = self.player + if not (player and self.map) then return nil end + return self.map:cellCollision(player.cellX, player.cellY) +end + +function OverworldState:grassAt(x, y) + return self.map ~= nil and self.map:isGrassCell(x, y) or false +end + + local Game -- set on enter (avoids circular require at load time) function OverworldState:movementState() diff --git a/tests/engine/gen2_player_terrain_reads.lua b/tests/engine/gen2_player_terrain_reads.lua new file mode 100644 index 000000000..97c0c7584 --- /dev/null +++ b/tests/engine/gen2_player_terrain_reads.lua @@ -0,0 +1,32 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Map=require('src.world.Map');local W=require('src.world.OverworldController') +for _,version in ipairs({'gold','silver','crystal','prism'})do + V.set(version) + local ts={id='TERRAIN',blockCells=2,collision={255,255,255,255,0x23,0x18,0x72,0x21,0x21,0x10,0x7A,0},walkable={0,0x18,0x23,0x10},grassTiles={0x18,0x10},waterTiles={0x21}} + local def={id='TERRAIN_MAP',generation=2,width=1,height=1,blocks={1}} + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,collision=255}},{__index=W}) + T.eq(w:playerCollision(),0x23,version..' player terrain reads actual native ice class rather than a cached actor value') + w.player.cellX=1;T.eq(w:playerCollision(),0x18,'native grass class stays a collision code') + T.eq(w:grassAt(1,0),true,'grass query uses native map classification') + T.eq(w:grassAt(0,0),false,'ice is not grass') + T.eq(w:grassAt(-1,0),false,'border filler is not standable grass') + T.eq(w:grassAt(2,0),false,'outside the actual map is not grass') + w.player.cellX=0;w.player.cellY=1;T.eq(w:playerCollision(),0x72,'native staircase is not a Gen3 blocking bit') + w.map:setBlock(0,0,2) + T.eq(w:playerCollision(),0x7A,'player terrain observes actual live block patch') + T.eq(w:grassAt(1,0),true,'grass observes actual replacement block') + w.player.cellX=1;w.player.cellY=1;T.eq(w:playerCollision(),0,'patched ordinary floor is read immediately') + w.player=nil;T.eq(w:playerCollision(),nil,'missing player has no fabricated collision') + w.map=nil;T.eq(w:grassAt(0,0),false,'missing active map has no grass') +end +V.set('emerald') +local map=Map.new({id='GBA',generation=3,width=2,height=1,blocks={0,1},collisionCells={1,0}}, + {id='GBA',blockCells=1,collision={8,9},walkable={8,9},blocks={}}) +local w=setmetatable({map=map,player={cellX=0,cellY=0}},{__index=W}) +T.eq(w:playerCollision(),1,'other-generation actual blocking bit is retained') +w.player.cellX=1;T.eq(w:playerCollision(),0,'other-generation unblocked cell retains its native bit') +w.map=nil;T.eq(w:playerCollision(),nil,'missing map has no fabricated terrain') +V.set(original) +T.finish('live native player collision and grass queries') From 94a330d68e37c216af3fe56de6da9c43d69b0669 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 12:37:22 +0100 Subject: [PATCH 13/34] Expose native Gen2 directional and special-terrain queries --- .../workflows/gen2-permissions-contract.yml | 36 ++++++ docs/native-gen2-movement-permissions.md | 43 ++++++++ src/world/Collision.lua | 38 ++++++- src/world/OverworldController.lua | 3 +- tests/engine/gen2_movement_permissions.lua | 103 ++++++++++++++++++ tools/gen2_permissions_contract.lua | 26 +++++ 6 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/gen2-permissions-contract.yml create mode 100644 docs/native-gen2-movement-permissions.md create mode 100644 tests/engine/gen2_movement_permissions.lua create mode 100644 tools/gen2_permissions_contract.lua diff --git a/.github/workflows/gen2-permissions-contract.yml b/.github/workflows/gen2-permissions-contract.yml new file mode 100644 index 000000000..e6217eed9 --- /dev/null +++ b/.github/workflows/gen2-permissions-contract.yml @@ -0,0 +1,36 @@ +name: native Gen2 movement permissions +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-permissions-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Compare queries with actual native grid movement + run: luajit tests/engine/gen2_movement_permissions.lua + - name: Compare all Crystal collision pairs with the pinned Gen1 engine + run: | + mkdir -p /tmp/gen2-permissions-contract + luajit tools/gen2_permissions_contract.lua native > /tmp/gen2-permissions-contract/native.txt + (cd .contract-reference/gen1 && luajit "$GITHUB_WORKSPACE/tools/gen2_permissions_contract.lua" reference) > /tmp/gen2-permissions-contract/reference.txt + cmp /tmp/gen2-permissions-contract/native.txt /tmp/gen2-permissions-contract/reference.txt + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gen2-permissions-contract + path: /tmp/gen2-permissions-contract + if-no-files-found: ignore diff --git a/docs/native-gen2-movement-permissions.md b/docs/native-gen2-movement-permissions.md new file mode 100644 index 000000000..24284710d --- /dev/null +++ b/docs/native-gen2-movement-permissions.md @@ -0,0 +1,43 @@ +# Native Gen2 movement permission queries + +The existing `src.world.gen2.Permissions` module alias resolves to the native +`src.world.Collision` table. It now exposes four terrain queries used by Gen2 +camera controls: + +| Method | Result | +| --- | --- | +| `isIce(coll)` | Whether the actual `COLL_*` byte is either native ice class | +| `currentDirection(coll)` | Forced direction for the current family, including waterfall tiles | +| `doorForcedDirection(coll)` | Downward movement for the native forced-door subset | +| `stepPermitted(collOf, x, y, direction)` | Whether the standing and destination cells permit crossing their directional side walls | + +The first three consume Gen2 collision bytes. They do not classify a later +generation's blocking bits. The side-wall query shares `Map.gen2SideWall` with +the native grid mover; it does not replace map bounds, walkability, occupants, +scripted closed cells, or the `movement.collision` hook. Callers must retain +those checks. Runtime block replacement is observed through the supplied +collision reader, without a second collision cache. Unknown directions are +refused before sampling cells. + +The native Gen2 ice controller uses the same predicate. This change does not +install `FieldMoves`, activate continuous controls, or start forced movement +from any of the query methods. Those integrations require their own native +landing and handoff verification. + +## Verification + +Run `luajit tests/engine/gen2_movement_permissions.lua`. It checks all 256 +collision classes and all 262,144 source/destination/direction combinations +against actual `Map` cells and `Collision.canMove`, isolating directional +permission from other collision reasons. It also checks occupants, scripted +closed cells, block replacement, invalid values, module identity, and the live +ice controller in Gold, Silver, Crystal and Prism. No ROM or mod is needed. + +`tools/gen2_permissions_contract.lua` produces a deterministic behavioral +dump. The workflow compares its complete output for Crystal with public Gen1 +reference commit `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Each engine runs +from its own checkout. The comparison covers 768 byte queries and 262,144 +directional cases; it does not claim complete movement or rendering parity. + +These are CPU contract tests. Actual mod camera movement, special movement +handoffs, full rendering and GPU acceptance remain separate requirements. diff --git a/src/world/Collision.lua b/src/world/Collision.lua index 94d7d0313..08f1281e6 100644 --- a/src/world/Collision.lua +++ b/src/world/Collision.lua @@ -6,8 +6,44 @@ local Runtime = require("src.mods.Runtime") local Collision = {} local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } Collision.DELTA = DELTA +-- Public Gen2 terrain queries used by camera-relative movement. These accept +-- COLL_* bytes, rather than the blocking bits of later generations. Walking +-- still needs map passability, bounds, occupants and movement.collision; the +-- directional query below only answers the side-wall part of that decision. +function Collision.isIce(coll) + return coll == 0x23 or coll == 0x2B +end + +local CURRENT_DIRECTIONS = { "right", "left", "up", "down" } +function Collision.currentDirection(coll) + if type(coll) ~= "number" or coll % 1 ~= 0 or coll < 0x30 or coll > 0x3F then + return nil + end + return CURRENT_DIRECTIONS[coll % 4 + 1] +end + +function Collision.doorForcedDirection(coll) + if coll == 0x71 or coll == 0x79 or coll == 0x7A or coll == 0x7B then + return "down" + end + return nil +end + +function Collision.stepPermitted(collOf, cx, cy, dir) + local delta = DELTA[dir] + if not delta then return false end + -- The same side classification used by Map:sideWallAt and the grid mover. + -- Resolve lazily: loading the map class must not load a second world owner. + local sides = require("src.world.Map").gen2SideWall + local leave = sides(collOf(cx, cy)) + if leave and leave[dir] then return false end + local enter = sides(collOf(cx + delta[1], cy + delta[2])) + return not (enter and enter[OPPOSITE[dir]]) +end + function Collision.target(cx, cy, dir) local d = DELTA[dir] return cx + d[1], cy + d[2] @@ -181,8 +217,6 @@ end -- -- Two halves, matching the ROM: the standing tile blocks stepping OUT over -- its walled side, and the destination tile blocks stepping IN through it. -local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } - -- Collision.lua local function sideWallBlocked(map, mover, dir, tx, ty) if not map.sideWallAt then return false end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3d0e497a8..233a23fd2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -8384,7 +8384,6 @@ end -- any step that LANDS on one into STEP_ICE: the walk repeats in the same -- direction, ignoring the d-pad, until the player leaves the ice or the next -- cell is blocked. Mahogany Gym's floor is the whole puzzle. -local GEN2_ICE_TILES = { [0x23] = true, [0x2B] = true } -- THE SOOTOPOLIS GYM'S ICE, which is the whole of that gym. -- @@ -10114,7 +10113,7 @@ end function OverworldState:gen2IsIce(cx, cy) local tile = self.map and self.map:cellTile(cx, cy) - return tile ~= nil and GEN2_ICE_TILES[tile] == true + return Collision.isIce(tile) end -- ICE, WHOEVER'S IT IS. Johto names it by a collision class and Hoenn by a diff --git a/tests/engine/gen2_movement_permissions.lua b/tests/engine/gen2_movement_permissions.lua new file mode 100644 index 000000000..f8baa1c71 --- /dev/null +++ b/tests/engine/gen2_movement_permissions.lua @@ -0,0 +1,103 @@ +-- Terrain-only queries must agree with the native Gen2 grid mover. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local C = require("src.world.Collision") +local Map = require("src.world.Map") +local World = require("src.world.OverworldController") +local Version = require("src.core.GameVersion") +local oldVersion = Version.get() +Version.set("crystal") +local Compat = require("src.core.ModCompat") +T.eq(Compat.resolve("src.world.gen2.Permissions"), C, "existing alias keeps the actual native collision owner") +for _, name in ipairs({"isIce", "currentDirection", "doorForcedDirection", "stepPermitted"}) do + T.eq(type(C[name]), "function", "public terrain query exists: " .. name) +end +local directions = {"right", "left", "up", "down"} +local forcedDoor = {[0x71]=true, [0x79]=true, [0x7A]=true, [0x7B]=true} +for byte = 0, 255 do + T.eq(C.isIce(byte), byte == 0x23 or byte == 0x2B, "exact native ice class") + local current = byte >= 48 and byte <= 63 and directions[byte % 4 + 1] or nil + T.eq(C.currentDirection(byte), current, "complete current family including waterfall caps") + T.eq(C.doorForcedDirection(byte), forcedDoor[byte] and "down" or nil, "exact forced doorway subset") +end +T.eq(C.isIce(nil), false, "missing collision does not enable ice") +for _, invalid in ipairs({-1, 256, 0x23 + 256, 0x30 + 256, "48", false, {}, 48.5, math.huge, 0/0}) do + T.eq(C.isIce(invalid), false, "invalid collision does not enable ice") + T.eq(C.currentDirection(invalid), nil, "invalid collision does not force current") + T.eq(C.doorForcedDirection(invalid), nil, "invalid collision does not force doorway") +end +T.eq(C.currentDirection(nil), nil, "missing current") +T.eq(C.doorForcedDirection(nil), nil, "missing doorway") +local tileset = {id="PERMISSIONS", blocks={}, collision={}, walkable={}} +for i = 0, 255 do tileset.walkable[#tileset.walkable+1] = i end +for i = 1, 40 do tileset.collision[i] = 0 end +local map = Map.new({id="NATIVE", generation=2, width=3, height=3, blocks={1,2,3,4,5,6,7,8,9}}, tileset) +local mover = {cellX=2, cellY=2} +local function put(x, y, byte) + local block = map:blockAt(math.floor(x/2), math.floor(y/2)) + tileset.collision[block*4 + (y%2)*2 + x%2 + 1] = byte +end +-- This samples the native Gen2 class directly, so the contract can also run +-- independently before the separate public cellCollision byte-read change. +local function collision(x,y) return map:inBounds(x,y) and map:cellTile(x,y) or 0xFF end +-- All classes stand on terrain made passable here to isolate directional +-- fences from land/water classification, occupancy and hooks. Use real Map +-- cells and the full native mover, not a second side-wall implementation. +for _, dir in ipairs(directions) do + local tx,ty = C.target(2,2,dir) + for source = 0,255 do + put(2,2,source) + for destination = 0,255 do + put(tx,ty,destination) + local native = C.canMove(map, {}, mover, dir) + T.eq(C.stepPermitted(collision,2,2,dir), native, "public directional fence agrees with actual native grid mover") + end + end + put(tx,ty,0) +end +-- A perpendicular neighbor must not close this direction. Both wall and buoy +-- families retain the same directional corners and repeated low-three bits. +put(2,2,0) +for _, family in ipairs({0xB0,0xC0}) do + for _, offset in ipairs({0,8}) do + put(2,1,family+offset+3) + T.eq(C.stepPermitted(collision,2,2,"up"),false,"north neighbor faces the body") + T.eq(C.stepPermitted(collision,2,2,"right"),true,"north neighbor does not block east") + put(2,1,0) + put(3,2,family+offset+1) + T.eq(C.stepPermitted(collision,2,2,"right"),false,"east neighbor faces the body") + put(3,2,family+offset) + T.eq(C.stepPermitted(collision,2,2,"right"),true,"east neighbor faces away") + put(3,2,0) + end +end +local reads=0 +T.eq(C.stepPermitted(function() reads=reads+1 end,2,2,"diagonal"),false,"unknown direction fails closed") +T.eq(reads,0,"unknown direction never samples unrelated cells") +-- A terrain-only permission must never be mistaken for whole-step admission. +local npc={cellX=3,cellY=2} +T.eq(C.stepPermitted(collision,2,2,"right"),true,"directional floor is clear") +T.eq(C.canMove(map,{npc},mover,"right"),false,"native occupancy remains authoritative") +map.shutCells = {["2:1"]=true} +T.eq(C.stepPermitted(collision,2,2,"up"),true,"directional query does not claim authored closed-cell ownership") +T.eq(C.canMove(map,{},mover,"up"),false,"native closed cell remains authoritative") +map.shutCells={} +-- Public values immediately observe real script block replacement. +put(3,2,0xB1) +T.eq(C.stepPermitted(collision,2,2,"right"),false,"live wall before changeblock") +map:setBlock(1,1,1) +T.eq(C.stepPermitted(collision,2,2,"right"),true,"changeblock takes effect without permission cache") +map:clearBlockPatches() +T.eq(C.stepPermitted(collision,2,2,"right"),false,"restoring native block restores wall") +local world = setmetatable({map=map},{__index=World}) +for _, version in ipairs({"gold","silver","crystal","prism"}) do + Version.set(version) + for byte = 0,255 do + put(2,2,byte) + T.eq(world:gen2IsIce(2,2), C.isIce(byte), "live native ice test shares the public predicate") + end +end +world.map=nil +T.eq(world:gen2IsIce(2,2),false,"missing map cannot force a native ice step") +Version.set(oldVersion) +T.finish("native Gen2 directional and special-terrain permissions") diff --git a/tools/gen2_permissions_contract.lua b/tools/gen2_permissions_contract.lua new file mode 100644 index 000000000..f39f826a9 --- /dev/null +++ b/tools/gen2_permissions_contract.lua @@ -0,0 +1,26 @@ +-- ROM-free behavioral dump. Run from each engine's own checkout, passing +-- "native" here or "reference" for the pinned Gen1 engine. Compare stdout. +package.path = "./?.lua;./?/init.lua;" .. package.path +local Version = require("src.core.GameVersion") +Version.set("crystal") +local P = require(arg[1] == "reference" and "src.world.gen2.Permissions" or "src.world.Collision") +for byte = 0, 255 do + io.write(byte, ":", P.isIce(byte) and "ice" or "-", ":", + P.currentDirection(byte) or "-", ":", P.doorForcedDirection(byte) or "-", "\n") +end +local deltas = {up={0,-1}, down={0,1}, left={-1,0}, right={1,0}} +for _, dir in ipairs({"up", "down", "left", "right"}) do + local delta = deltas[dir] + for source = 0, 255 do + local row = {} + for destination = 0, 255 do + local function collOf(x, y) + if x == 0 and y == 0 then return source end + if x == delta[1] and y == delta[2] then return destination end + return 0 + end + row[#row+1] = P.stepPermitted(collOf,0,0,dir) and "1" or "0" + end + io.write(dir, ":", source, ":", table.concat(row), "\n") + end +end From d0f5ee96bb78d1e80b8cd9acce7fd65b4fb4d60f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 12:56:04 +0100 Subject: [PATCH 14/34] Return Gen2 special movement to native handlers and retain directional fences --- .../workflows/gen2-permissions-contract.yml | 2 + docs/native-gen2-movement-handoffs.md | 48 ++++ src/world/OverworldController.lua | 91 +++++++ tests/engine/gen2_movement_handoffs.lua | 228 ++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 docs/native-gen2-movement-handoffs.md create mode 100644 tests/engine/gen2_movement_handoffs.lua diff --git a/.github/workflows/gen2-permissions-contract.yml b/.github/workflows/gen2-permissions-contract.yml index e6217eed9..4989d28e0 100644 --- a/.github/workflows/gen2-permissions-contract.yml +++ b/.github/workflows/gen2-permissions-contract.yml @@ -22,6 +22,8 @@ jobs: run: sudo apt-get update && sudo apt-get install -y luajit - name: Compare queries with actual native grid movement run: luajit tests/engine/gen2_movement_permissions.lua + - name: Exercise native special movement handoffs + run: luajit tests/engine/gen2_movement_handoffs.lua - name: Compare all Crystal collision pairs with the pinned Gen1 engine run: | mkdir -p /tmp/gen2-permissions-contract diff --git a/docs/native-gen2-movement-handoffs.md b/docs/native-gen2-movement-handoffs.md new file mode 100644 index 000000000..3763c33e4 --- /dev/null +++ b/docs/native-gen2-movement-handoffs.md @@ -0,0 +1,48 @@ +# Native Gen2 special movement handoffs + +Gen2 controls can return special movement to the existing native overworld +through `tryConnection(direction)`, `tryLedgeJump(direction)` and +`tryPushBoulder(direction, x, y)`. The last method requires the actual adjacent +cell in that direction. These methods use the existing map/warp, ledge and +boulder handlers, player interpolation, script queues and completion events. + +Admission requires the current Gen2 world and save, a standing actor, no +overlay or battle, and no active or pending native script, field action or +transition. Both the persistent world with an empty overlay stack and the +legacy world on top of its stack are supported. An expired numeric whirlpool +hold is clear; a positive hold retains control. Nested admission from a +synchronous native callback is refused, and a handler error remains visible +without leaving a permanent admission lock. + +The connection path retains directional carpet exits. A carpet is an input +action, so an integration must not substitute an immediate arrival warp. +Ordinary seam steps also check directional walls on the standing and +destination cells before changing map. Scripted seams keep their deliberate +collision bypass. + +The native Strength handler checks directional walls on both the player's +approach and the boulder's step. Previously, its land/water check admitted +those walls. Both the existing grid path and the new public handoff now refuse +that push before queuing movement. Native occupancy, Strength activation, +object classification, destination passability and completion effects retain +their existing ownership. + +## Verification and limits + +`luajit tests/engine/gen2_movement_handoffs.lua` exercises actual map definitions, +native connection selection, all four seam directions, native player/NPC +interpolation, two-cell ledge queues and boulder completion. Only graphics/map +materialization and the audio device are substituted. Native directional +carpet fades are advanced to completion. The suite also checks busy owners, +invalid directions, exact boulder coordinates, callback reentry, error cleanup, +and the unchanged scripted collision bypass. + +The directional-wall regressions cover Gold, Silver, Crystal and Prism. A +complete valid-byte/directional query comparison with the pinned Gen1 engine +is covered by the prerequisite terrain-query contract. This follow-up builds +on that implementation; it does not replace any generation's world controller. + +These are native CPU tests. They do not establish complete continuous-movement +activation, the FieldMoves facade, all special terrain/landing behavior, or +actual VASC GPU acceptance. The full generated VASC model renderer remains +blocked by the separately documented compiler limit. diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 233a23fd2..ef2d94828 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -79,6 +79,72 @@ end local Game -- set on enter (avoids circular require at load time) +local GEN2_HANDOFF_LOCKS = { + "transitioning", "flyAnim", "teleportOut", "engaging", "emote", + "pikaHop", "healAnim", "whirlSpin", "battleActive", + "fieldMove", "fishing", "headbutt", "mapSetup", "moveState", "iceSlide", + "frameScriptThisTick", "_modScriptLock", "droppingScriptMoves", +} +local gen2Handoffs = setmetatable({}, { __mode = "k" }) + +local function gen2HandoffReady(world, dir) + if not GameVersion.isGen2() or not Collision.DELTA[dir] or gen2Handoffs[world] then return false end + if not (Game and Game.data and Game.save and Game.overworld == world) then return false end + local map, p, stack = world.map, world.player, Game.stack + if not (map and p and stack and type(stack.top) == "function") then return false end + local top = stack:top() + if top and top ~= world then return false end + if Game.linkSession or (Game.linkNet and not Game.linkNet.closed) then return false end + if p.moving or p.inputLocked or p.stairExit or p.spinning or p.fieldMove then return false end + if (world.whirlHold or 0) > 0 then return false end + for _, key in ipairs(GEN2_HANDOFF_LOCKS) do if world[key] then return false end end + if not (world.runner and type(world.runner.isRunning) == "function") + or world.runner:isRunning() or #(world.scriptMoves or {}) > 0 + or #(world.pendingScripts or {}) > 0 then return false end + if type(p.cellX) ~= "number" or type(p.cellY) ~= "number" + or p.cellX % 1 ~= 0 or p.cellY % 1 ~= 0 then return false end + return map:inBounds(p.cellX, p.cellY) +end + +local function gen2Handoff(world, dir, handler, ...) + if not gen2HandoffReady(world, dir) then return false end + -- A native sound/event callback can call back into the public methods + -- before the handler has queued its movement. Admit that action once. + gen2Handoffs[world] = true + local ok, result = pcall(handler, world, dir, ...) + gen2Handoffs[world] = nil + if not ok then error(result, 0) end + return result and true or false +end + +local function gen2ConnectionHandoff(world, dir) + -- Exit carpets are directional input actions, not arrival warps. A free + -- body stopped at the map edge still needs the native carpet exit path. + if world:checkGen2CarpetExit(dir) then return true end + return world:checkEdgeExit(dir) +end + +local function gen2BoulderHandoff(world, dir, cx, cy) + local x, y = Collision.target(world.player.cellX, world.player.cellY, dir) + if cx ~= x or cy ~= y then return false end + return world:checkBoulderPush(dir) +end + +-- Free-body controls return special movement to these same native handlers. +-- No parallel transition, boulder animation, scripted queue or map owner is +-- created. Readiness is checked again after the caller's ordinary body tick. +function OverworldState:tryConnection(dir) + return gen2Handoff(self, dir, gen2ConnectionHandoff) +end + +function OverworldState:tryLedgeJump(dir) + return gen2Handoff(self, dir, self.checkLedgeHop) +end + +function OverworldState:tryPushBoulder(dir, cx, cy) + return gen2Handoff(self, dir, gen2BoulderHandoff, cx, cy) +end + function OverworldState:movementState() if not GameVersion.isGen2() then return nil end return require("src.world.Gen2PlayerState").read(self.player, Game and Game.save) @@ -5588,6 +5654,14 @@ function OverworldState:checkBoulderPush(dir) end local bx, by = Collision.target(fx, fy, dir) if not self.map:inBounds(bx, by) then self.boulderTried = nil return false end + if GameVersion.isGen2() and self.map:speaksGen2Collision() then + local function collision(x, y) return self.map:cellTile(x, y) end + if not Collision.stepPermitted(collision, p.cellX, p.cellY, dir) + or not Collision.stepPermitted(collision, fx, fy, dir) then + self.boulderTried = nil + return false + end + end -- DistWorld_WillBoulderBeAtFallLocation: a Distortion World pit takes a -- boulder although nothing may walk there (Gen4DistPlatforms.fallAt) local dwFall = self.map.def and self.map.def.gen4DistWorld @@ -6041,6 +6115,23 @@ function OverworldState:crossConnection(dir, conn, scripted) end conn = landed or conn local p = self.player + if not scripted and GameVersion.isGen2() then + -- Border strips carry the same one-way walls as interior cells. Checking + -- only land/water passability admitted both standing walls and walls + -- facing us on the destination map. Scripted seams deliberately bypass + -- collision, just as their in-map movement queue does. + local edgeX, edgeY = Collision.target(p.cellX, p.cellY, dir) + local function collision(cx, cy) + if cx == edgeX and cy == edgeY then + if ts.collision and not ts.behaviourBytes and (tonumber(ts.blockCells) or 2) > 1 then + return Map.defCellTile(dest, ts, x, y) + end + return 0 + end + return self.map:speaksGen2Collision() and self.map:cellTile(cx, cy) or 0 + end + if not Collision.stepPermitted(collision, p.cellX, p.cellY, dir) then return false end + end -- pokered's collision check reads the NEIGHBOR strip's tile bytes, so -- stepping off the edge onto a solid tile of the connected map bumps -- exactly like an in-map wall. Without this read, Pallet's south diff --git a/tests/engine/gen2_movement_handoffs.lua b/tests/engine/gen2_movement_handoffs.lua new file mode 100644 index 000000000..8ab2c7abe --- /dev/null +++ b/tests/engine/gen2_movement_handoffs.lua @@ -0,0 +1,228 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local Map = require("src.world.Map") +local W = require("src.world.OverworldController") +local Player = require("src.world.Player") +local NPC = require("src.world.NPC") +local Stack = require("src.core.StateStack") +local Transition = require("src.render.Transition") +local Sound = require("src.core.Sound") +local Runtime = require("src.mods.Runtime") +local originalVersion, oldSound, oldEmit = V.get(), Sound.play, Runtime.emit +Sound.play = function() end -- audio device only +local gameSlot, oldGame +for i=1,100 do + local name,value=debug.getupvalue(W.crossConnection,i) + if not name then break end + if name=="Game" then gameSlot,oldGame=i,value;break end +end +assert(gameSlot) +local scriptsSlot,oldScripts +for i=1,100 do + local name,value=debug.getupvalue(W.checkBoulderPush,i) + if not name then break end + if name=="mapScripts"then scriptsSlot,oldScripts=i,value;break end +end +assert(scriptsSlot) +debug.setupvalue(W.checkBoulderPush,scriptsSlot,require("src.script.MapScripts")) +local compass={up="north",down="south",left="west",right="east"} +local opposite={up="down",down="up",left="right",right="left"} +local wall={right=0xB0,left=0xB1,up=0xB2,down=0xB3} +local function fixture(version,x,y) + V.set(version) + local data=T.fixtures.fresh() + data.field.ledges={} + data.field.ledgeHops={[0xA0]={"right"},[0xA1]={"left"},[0xA2]={"up"},[0xA3]={"down"}} + local ts={id="HANDOFF",collision={},walkable={0,0xA0,0xA1,0xA2,0xA3},waterTiles={0x29},blocks={}} + for i=1,76 do ts.collision[i]=0 end + for byte=0xB0,0xCF do ts.walkable[#ts.walkable+1]=byte end + local from={id="HANDOFF_FROM",tileset=ts.id,generation=2,width=3,height=3,blocks={1,2,3,4,5,6,7,8,9},connections={},warps={}} + local dest={id="HANDOFF_TO",tileset=ts.id,generation=2,width=3,height=3,blocks={10,11,12,13,14,15,16,17,18},warps={}} + for _,c in pairs(compass)do from.connections[c]={map=dest.id,offset=0}end + data.maps[from.id]=from;data.maps[dest.id]=dest;data.tilesets[ts.id]=ts + local p=setmetatable({cellX=x or 2,cellY=y or 2,facing="right",moving=false,progress=0,stepFrames=16,turnTimer=0},{__index=Player}) + p.px,p.py=p.cellX*16,p.cellY*16 + local w=setmetatable({map=Map.new(from,ts),player=p,npcs={},ghosts={},entities={p},scriptMoves={},pendingScripts={},runner={isRunning=function()return false end},mapChanges=0},{__index=W}) + local game={data=data,save={onBike=false,flags={},party={}},overworld=w,stack=setmetatable({states={}},{__index=Stack})} + debug.setupvalue(W.crossConnection,gameSlot,game) + -- Only renderer/map materialization is replaced. Connection selection, + -- eligibility, movement queues, actor interpolation and callbacks are real. + function w:setMap(id,cx,cy,facing) + self.mapChanges=self.mapChanges+1;self.map=Map.new(data.maps[id],ts) + p.cellX,p.cellY,p.px,p.py,p.facing=cx,cy,cx*16,cy*16,facing + p.targetX,p.targetY,p.moving=nil,nil,false + end + function w:followCamera()end + local function put(def,cx,cy,byte) + local block=def.blocks[math.floor(cy/2)*def.width+math.floor(cx/2)+1] + ts.collision[block*4+(cy%2)*2+cx%2+1]=byte + end + return w,game,from,dest,put +end +local function edge(dir) + if dir=="up" then return 2,0,2,5 end + if dir=="down" then return 2,5,2,0 end + if dir=="left" then return 0,2,5,2 end + return 5,2,0,2 +end +-- Regressions for the native grid entry point as well as its new public seam. +for _,version in ipairs({"gold","silver","crystal","prism"})do + for _,dir in ipairs({"up","down","left","right"})do + local x,y,tx,ty=edge(dir) + for _,side in ipairs({"source","destination"})do + local w,game,from,dest,put=fixture(version,x,y) + if side=="source" then put(from,x,y,wall[dir]) else put(dest,tx,ty,wall[opposite[dir]]) end + T.eq(w:crossConnection(dir,from.connections[compass[dir]]),false,version.." native seam refuses "..side.." side wall") + T.eq(w.mapChanges,0,"refused seam does not load another map") + T.eq(w.player.moving,false,"refused seam cannot start a step") + end + end +end +for _,name in ipairs({"tryConnection","tryLedgeJump","tryPushBoulder"})do T.eq(type(W[name]),"function","public native handoff exists: "..name)end +for _,version in ipairs({"gold","silver","crystal","prism"})do + -- All four real seam interpolations, including a caller arriving off-grid. + for _,dir in ipairs({"up","down","left","right"})do + local x,y,tx,ty=edge(dir) + local w,game,from,dest=fixture(version,x,y) + w.player.px=w.player.px+3;w.player.py=w.player.py-2 + T.eq(w:tryConnection(dir),true,version.." public seam uses native crossing") + T.eq(w.mapChanges,1,"one native map swap") + T.eq(w.map.id,dest.id,"actual selected destination") + T.eq(w.player.moving,true,"native seam owns interpolation") + T.eq(w.player.targetX,tx,"native seam target x") + T.eq(w.player.targetY,ty,"native seam target y") + T.eq(w:tryConnection(dir),false,"ongoing seam rejects reentry") + for _=1,20 do w.player:update()end + T.eq(w.player.cellX,tx,"native player completes seam x") + T.eq(w.player.cellY,ty,"native player completes seam y") + T.eq(w.player.px,tx*16,"native player completes centered pixels") + T.eq(w.player.moving,false,"seam releases actor") + end + -- Real ledge queue and player interpolation; the landing is two cells away. + local w,game,from,dest,put=fixture(version) + put(from,2,2,0xA0);put(from,3,2,0x07) + w.player.px=w.player.px+4 + T.eq(w:tryLedgeJump("right"),true,"public ledge hands off to native queue") + T.eq(#w.scriptMoves,1,"one native ledge move") + T.eq(w.player.hopFrames,32,"native ledge arc") + T.eq(w:tryLedgeJump("right"),false,"queued ledge rejects reentry") + for _=1,40 do w:updateScriptMoves();w.player:update()end + T.eq(w.player.cellX,4,"real two-cell hop lands") + T.eq(w.player.moving,false,"real ledge interpolation ends") + T.eq(#w.scriptMoves,0,"native ledge queue drains") + -- Boulders retain their own native animation, callback and event owner. + w,game,from,dest,put=fixture(version) + local b=setmetatable({id="HANDOFF_BOULDER",def={index=1,pushable=true},cellX=3,cellY=2,px=48,py=32,moving=false,progress=0,frozen=true,stepFrames=16},{__index=NPC}) + w.npcs={b};w.entities={w.player,b};w.strengthActive=true + local moved=0 + Runtime.emit=function(name,ctx)if name=="world.boulder_moved"then moved=moved+1;T.eq(ctx.x,4,"settled boulder event has native x")end end + T.eq(w:tryPushBoulder("right",4,2),false,"remote cell cannot be supplied as faced boulder") + T.eq(w:tryPushBoulder("right",3,2),true,"native Strength queue accepts adjacent boulder") + T.eq(w:tryPushBoulder("right",3,2),false,"queued push rejects duplicate handoff") + T.eq(moved,0,"push event waits for native completion") + for _=1,24 do w:updateScriptMoves();b:update(w.map,w.entities)end + T.eq(b.cellX,4,"real NPC interpolation settles the boulder") + T.eq(w.player.cellX,2,"pushing player stays in their cell") + T.eq(moved,1,"native push completion emits once") + T.eq(#w.scriptMoves,0,"native boulder queue drains") + T.check(w.dustAnim~=nil,"native boulder completion owns its dust animation") + -- Both the standing side and destination side must permit the boulder. + for _,side in ipairs({"player","source","destination"})do + w,game,from,dest,put=fixture(version) + b=setmetatable({id="BOULDER",def={index=1,pushable=true},cellX=3,cellY=2,moving=false},{__index=NPC}) + w.npcs={b};w.entities={w.player,b};w.strengthActive=true + if side=="player"then put(from,2,2,0xB0) + elseif side=="source"then put(from,3,2,0xB0)else put(from,4,2,0xB1)end + T.eq(w:checkBoulderPush("right"),false,"native push refuses "..side.." side wall") + T.eq(#w.scriptMoves,0,"blocked boulder never joins the script queue") + T.eq(w:tryPushBoulder("right",3,2),false,"public push retains native directional fence") + end +end +-- Water is still decided by native passability after directional fences. +for _,surfing in ipairs({false,true})do + local w,g,from,dest,put=fixture("crystal",5,2) + put(dest,0,2,0x29);w.player.surfing=surfing + T.eq(w:tryConnection("right"),surfing,"native seam retains land/surf admission") + T.eq(w.mapChanges,surfing and 1 or 0,"water refusal cannot materialize destination") +end +-- Wrong-way exit-mat probes cannot warp; the right direction takes the real +-- native fade even when standingOnWarp is false for that carpet. +do + local w,g,from,dest,put=fixture("crystal",2,5) + put(from,2,5,0x70) + from.warps={{x=2,y=5,destMap=dest.id,destWarp=1}} + dest.warps={{x=1,y=1,destMap=from.id,destWarp=1}} + w.map=Map.new(from,g.data.tilesets[from.tileset]) + w.standingOnWarp=false + T.eq(w:tryConnection("right"),false,"sideways carpet probe cannot exit") + T.eq(#g.stack.states,0,"wrong-way carpet retains empty overlay stack") + T.eq(w:tryConnection("down"),true,"directional carpet takes native input-side warp") + local fade=g.stack:top() + T.eq(getmetatable(fade),Transition,"public exit acquires the actual native transition") + T.eq(w:tryConnection("down"),false,"native transition blocks same-tick carpet reentry") + for _=1,fade.frames+fade.framesIn+2 do if g.stack:top()==fade then fade:update(1/60)end end + T.eq(w.mapChanges,1,"native carpet fade materializes once") + T.eq(w.map.id,dest.id,"native carpet reaches resolved destination") + T.eq(w.transitioning,false,"native fade releases transition ownership") +end +-- Native cutscenes keep their explicitly collision-free seam path. +do + local w,game,from,dest,put=fixture("crystal",5,2);put(from,5,2,0xB0);put(dest,0,2,0xB1) + T.eq(w:crossConnection("right",from.connections.east,true),true,"scripted seam retains deliberate collision bypass") +end +-- Each owner/lock is checked before a special can be started, on all three APIs. +local methods={ + function(w)return w:tryConnection("right")end, + function(w)return w:tryLedgeJump("right")end, + function(w)return w:tryPushBoulder("right",3,2)end, +} +for _,call in ipairs(methods)do + local w,g=fixture("crystal") + local calls=0 + w.checkEdgeExit=function()calls=calls+1;return true end + w.checkLedgeHop=function()calls=calls+1;return true end + w.checkBoulderPush=function()calls=calls+1;return true end + w.checkGen2CarpetExit=function()return false end + for _,key in ipairs({"transitioning","flyAnim","teleportOut","engaging","emote","whirlSpin","battleActive","fieldMove","fishing","headbutt","mapSetup","moveState","iceSlide"})do + w[key]=true;T.eq(call(w),false,"native owner refuses handoff: "..key);w[key]=nil + end + for _,key in ipairs({"moving","inputLocked","stairExit","spinning","fieldMove"})do + w.player[key]=true;T.eq(call(w),false,"native actor owner refuses handoff: "..key);w.player[key]=nil + end + g.stack.states={{}};T.eq(call(w),false,"overlay keeps input ownership");g.stack.states={} + w.runner.isRunning=function()return true end;T.eq(call(w),false,"native VM retains ownership");w.runner.isRunning=function()return false end + w.scriptMoves={{}};T.eq(call(w),false,"script queue retains ownership");w.scriptMoves={} + w.pendingScripts={{}};T.eq(call(w),false,"pending map script retains ownership");w.pendingScripts={} + for _,direction in ipairs({"diagonal","",false,17,{}})do + T.eq(w:tryConnection(direction),false,"invalid connection direction cannot enter native handler") + T.eq(w:tryLedgeJump(direction),false,"invalid ledge direction cannot enter native handler") + T.eq(w:tryPushBoulder(direction,3,2),false,"invalid boulder direction cannot enter native handler") + end + local owner=g.overworld;g.overworld={};T.eq(call(w),false,"stale world cannot mutate current owner");g.overworld=owner + g.linkSession={};T.eq(call(w),false,"link session keeps ownership");g.linkSession=nil + T.eq(calls,0,"all refusals happen before native special dispatch") + w.whirlHold=1;T.eq(call(w),false,"unexpired whirlpool hold retains control") + w.whirlHold=0;T.eq(call(w),true,"expired numeric hold permits native handoff") + T.eq(calls,1,"expired hold dispatches exactly once");w.whirlHold=nil + for _,version in ipairs({"red","emerald","platinum"})do V.set(version);T.eq(call(w),false,"Gen2 facade cannot mutate another generation")end +end +-- A synchronous native handler callback cannot queue a second public special. +do + local w=fixture("crystal");local nested=0 + w.checkLedgeHop=function()nested=nested+1;return true end + w.checkGen2CarpetExit=function()return false end + w.checkEdgeExit=function(self) + T.eq(self:tryLedgeJump("right"),false,"native callback cannot reenter public handoff") + return true + end + T.eq(w:tryConnection("right"),true,"outer native handler completes") + T.eq(nested,0,"nested handler was never entered") + w.checkEdgeExit=function()error("native handoff fixture error")end + T.raises(function()w:tryConnection("right")end,"native handoff fixture error","native failures remain observable") + T.eq(w:tryLedgeJump("right"),true,"error cannot leave an admission lock behind") + T.eq(nested,1,"next valid native handler is admitted once") +end +Runtime.emit=oldEmit;Sound.play=oldSound;debug.setupvalue(W.crossConnection,gameSlot,oldGame) +debug.setupvalue(W.checkBoulderPush,scriptsSlot,oldScripts);V.set(originalVersion) +T.finish("native Gen2 special movement handoffs") From 07aedc76ce8daf33cee556dc1a54e86a64d00bce Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 13:01:43 +0100 Subject: [PATCH 15/34] Verify native boulder completion through a public mod observer --- tests/engine/gate_meta_coverage.lua | 1 - tests/engine/gen2_movement_handoffs.lua | 28 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..e0fcf3964 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -126,7 +126,6 @@ local DEBT = { ["event:save.writing"] = "M11", ["event:trade.completed"] = "M12", ["event:world.blacked_out"] = "M5", - ["event:world.boulder_moved"] = "M5", ["event:world.interacted"] = "M5", ["event:world.npc_spawned"] = "M5", ["event:world.trainer_engaged"] = "M5", diff --git a/tests/engine/gen2_movement_handoffs.lua b/tests/engine/gen2_movement_handoffs.lua index 8ab2c7abe..a23da6730 100644 --- a/tests/engine/gen2_movement_handoffs.lua +++ b/tests/engine/gen2_movement_handoffs.lua @@ -8,9 +8,20 @@ local NPC = require("src.world.NPC") local Stack = require("src.core.StateStack") local Transition = require("src.render.Transition") local Sound = require("src.core.Sound") -local Runtime = require("src.mods.Runtime") -local originalVersion, oldSound, oldEmit = V.get(), Sound.play, Runtime.emit +local originalVersion, oldSound = V.get(), Sound.play Sound.play = function() end -- audio device only +local eventProbe=T.sdk.loadMods({"mods/handoff_probe"},{fs=T.sdk.memfs({ + ["mods/handoff_probe/manifest.json"]='{"id":"handoff_probe","name":"Handoff probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ["mods/handoff_probe/main.lua"]=[[ + local mod=... + mod.exports.moves={} + mod.events:on("world.boulder_moved",function(ev) + mod.exports.moves[#mod.exports.moves+1]=ev + end) + ]], +})}) +T.eq(#eventProbe.errors,0,"native boulder observer loads through the public mod API") +local observed=eventProbe.loader.exports.handoff_probe local gameSlot, oldGame for i=1,100 do local name,value=debug.getupvalue(W.crossConnection,i) @@ -115,16 +126,19 @@ for _,version in ipairs({"gold","silver","crystal","prism"})do w,game,from,dest,put=fixture(version) local b=setmetatable({id="HANDOFF_BOULDER",def={index=1,pushable=true},cellX=3,cellY=2,px=48,py=32,moving=false,progress=0,frozen=true,stepFrames=16},{__index=NPC}) w.npcs={b};w.entities={w.player,b};w.strengthActive=true - local moved=0 - Runtime.emit=function(name,ctx)if name=="world.boulder_moved"then moved=moved+1;T.eq(ctx.x,4,"settled boulder event has native x")end end + observed.moves={} T.eq(w:tryPushBoulder("right",4,2),false,"remote cell cannot be supplied as faced boulder") T.eq(w:tryPushBoulder("right",3,2),true,"native Strength queue accepts adjacent boulder") T.eq(w:tryPushBoulder("right",3,2),false,"queued push rejects duplicate handoff") - T.eq(moved,0,"push event waits for native completion") + T.eq(#observed.moves,0,"push event waits for native completion") for _=1,24 do w:updateScriptMoves();b:update(w.map,w.entities)end T.eq(b.cellX,4,"real NPC interpolation settles the boulder") T.eq(w.player.cellX,2,"pushing player stays in their cell") - T.eq(moved,1,"native push completion emits once") + T.eq(#observed.moves,1,"public observer receives native push completion once") + T.eq(observed.moves[1].x,4,"settled boulder event has native x") + T.eq(observed.moves[1].y,2,"settled boulder event has native y") + T.eq(observed.moves[1].npcId,b.id,"settled boulder event identifies actual native actor") + T.eq(observed.moves[1].mapId,from.id,"settled boulder event identifies actual native map") T.eq(#w.scriptMoves,0,"native boulder queue drains") T.check(w.dustAnim~=nil,"native boulder completion owns its dust animation") -- Both the standing side and destination side must permit the boulder. @@ -223,6 +237,6 @@ do T.eq(w:tryLedgeJump("right"),true,"error cannot leave an admission lock behind") T.eq(nested,1,"next valid native handler is admitted once") end -Runtime.emit=oldEmit;Sound.play=oldSound;debug.setupvalue(W.crossConnection,gameSlot,oldGame) +eventProbe.release();Sound.play=oldSound;debug.setupvalue(W.crossConnection,gameSlot,oldGame) debug.setupvalue(W.checkBoulderPush,scriptsSlot,oldScripts);V.set(originalVersion) T.finish("native Gen2 special movement handoffs") From edc4cbefcd125b21e793771b4c4c70d72405a497 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 14:44:02 +0100 Subject: [PATCH 16/34] Expose native Gen2 field state and resume forced terrain after external movement --- src/core/ModCompat.lua | 6 +- src/world/Gen2FieldMoves.lua | 16 ++++ src/world/OverworldController.lua | 38 +++++++- tests/engine/gen2_field_state_queries.lua | 31 +++++++ tests/engine/gen2_forced_body_handoff.lua | 107 ++++++++++++++++++++++ 5 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 src/world/Gen2FieldMoves.lua create mode 100644 tests/engine/gen2_field_state_queries.lua create mode 100644 tests/engine/gen2_forced_body_handoff.lua diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..69e064183 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -27,9 +27,6 @@ -- Deliberately ABSENT, because this engine has no equivalent module and the -- honest answer is "not found": -- --- src.world.gen2.FieldMoves field-move entry points live in --- src/script/* and the fieldmove.eligibility --- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower -- src.ui.gen2.Chrome no shared window-chrome module @@ -63,6 +60,9 @@ ModCompat.ALIASES = { ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", ["src.world.gen2.Permissions"] = "src.world.Collision", + -- State predicates share the actual native mover. Legacy field-action, + -- collision classification and fly-picker functions are not emulated. + ["src.world.gen2.FieldMoves"] = "src.world.Gen2FieldMoves", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data diff --git a/src/world/Gen2FieldMoves.lua b/src/world/Gen2FieldMoves.lua new file mode 100644 index 000000000..7401d1459 --- /dev/null +++ b/src/world/Gen2FieldMoves.lua @@ -0,0 +1,16 @@ +-- Gen2 field-state queries over the native mover. +-- Action admission, scripts, badges, animations and warps remain owned by +-- OverworldController. Do not advertise legacy action entry points here: +-- their context/result protocol is different from the native script VM. +local State = require("src.world.Gen2PlayerState") + +local FieldMoves = { + PLAYER_NORMAL = State.PLAYER_NORMAL, + PLAYER_BIKE = State.PLAYER_BIKE, + PLAYER_SURF = State.PLAYER_SURF, + PLAYER_SURF_PIKA = State.PLAYER_SURF_PIKA, + isBiking = State.isBiking, + isSurfing = State.isSurfing, +} + +return FieldMoves diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ef2d94828..fd15a1ae4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -145,6 +145,41 @@ function OverworldState:tryPushBoulder(dir, cx, cy) return gen2Handoff(self, dir, gen2BoulderHandoff, cx, cy) end +local function gen2ForcedHandoff(world, dir, ice) + local p = world.player + -- The caller has centred its free body before returning ownership. The + -- existing ice handler owns its queue, collision and subsequent landings. + if ice then + world.iceSlide = dir + if not world:checkGen2Ice() then world.turningDirection = nil end + return true + end + -- Current/door collisions choose movement without a held direction. A + -- blocked forced tile still owns this input frame; it cannot be escaped + -- by substituting a different held direction for the forced one. + if Collision.canMove(world.map, world.cast or world.entities, p, dir) then + p.facing = dir + world:scriptMove(p, dir, 1, function() world:onStepComplete() end) + end + return true +end + +-- Resume the native mover after an external body lands on forced terrain. +-- A free body has already run the public landing phases; replaying the whole +-- landing here would double-count steps/events. Start only the existing +-- native movement queue, whose completion runs the next actual landing. +function OverworldState:resumeForcedMovement() + if not GameVersion.isGen2() or not (self.map and self.player + and type(self.map.cellCollision) == "function") then return false end + local coll = self:playerCollision() + local dir = Collision.currentDirection(coll) or Collision.doorForcedDirection(coll) + local ice = Collision.isIce(coll) + if not ice then self.turningDirection = nil end + if not dir and ice then dir = self.turningDirection end + if not Collision.DELTA[dir] then return false end + return gen2Handoff(self, dir, gen2ForcedHandoff, ice) +end + function OverworldState:movementState() if not GameVersion.isGen2() then return nil end return require("src.world.Gen2PlayerState").read(self.player, Game and Game.save) @@ -1201,6 +1236,7 @@ function OverworldState:stampClosedDoors() end function OverworldState:setMap(mapId, x, y, facing, opts) + if GameVersion.isGen2() then self.turningDirection, self.iceSlide = nil, nil end -- BLACK / WHITE'S ARRIVAL GUARD: the warp you land on stays disarmed until -- you step off it (see checkGen4EntranceWarp's gen5 branch) self.gen5ArrivalMap, self.gen5ArrivalX, self.gen5ArrivalY = mapId, x, y @@ -4220,7 +4256,7 @@ function OverworldState:stepBody(scriptJustEnded) or self.whirlSpin or self.player.stairExit end if (not scripted and not self.transitioning) or gen2BootBedroom then - self:handleInput() + if not self:resumeForcedMovement() then self:handleInput() end self.lockedFrames = nil else -- THE WATCHDOG, because "I could not walk" is the one bug report this diff --git a/tests/engine/gen2_field_state_queries.lua b/tests/engine/gen2_field_state_queries.lua new file mode 100644 index 000000000..4407bef93 --- /dev/null +++ b/tests/engine/gen2_field_state_queries.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Compat = require("src.core.ModCompat") +Compat.install() +local Fields = require("src.world.gen2.FieldMoves") +local State = require("src.world.Gen2PlayerState") +T.eq(Fields, require("src.world.Gen2FieldMoves"), "both module names share the native query table") +T.eq(Fields.isBiking, State.isBiking, "bike predicate is the actual native state predicate") +T.eq(Fields.isSurfing, State.isSurfing, "surf predicate is the actual native state predicate") +for _, key in ipairs({"PLAYER_NORMAL", "PLAYER_BIKE", "PLAYER_SURF", "PLAYER_SURF_PIKA"}) do + T.eq(Fields[key], State[key], "native movement constant: " .. key) +end +for _, state in ipairs({"normal", "bike", "surf", "surf_pika", "unknown", 0, false}) do + T.eq(Fields.isBiking(state), state == "bike", "only native bike state is biking") + T.eq(Fields.isSurfing(state), state == "surf" or state == "surf_pika", "both actual surf variants are surfing") +end +T.eq(Fields.isBiking(nil), false, "teardown does not invent a bike state") +T.eq(Fields.isSurfing(nil), false, "teardown does not invent a surf state") +-- Querying the derived state must follow a script's flag changes without a +-- cached mode byte, and must not perform any native action itself. +local player, save = {surfing = false}, {onBike = false} +T.eq(Fields.isBiking(State.read(player, save)), false, "standing player walks") +save.onBike = true +T.eq(Fields.isBiking(State.read(player, save)), true, "native script mounts immediately observable") +player.surfing, player.surfingPikachu = true, true +T.eq(Fields.isSurfing(State.read(player, save)), true, "actual Pikachu surf overrides stale bike flag") +T.eq(save.onBike, true, "observing state does not edit save flags") +T.eq(Fields.fromMenu, nil, "no invented legacy action protocol") +T.eq(Fields.flyPoints, nil, "no invented visited-destination or fly-picker protocol") +T.eq(Fields.encounterTable, nil, "no guessed permission table for imported collision classes") +T.finish("native Gen2 field-state queries") diff --git a/tests/engine/gen2_forced_body_handoff.lua b/tests/engine/gen2_forced_body_handoff.lua new file mode 100644 index 000000000..0b26c8ea8 --- /dev/null +++ b/tests/engine/gen2_forced_body_handoff.lua @@ -0,0 +1,107 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local W = require("src.world.OverworldController") +local Map = require("src.world.Map") +local Player = require("src.world.Player") +local Runner = require("src.script.ScriptRunner") +local Stack = require("src.core.StateStack") +local Sound = require("src.core.Sound") +local originalVersion, oldSound = V.get(), Sound.play +Sound.play = function() end -- audio device only +local slot, oldGame +for i = 1, 100 do + local name, value = debug.getupvalue(W.onStepComplete, i) + if name == "Game" then slot, oldGame = i, value; break end +end +assert(slot) +local function fixture(collision) + local data = T.fixtures.fresh() + local def = {id = "FORCED_BODY", generation = 2, environment = 3, + width = 3, height = 3, blocks = {1,1,1,1,2,1,1,1,1}, warps = {}} + data.maps[def.id] = def + local ts = {id = "FORCED_FLOOR", blockCells = 2, + collision = {255,255,255,255,0,0,0,0,collision,0,0,0}, + walkable = {0,0x23,0x2b,0x71,0x79,0x7a,0x7b,0xb1}, + waterTiles = {0x29}, shoreTiles = {}, grassTiles = {}} + local p = setmetatable({cellX = 2, cellY = 2, px = 32, py = 32, + facing = "down", moving = false, progress = 0, turnTimer = 0, + turnArmed = true, stepFrames = 16, animClock = 0}, {__index = Player}) + local w = setmetatable({map = Map.new(def, ts), player = p, entities = {p}, + npcs = {}, ghosts = {}, scriptMoves = {}, pendingScripts = {}}, {__index = W}) + local g = {data = data, save = {party = {}, flags = {}, player = {name = "QA"}}, + overworld = w, world = w, stack = setmetatable({states = {}, worldState = w}, {__index = Stack})} + w.runner = Runner.new(g, w) + debug.setupvalue(W.onStepComplete, slot, g) + return w, g, ts +end +local directions = {"right", "left", "up", "down"} +for _, version in ipairs({"gold", "silver", "crystal", "prism"}) do + V.set(version) + for byte = 0x30, 0x3f do + local w = fixture(byte) + T.eq(w:resumeForcedMovement(), true, version .. " current owns the input frame") + T.eq(#w.scriptMoves, 1, "one real native movement is queued") + T.eq(w.scriptMoves[1].dir, directions[byte % 4 + 1], "collision chooses native current direction") + T.eq(w.player.moving, false, "queue admission does not interpolate the actor early") + T.eq(w:resumeForcedMovement(), false, "queued owner refuses duplicate admission") + w:updateScriptMoves() + T.eq(w.player.moving, true, "real native script mover begins interpolation") + end + for _, byte in ipairs({0x71, 0x79, 0x7a, 0x7b}) do + local w = fixture(byte) + T.eq(w:resumeForcedMovement(), true, "forced door does not require a held key") + T.eq(w.scriptMoves[1].dir, "down", "door retains its native downward direction") + end + for _, byte in ipairs({0x23, 0x2b}) do + local w = fixture(byte) + T.eq(w:resumeForcedMovement(), false, "standing on ice alone invents no travel direction") + w.turningDirection = "right" + T.eq(w:resumeForcedMovement(), true, "external ice latch returns ownership to native ice handler") + T.eq(w.iceSlide, "right", "actual native ice latch is armed") + T.eq(#w.scriptMoves, 1, "ice starts the existing native queue") + T.eq(w.scriptMoves[1].dir, "right", "ice queue uses actual incoming direction") + T.eq(w.todSteps, nil, "handoff does not replay the free body's completed landing") + end + local w, g, ts = fixture(0x30) + -- Block the east destination without replacing the mover or permission code. + ts.collision[10] = 0x07 + T.eq(w:resumeForcedMovement(), true, "blocked current still owns the input frame") + T.eq(#w.scriptMoves, 0, "blocked current cannot queue a wall crossing") + T.eq(w.player.moving, false, "blocked current cannot start interpolation") + ts.collision[10] = 0xb1 -- destination's west wall + T.eq(w:resumeForcedMovement(), true, "directional wall still retains forced ownership") + T.eq(#w.scriptMoves, 0, "native destination side wall is respected") + for _, field in ipairs({"transitioning", "fieldMove", "fishing", "headbutt", "mapSetup", "moveState", "battleActive"}) do + w = fixture(0x30); w[field] = true + T.eq(w:resumeForcedMovement(), false, "existing owner retains forced movement: " .. field) + T.eq(#w.scriptMoves, 0, "owner cannot acquire an extra queue") + end + w, g = fixture(0x30); g.stack.states = {{}} + T.eq(w:resumeForcedMovement(), false, "an overlay retains input ownership") + w, g = fixture(0x30); w.player.moving = true + T.eq(w:resumeForcedMovement(), false, "mid-step forced input cannot queue another move") + w, g = fixture(0x30); w.runner:run({{"wait", 3}}) + T.eq(w:resumeForcedMovement(), false, "actual native runner retains ownership") + w, g = fixture(0x23); w.turningDirection = "invalid" + T.eq(w:resumeForcedMovement(), false, "invalid external latch is refused") + w, g = fixture(0); w.turningDirection = "right" + T.eq(w:resumeForcedMovement(), false, "ordinary floor remains ordinary input") + T.eq(w.turningDirection, nil, "leaving ice retires the external travel latch") + w, g = fixture(0x30) + local native = w.scriptMove + w.scriptMove = function() error("forced queue error") end + T.eq(pcall(w.resumeForcedMovement, w), false, "native queue errors remain observable") + w.scriptMove = native + T.eq(w:resumeForcedMovement(), true, "queue error does not leave an admission lock") +end +for _, version in ipairs({"red", "emerald", "firered", "platinum"}) do + V.set(version) + local w = fixture(0x30); w.turningDirection = "left" + T.eq(w:resumeForcedMovement(), false, "other generations retain their own movement path") + T.eq(w.turningDirection, "left", "other-generation fields are untouched") + T.eq(#w.scriptMoves, 0, "no Gen2 queue in another generation") +end +V.set(originalVersion); Sound.play = oldSound +debug.setupvalue(W.onStepComplete, slot, oldGame) +T.finish("native Gen2 forced movement after external body landing") From 91c74da05ecb17d59973128e75ed5703a2c73954 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 14:47:57 +0100 Subject: [PATCH 17/34] Record independent native camera control and forced terrain CPU acceptance --- docs/native-gen2-camera-movement.md | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/native-gen2-camera-movement.md diff --git a/docs/native-gen2-camera-movement.md b/docs/native-gen2-camera-movement.md new file mode 100644 index 000000000..4de847072 --- /dev/null +++ b/docs/native-gen2-camera-movement.md @@ -0,0 +1,54 @@ +# Native Gen2 external movement handoffs + +`src.world.gen2.FieldMoves` now resolves to `Gen2FieldMoves` for its movement +constants and `isBiking` / `isSurfing` predicates. They are the existing +`Gen2PlayerState` functions, so scripts and native surf/bicycle changes remain +immediately observable. There is no shadow movement byte. Legacy action, +fly-picker and collision-classification APIs are still absent: native script +and ROM-derived terrain protocols must not be replaced with guessed results. + +This is sufficient for the supplied continuous camera controller to install +over the existing native `pollInput` and `stepBody` phases. Merely supplying +the name was insufficient for forced terrain: that controller completes its +public landing phases itself, then hands ice/current/forced-door movement back +to the engine. The native engine previously waited for its own grid landing +before starting those actions. + +`resumeForcedMovement` now admits that return before ordinary input. Current +and forced-door collision bytes select their native direction without a held +key. The external ice `turningDirection` latch starts the existing ice handler. +Both paths retain native collision, actor interpolation, script queues and +completed-step callbacks. They do not replay an already completed free-body +landing. Blocked forced movement retains ownership of that frame. Scripts, +overlays, transitions, battles and in-progress actions cannot acquire a second +queue. The external ice latch and native ice latch are retired on map changes; +ordinary floor clears the external latch. Other generations retain their path. + +## Verification + +- `tests/engine/gen2_field_state_queries.lua`: 30 state/alias assertions. +- `tests/engine/gen2_forced_body_handoff.lua`: 584 assertions using real native + maps, collision, runner and actor queues across Gold, Silver, Crystal and + Prism, plus other-generation refusal. Includes blocked currents, directional + walls, pending owners and error recovery. +- Existing body/input, special-handoff and CountStep suites: 21, 549 and 313 + assertions respectively. +- The committed fork passes 328 engine suites, three SDK suites and all quick + tiers. The independent upstream composition passes the focused native suites. + +An additional local CPU probe loads the supplied `3.1.2-omega.1.8` +`GoldCameraControls`, `FirstPerson`, `ThirdPerson`, `VoxelState` and `Mat4` +modules without changing their source. It passes 92 assertions on both the +fork and independent upstream composition. It exercises actual camera-space +key/stick math, both selected camera modes (including an extended third-person +body bearing), sub-cell travel, occupied-wall sliding, native counters, a real +coordinate script, Repel text ownership, native bicycle/surf movement and +ice/current/door returns. The previous `main` fails installation with +`Gold free-movement dependencies unavailable`. + +The probe supplies a renderer-availability boundary and does not draw graphics. +It uses public native fixtures rather than an imported ROM. Script clocks +outside the body phase are not a complete game loop. This is **CPU integration +acceptance**, not full-package, GPU, all-story or Windows hardware acceptance. +No supplied mod code/assets or cartridge data are distributed. See the +[source-bound receipt](qa/native-foundation/johto-native-camera-state.json). From 80a1fa9dd88d43150ff8f2519f539c94d36986aa Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 14:48:10 +0100 Subject: [PATCH 18/34] Include referenced native camera acceptance receipt --- .../johto-native-camera-state.json | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/qa/native-foundation/johto-native-camera-state.json diff --git a/docs/qa/native-foundation/johto-native-camera-state.json b/docs/qa/native-foundation/johto-native-camera-state.json new file mode 100644 index 000000000..058f8804c --- /dev/null +++ b/docs/qa/native-foundation/johto-native-camera-state.json @@ -0,0 +1,54 @@ +{ + "schema": 1, + "sourceCommit": "2c0320062fe5a08f38c6e68f5679841502b55027", + "independentUpstreamSource": "edc4cbef", + "previousMain": "321019ae83d70314aab7ac86d91ef0bf9b52b320", + "fullQuick": { + "engineSuites": 328, + "sdkSuites": 3, + "allTiersPassed": true + }, + "focusedChecks": { + "fieldStateQueries": 30, + "forcedBodyHandoff": 584, + "existingBodyPhases": 21, + "existingSpecialHandoffs": 549, + "existingCountStep": 313 + }, + "suppliedCameraProbe": { + "checks": 92, + "passedInForkAndIndependentUpstream": true, + "modVersion": "3.1.2-omega.1.8", + "sourceUnmodified": true, + "cameraModes": [ + "first", + "third" + ], + "thirdPersonExtension": 1, + "nativeMapsCollisionActorsScriptRunnerAndCounters": true, + "rendererAvailabilityBoundarySubstituted": true, + "gpuExecuted": false, + "importedRomUsed": false, + "oldMainResult": "Controller installation fails: Gold free-movement dependencies unavailable", + "moduleSha256": { + "GoldCameraControls.lua": "e1cd84d74faabdde0de18cba1565f5646b91b2ab6577cccb5793e1e875ba8d67", + "FirstPerson.lua": "482c40977d95e5aaa8476f1e3676050c69af959028fa1a4e6d397efa51250417", + "ThirdPerson.lua": "56a98dd4a71bd07b1c75db1adc649a43c96e6dff1e9a7f51325efc398f9deee1", + "VoxelState.lua": "bfa464e438f8a8d2476502301d0f7727f6868545a5fe68b452237b1914918658", + "Mat4.lua": "af1bf410b6d2855cce3d361837f9096613b17f4300a076e8b530fae5228b4827" + } + }, + "scope": "CPU query and native movement/landing integration; not full VASC graphics, gameplay or all field-move parity.", + "releaseUpdated": false, + "privateContentDistributed": false, + "protectedPlayerOrGen1Used": false, + "logSha256": { + "gen2-camera-full-quick.log": "c7ce7db7b553098b65bd64046d221a859d04cb32fc573c96b92f38d2dc6c41b5", + "gen2_field_state_queries.log": "bf6badcb7884bcaeef04ec740078da42737b7db55cf0d7fccfec09f85e408cb7", + "gen2_forced_body_handoff.log": "cdf15faad3d37080d24fdf3d2e86b61e40f157f617ef2d44fdc1e9969fce71a3", + "gen2-camera-upstream-focused.log": "b3e21e7c3244136f2b6dc49a07fb212580d4a7e1587554e9e562397d21bff7c4", + "vasc-camera-native-probe.log": "b411ac287fbcd321b4b2af2fb38effc4fae1ac811f114743a3b3d79b0a53d701", + "vasc-camera-upstream-probe.log": "64e4937e9fb4d0a26736beb7e956770c5717fa6b6ef6c779267a8b327e77d960", + "gen2-camera-main-negative.log": "ac0622b0f37afd4b1e88e4f6547148a39985eb120f077a256899b7a679c2b0ca" + } +} From 6eb08c8b0bf27c880d56a5f7b40c1d32f9b65a88 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:03:42 +0100 Subject: [PATCH 19/34] Record scoped GPU motion checks in fork without claiming upstream rendering acceptance --- docs/native-gen2-camera-movement.md | 21 ++ .../johto-native-camera-state.json | 251 ++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/docs/native-gen2-camera-movement.md b/docs/native-gen2-camera-movement.md index 4de847072..08aace909 100644 --- a/docs/native-gen2-camera-movement.md +++ b/docs/native-gen2-camera-movement.md @@ -52,3 +52,24 @@ outside the body phase are not a complete game loop. This is **CPU integration acceptance**, not full-package, GPU, all-story or Windows hardware acceptance. No supplied mod code/assets or cartridge data are distributed. See the [source-bound receipt](qa/native-foundation/johto-native-camera-state.json). + +## Full-package GPU movement smoke + +At source `62ca106fadebfcca1b262cde05a60a081c10d765`, fresh actual Crystal imports +with all 27,329 supplied Ver.1.8 files unchanged pass the scoped movement smoke +on macOS OpenGL/VSync 0 and Metal/VSync 1. Each backend executes 12 actual free +body ticks in First and Third Person, crosses a logical cell, retains a false +native grid-moving flag and accepts the real V key callback to change mode. +Each backend passes 19 driver assertions and two fresh OS-window proofs. +The images were inspected: they show visible, upright scenery. + +The introduction is skipped and a native New Bark Town position is selected +for this controlled walk. This does not verify normal doors, map boundaries, +forced terrain with GPU, all story/encounters or Windows. A test-only assumption +about `npcAt` stopped the first run; the revised test uses native occupancy. + +The generated model renderer still reports the separate 60-upvalue compile +failure. The fallback scene is visible; avatar/occlusion and complete First/ +Third Person visual equivalence are **not accepted** by these proofs. The PR +remains a development checkpoint pending broader movement/transition tests. +The existing public release assets are unchanged by this follow-up. diff --git a/docs/qa/native-foundation/johto-native-camera-state.json b/docs/qa/native-foundation/johto-native-camera-state.json index 058f8804c..208542462 100644 --- a/docs/qa/native-foundation/johto-native-camera-state.json +++ b/docs/qa/native-foundation/johto-native-camera-state.json @@ -50,5 +50,256 @@ "vasc-camera-native-probe.log": "b411ac287fbcd321b4b2af2fb38effc4fae1ac811f114743a3b3d79b0a53d701", "vasc-camera-upstream-probe.log": "64e4937e9fb4d0a26736beb7e956770c5717fa6b6ef6c779267a8b327e77d960", "gen2-camera-main-negative.log": "ac0622b0f37afd4b1e88e4f6547148a39985eb120f077a256899b7a679c2b0ca" + }, + "gpuMotionSmoke": { + "sourceCommit": "62ca106fadebfcca1b262cde05a60a081c10d765", + "runtimeCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "runtimeExecutableSha256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "package": "3.1.2-omega.1.8", + "runs": [ + { + "sourceCommit": "62ca106fadebfcca1b262cde05a60a081c10d765", + "backend": "OpenGL", + "vsync": 0, + "checks": 19, + "modFilesVerifiedUnmodified": 27329, + "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "osWindowProofs": { + "initial-map": { + "freshFrameStamp": 540, + "minimumFrameStamp": 531, + "centralColours": 9398, + "colourProfile": "gbc", + "minimumColours": 4, + "screenshot": "os-window-initial-map.png", + "attempts": 1 + }, + "second-map": { + "freshFrameStamp": 842, + "minimumFrameStamp": 836, + "centralColours": 1555, + "colourProfile": "gbc", + "minimumColours": 4, + "screenshot": "os-window-second-map.png", + "attempts": 1 + } + }, + "motionAndVSwitchPassed": true, + "modelRendererInstalled": false, + "visualParityAccepted": false, + "phases": [ + { + "camera": "first", + "map": "NEW_BARK_TOWN", + "distance": 12, + "start": { + "x": 48, + "y": 80 + }, + "finish": { + "x": 48, + "y": 96 + }, + "before": { + "remaps": 0, + "freeFrames": 0, + "freeCells": 0, + "wallSlides": 0, + "specialHandoffs": 0, + "mode": "first_and_third_person_continuous", + "lastWorldZ": 0, + "lastWorldX": 0, + "installed": true + }, + "after": { + "remaps": 0, + "freeFrames": 12, + "freeCells": 1, + "wallSlides": 0, + "specialHandoffs": 0, + "mode": "first_and_third_person_continuous", + "lastWorldZ": 0, + "lastWorldX": 0, + "installed": true + }, + "rendererInstalled": false + }, + { + "camera": "third", + "map": "NEW_BARK_TOWN", + "distance": 12, + "start": { + "x": 48, + "y": 80 + }, + "finish": { + "x": 48, + "y": 96 + }, + "before": { + "remaps": 0, + "freeFrames": 12, + "freeCells": 1, + "wallSlides": 0, + "specialHandoffs": 0, + "mode": "first_and_third_person_continuous", + "lastWorldZ": 0, + "lastWorldX": 0, + "installed": true + }, + "after": { + "remaps": 0, + "freeFrames": 24, + "freeCells": 2, + "wallSlides": 0, + "specialHandoffs": 0, + "mode": "first_and_third_person_continuous", + "lastWorldZ": 0, + "lastWorldX": 0, + "installed": true + }, + "rendererInstalled": false + } + ], + "evidenceSha256": { + "native.log": "a9be790e90a430551e195d51795a9db49b79eff126a3334de4e64ce0acfc8f8b", + "verification.json": "245f74e1b3203347613effcb6b32e6327929d8c907b83b203bcd99bf072325ad", + "result.json": "2362b8dd97396d302b9b71871bffe1fd1b53769aba74fa400f24787d2cfeb808", + "os-window-initial-map.png": "c23ba20ce7bede8b4d51c7f77615681078490d3f97b156818756b3f1a884f20a", + "os-window-second-map.png": "13a5cb28b065b6de9f24cb9b15b1e6104fbca5a158b5e3a9c4eb59a28469f689", + "first.png": "ce2163504242c5d7ac0643672b7e2bad3a32cf57a31cbcbd89f0f6f3818784cd", + "third.png": "de46237d37b51d1e2be7b41dc18c788cb0ba674b4593942458270c85e9478f47" + } + }, + { + "sourceCommit": "62ca106fadebfcca1b262cde05a60a081c10d765", + "backend": "Metal", + "vsync": 1, + "checks": 19, + "modFilesVerifiedUnmodified": 27329, + "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "osWindowProofs": { + "initial-map": { + "freshFrameStamp": 538, + "minimumFrameStamp": 531, + "centralColours": 9032, + "colourProfile": "gbc", + "minimumColours": 4, + "screenshot": "os-window-initial-map.png", + "attempts": 1 + }, + "second-map": { + "freshFrameStamp": 841, + "minimumFrameStamp": 834, + "centralColours": 1307, + "colourProfile": "gbc", + "minimumColours": 4, + "screenshot": "os-window-second-map.png", + "attempts": 1 + } + }, + "motionAndVSwitchPassed": true, + "modelRendererInstalled": false, + "visualParityAccepted": false, + "phases": [ + { + "camera": "first", + "map": "NEW_BARK_TOWN", + "distance": 12, + "start": { + "y": 80, + "x": 48 + }, + "finish": { + "y": 96, + "x": 48 + }, + "before": { + "remaps": 0, + "freeFrames": 0, + "freeCells": 0, + "wallSlides": 0, + "specialHandoffs": 0, + "installed": true, + "lastWorldZ": 0, + "mode": "first_and_third_person_continuous", + "lastWorldX": 0 + }, + "after": { + "remaps": 0, + "freeFrames": 12, + "freeCells": 1, + "wallSlides": 0, + "specialHandoffs": 0, + "installed": true, + "lastWorldZ": 0, + "mode": "first_and_third_person_continuous", + "lastWorldX": 0 + }, + "rendererInstalled": false + }, + { + "camera": "third", + "map": "NEW_BARK_TOWN", + "distance": 12, + "start": { + "y": 80, + "x": 48 + }, + "finish": { + "y": 96, + "x": 48 + }, + "before": { + "remaps": 0, + "freeFrames": 12, + "freeCells": 1, + "wallSlides": 0, + "specialHandoffs": 0, + "installed": true, + "lastWorldZ": 0, + "mode": "first_and_third_person_continuous", + "lastWorldX": 0 + }, + "after": { + "remaps": 0, + "freeFrames": 24, + "freeCells": 2, + "wallSlides": 0, + "specialHandoffs": 0, + "installed": true, + "lastWorldZ": 0, + "mode": "first_and_third_person_continuous", + "lastWorldX": 0 + }, + "rendererInstalled": false + } + ], + "evidenceSha256": { + "native.log": "653c46f649777f0e153eab84beaeab33d3966a5734e0a223ad0fe83f2fa0c1b5", + "verification.json": "4ea9f2ce8cd2a790447d328e01e42e68fcaff51e1bc23e9b21c172786b36b7c8", + "result.json": "c030fb7f024a1c035e59c2d87877fb28c3a87a86cb03531363cbff22e56423fc", + "os-window-initial-map.png": "7421f033e768d5e2f2c7a0fd20ccb62fcdcd37047d1c730c479e238680f07729", + "os-window-second-map.png": "ad1b27ae476a9b0a135b12116042746cdcc23e2b19b2d558c879cf3d91696826", + "first.png": "559328b80003b4869fa05927550a6b8dec273c5d2d9568ed849ccb388b11647c", + "third.png": "59889c184d1edfc15ff34d2d5542f3473bfb89b73692f342747bdfa79c3dc0ee" + } + } + ], + "scope": "Fresh actual Crystal import and full unchanged mod; introduction skipped, native town position chosen for controlled walking; 12 free movement ticks and actual V input in each selected camera, then one fresh OS capture per mode. Not all-map, story, forced-terrain GPU or complete model/visual acceptance.", + "renderingLimit": "The supplied generated renderer remains uninstalled at the LuaJIT 60-upvalue compiler limit. The existing fallback renders; complete avatar/occlusion and visual parity are not accepted.", + "hostOnlyChanges": [ + "Entry-point isolation: reject identity switches, bypass legacy migration, reject portable/external roots.", + "Explicit backend, window dimensions and VSync.", + "Frame-marker and test driver; no production/mod sources edited." + ], + "initialHarnessFailure": "First own run stopped at an incorrect test assumption that npcAt exists; the revised test uses native Collision.occupied. No blank-frame retry or last-good-image fallback was added.", + "privateImagesDistributed": false, + "driverSha256": "493f1faa9cffae5319cfc06e9793b48fcebac5ea5b49b04ab3312e12e05d9190" + }, + "hostedCI": { + "pullRequest": 3, + "head": "62ca106fadebfcca1b262cde05a60a081c10d765", + "all65ChecksPassed": true } } From 37ff3c20f5c1a1a7305f8d7490e237423e4c97f0 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:25:52 +0100 Subject: [PATCH 20/34] Keep Apple Silicon on the desktop performance tier --- src/core/Performance.lua | 5 +++-- tests/engine/performance_tiers.lua | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..7684d5369 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -81,7 +81,8 @@ local function processorCount() end -- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on --- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a +-- phones, Apple Silicon Macs and PortMaster handhelds; "x86"/"x64" on +-- Intel/AMD desktops. nil under a -- plain-Lua tool or test with no jit table, which resolves to HIGH (no -- clamping) so tooling is never surprised. local function cpuArch() @@ -102,7 +103,7 @@ function Performance.detect() -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project -- already ships a build for): the weakest target here. - if isArm and os ~= "Android" and os ~= "iOS" then + if isArm and os ~= "Android" and os ~= "iOS" and os ~= "OS X" then return "low" end -- Phones and tablets: GBC FX is already force-disabled here (issue #136); diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2fd..14591e0c5 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -42,6 +42,17 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced") device("OS X", "x64", 8) T.eq(Performance.detect(), "high", "8-core desktop -> high") +device("OS X", "arm64", 8) +T.eq(Performance.detect(), "high", "Apple Silicon is a desktop, not an ARM handheld") +T.eq(Performance.resolve("auto"), "high", "Apple Silicon AUTO avoids the handheld cap") +Performance.applyOptions({ performance = "auto" }) +T.eq(Performance.tier, "high", "Apple Silicon publishes the resolved desktop tier") +T.eq(Performance.resolve("low"), "low", "explicit low remains selectable on Apple Silicon") +T.eq(Performance.resolve("balanced"), "balanced", "explicit balanced remains selectable on Apple Silicon") + +device("OS X", "arm64", 2) +T.eq(Performance.detect(), "balanced", "Apple Silicon retains the desktop core-count floor") + device("Windows", "x64", 2) T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") From 4a99d4a43203f031f952f35d9efbc03d08c426e8 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:29:50 +0100 Subject: [PATCH 21/34] Record Apple Silicon quality diagnosis and Crystal GPU checks --- docs/native-apple-silicon-quality.md | 52 ++++ .../johto-native-apple-silicon-quality.json | 251 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 docs/native-apple-silicon-quality.md create mode 100644 docs/qa/native-foundation/johto-native-apple-silicon-quality.json diff --git a/docs/native-apple-silicon-quality.md b/docs/native-apple-silicon-quality.md new file mode 100644 index 000000000..5ee7f41ef --- /dev/null +++ b/docs/native-apple-silicon-quality.md @@ -0,0 +1,52 @@ +# Apple Silicon desktop quality + +`Performance.detect()` treated every non-mobile ARM host as a low-end +handheld, including macOS. The supplied Johto package correctly identified +the M3 CPU/GPU as capable, but its AUTO policy also consumed that engine +classification and started at LOW. In a fresh Crystal GPU test this selected +the 720p-class scene budget, flat sky, disabled clouds and disabled shadows. + +macOS now uses the existing desktop core-count policy on Apple Silicon, as +Intel Macs already did. Explicit LOW/BALANCED selections remain available; +ARM Linux handhelds and mobile detection retain their existing behavior. +No mod-specific quality preset or saved setting is forced by the engine. + +At fork source `7b05320fcc85c13f445f928586d5cd91ea39e16b`, a fresh profile +reports engine HIGH. The unchanged supplied package starts at MEDIUM under +its own conservative Gen2 AUTO policy, with the balanced scene resolution, +full sky, clouds and low-quality shadows enabled. The previous LOW default +was a classification error, not an intrinsic limit of native Gen2 rendering. + +For the 800×720 logical / 1600×1440 physical test window, measured color +targets are: + +| Selection | Scene color target | Presented world | +| --- | --- | --- | +| Previous fresh AUTO / LOW | 800×720 | 1600×1440 | +| Corrected fresh AUTO / MEDIUM | 1200×1080 | 1600×1440 | +| NATIVE, AA OFF | 1600×1440 | 1600×1440 | +| NATIVE, AA 2X | 2263×2036 | 1600×1440 | + +The active drawWorld pipeline already receives physical pixel dimensions. +Its private adapter translates these correctly; the older compose helper's +logical-size fallback does not explain this active-path reduction. Inspecting +the helper alone would give the wrong diagnosis. Window, scene, readable-depth +and presentation-target sizes must be distinguished. + +The existing performance suite passes 42 assertions, including six new Mac +regressions for detection, AUTO resolution, published tier, explicit selections +and the desktop core-count floor. GPU verification is source-bound in the +[quality receipt](qa/native-foundation/johto-native-apple-silicon-quality.json). +The comparison uses actual imported Crystal and all 27,329 supplied mod files +unchanged, with fresh isolated QA profiles, skipped introduction and a selected +town position. It is image-size/camera smoke coverage, not a frame-rate benchmark +or normal story acceptance. Native/AA presets are changed through the real mod +option event interface only in those QA profiles. + +Sharper edges do not establish complete renderer parity. The generated +Stadium overworld extension still exceeds the stock runtime's 60-upvalue +limit; the base voxel scene is visible, but model/occlusion parity remains +open. A preliminary 4X comparison produced sharper images but its second +owned-window proof failed the stale-frame check. It is not accepted as a +successful 4X run, and 4X is not made the default. No source/assets/screenshots, +ROM data or saves from the supplied package are published. diff --git a/docs/qa/native-foundation/johto-native-apple-silicon-quality.json b/docs/qa/native-foundation/johto-native-apple-silicon-quality.json new file mode 100644 index 000000000..1addb3ddd --- /dev/null +++ b/docs/qa/native-foundation/johto-native-apple-silicon-quality.json @@ -0,0 +1,251 @@ +{ + "scope": "macOS Apple Silicon classification and scoped Crystal GPU resolution comparison; not full VASC visual or gameplay parity", + "sourceCommit": "7b05320fcc85c13f445f928586d5cd91ea39e16b", + "upstreamSourceCommit": "37ff3c20", + "runtime": "LOVE 12 commit 937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "performanceRegressionChecks": 42, + "logicalWindow": [ + 800, + 720 + ], + "physicalWindow": [ + 1600, + 1440 + ], + "oldSource": "9a4f30989d7fd0a846a8fb3228aee380403a2d9c", + "oldObservedAuto": { + "engineTier": "low", + "modInitialTier": "low", + "sceneColorPixels": [ + 800, + 720 + ], + "sky": "flat", + "clouds": "off", + "shadows": false + }, + "runs": [ + { + "backend": "OpenGL", + "sourceCommit": "7b05320fcc85c13f445f928586d5cd91ea39e16b", + "engineTier": "high", + "initialAuto": { + "deviceProfile": "auto", + "sceneResolution": "balanced", + "aa": 0, + "sky": "full", + "clouds": "on", + "shadows": true + }, + "checks": 13, + "ownedWindowProofs": 2, + "proofLabelsAreTransitionTests": false, + "modFilesVerified": 27329, + "originalModUnchanged": true, + "resultSha256": "bdaaaede073090b90c176f6c7c2d1503f0e00299cb118a1163830d3b47e2c91f", + "verificationSha256": "2ad883bf605a3c4b801a9ce9f5ddbe67836dceec1249f296e1128a16a02cde3c", + "selections": [ + { + "camera": "first", + "selection": "default", + "sceneColorPixels": [ + 1200, + 1080 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "balanced", + "aa": 0 + }, + { + "camera": "first", + "selection": "native", + "sceneColorPixels": [ + 1600, + 1440 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 0 + }, + { + "camera": "first", + "selection": "native-aa2", + "sceneColorPixels": [ + 2263, + 2036 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 2 + }, + { + "camera": "third", + "selection": "default", + "sceneColorPixels": [ + 1200, + 1080 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "balanced", + "aa": 0 + }, + { + "camera": "third", + "selection": "native", + "sceneColorPixels": [ + 1600, + 1440 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 0 + }, + { + "camera": "third", + "selection": "native-aa2", + "sceneColorPixels": [ + 2263, + 2036 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 2 + } + ] + }, + { + "backend": "Metal", + "sourceCommit": "7b05320fcc85c13f445f928586d5cd91ea39e16b", + "engineTier": "high", + "initialAuto": { + "deviceProfile": "auto", + "sceneResolution": "balanced", + "aa": 0, + "sky": "full", + "clouds": "on", + "shadows": true + }, + "checks": 13, + "ownedWindowProofs": 2, + "proofLabelsAreTransitionTests": false, + "modFilesVerified": 27329, + "originalModUnchanged": true, + "resultSha256": "d0a46b9a9f85a5c1b0f03ad939651a1e8d5176fe695e0f7d3ff53a8b58f671de", + "verificationSha256": "40d82a9632d001daaef557562552438f91817ac904b98066ebb0460f32121563", + "selections": [ + { + "camera": "first", + "selection": "default", + "sceneColorPixels": [ + 1200, + 1080 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "balanced", + "aa": 0 + }, + { + "camera": "first", + "selection": "native", + "sceneColorPixels": [ + 1600, + 1440 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 0 + }, + { + "camera": "first", + "selection": "native-aa2", + "sceneColorPixels": [ + 2263, + 2036 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 2 + }, + { + "camera": "third", + "selection": "default", + "sceneColorPixels": [ + 1200, + 1080 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "balanced", + "aa": 0 + }, + { + "camera": "third", + "selection": "native", + "sceneColorPixels": [ + 1600, + 1440 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 0 + }, + { + "camera": "third", + "selection": "native-aa2", + "sceneColorPixels": [ + 2263, + 2036 + ], + "worldOutputPixels": [ + 1600, + 1440 + ], + "sceneResolution": "native", + "aa": 2 + } + ] + } + ], + "limits": [ + "Own fresh profiles; introduction skipped and New Bark position selected by QA", + "Only fork GPU runs; upstream has the same source change, no upstream GPU claim", + "OS window proofs cover default selections; quality alternatives have driver screenshots and actual allocation measurements", + "No Windows hardware test or FPS benchmark", + "Generated Stadium overworld extension still exceeds 60 upvalues", + "Preliminary 4X run failed its second OS proof as stale; not accepted", + "No private assets, mod source, cartridge data, screenshots or saves published", + "Existing distributed release remains source 321019ae; this change is pushed in development PRs" + ] +} From 8555c011fda773e8f017d8575da5c5516349fad0 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:51:14 +0100 Subject: [PATCH 22/34] Verify native Crystal doors and map connections with camera controls --- docs/native-gen2-camera-movement.md | 30 ++ .../johto-native-camera-transitions.json | 296 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 docs/qa/native-foundation/johto-native-camera-transitions.json diff --git a/docs/native-gen2-camera-movement.md b/docs/native-gen2-camera-movement.md index 08aace909..4abe2ed48 100644 --- a/docs/native-gen2-camera-movement.md +++ b/docs/native-gen2-camera-movement.md @@ -73,3 +73,33 @@ failure. The fallback scene is visible; avatar/occlusion and complete First/ Third Person visual equivalence are **not accepted** by these proofs. The PR remains a development checkpoint pending broader movement/transition tests. The existing public release assets are unchanged by this follow-up. + +## Full-package normal transition smoke + +The next actual Crystal matrix passes 212 assertions on each of macOS +OpenGL and Metal. In both First and Third Person it walks through the normal +Cherrygrove Mart and Center entrances, opens/closes the native START menu +without moving behind it, walks inside to the exit, and returns through the +real directional carpet and native fade. It also crosses the actual +Cherrygrove/Route 29 connection in both directions. That is 12 transitions, +four menu ownership checks and four fresh owned-window proofs per backend. +Selected camera modes survive normal map handoffs and all final native owners +are clear. Original supplied files remain byte-identical. + +OpenGL uses source `18489b7a`; Metal uses `aa669f69`, which additionally +integrates the current fork's separate launcher work. The +[source-bound receipt](qa/native-foundation/johto-native-camera-transitions.json) +distinguishes these builds. Initial exterior approaches are selected by QA; +travel itself uses real native input. Camera yaw uses the supplied look API, +so this is not physical mouse acceptance. No window resize is implied by the +legacy capture labels. + +Two failed scenarios remain explicit: the first backward-facing entrance +looked into the front void and failed the blank-frame guard; the ordinary +forward-looking approach passes without changing that guard. A separate +fresh-save New Bark west approach did not clear a native owner and is not +accepted here. The successful connection matrix uses Cherrygrove/Route 29, +outside that early-story approach. Full story, all maps, forced-terrain GPU, +Windows and complete avatar/model/occlusion parity remain open. The generated +Stadium extension is still uninstalled. This checkpoint does not change the +existing release assets. diff --git a/docs/qa/native-foundation/johto-native-camera-transitions.json b/docs/qa/native-foundation/johto-native-camera-transitions.json new file mode 100644 index 000000000..14924a799 --- /dev/null +++ b/docs/qa/native-foundation/johto-native-camera-transitions.json @@ -0,0 +1,296 @@ +{ + "scope": "Crystal full-package normal door/carpet and Cherrygrove/Route29 seam smoke with unchanged continuous VASC controls", + "runs": [ + { + "backend": "OpenGL", + "sourceCommit": "18489b7acc27b44dc5ef5ddb1877f0a870a794a9", + "checks": 212, + "transitions": [ + { + "kind": "door-entry-CHERRYGROVE_MART", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_MART", + "freeFramesBefore": 0, + "freeFramesAfter": 9, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_MART", + "camera": "first", + "source": "CHERRYGROVE_MART", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 41, + "freeFramesAfter": 60, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_POKECENTER1_F", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_POKECENTER_1F", + "freeFramesBefore": 60, + "freeFramesAfter": 69, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_POKECENTER1_F", + "camera": "first", + "source": "CHERRYGROVE_POKECENTER_1F", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 85, + "freeFramesAfter": 104, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "east-map-seam", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "ROUTE_29", + "freeFramesBefore": 104, + "freeFramesAfter": 123, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "west-map-seam", + "camera": "first", + "source": "ROUTE_29", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 123, + "freeFramesAfter": 126, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_MART", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_MART", + "freeFramesBefore": 126, + "freeFramesAfter": 135, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_MART", + "camera": "third", + "source": "CHERRYGROVE_MART", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 167, + "freeFramesAfter": 186, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_POKECENTER1_F", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_POKECENTER_1F", + "freeFramesBefore": 186, + "freeFramesAfter": 195, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_POKECENTER1_F", + "camera": "third", + "source": "CHERRYGROVE_POKECENTER_1F", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 211, + "freeFramesAfter": 230, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "east-map-seam", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "ROUTE_29", + "freeFramesBefore": 230, + "freeFramesAfter": 249, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "west-map-seam", + "camera": "third", + "source": "ROUTE_29", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 249, + "freeFramesAfter": 252, + "busyAfter": false, + "nativeOverlayAfter": false + } + ], + "menus": 4, + "ownedWindowProofs": 4, + "proofLabels": [ + "initial-map", + "second-map", + "resized", + "map-return" + ], + "proofLabelsDoNotImplyResize": true, + "originalModUnchanged": true, + "verifiedModFiles": 27329, + "modelRendererInstalled": false, + "resultSha256": "ca4482f7dc4a53f477c34b6947fd945b3208173cdce1ef2b001c4b4775eecf97", + "verificationSha256": "cb2330ebb884aed865d768faeb8f79e93d8e7807ae1cc95c22c05dae02e0d716" + }, + { + "backend": "Metal", + "sourceCommit": "aa669f69136d1db579937e767a2ff63beb755ad1", + "checks": 212, + "transitions": [ + { + "kind": "door-entry-CHERRYGROVE_MART", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_MART", + "freeFramesBefore": 0, + "freeFramesAfter": 9, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_MART", + "camera": "first", + "source": "CHERRYGROVE_MART", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 41, + "freeFramesAfter": 60, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_POKECENTER1_F", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_POKECENTER_1F", + "freeFramesBefore": 60, + "freeFramesAfter": 69, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_POKECENTER1_F", + "camera": "first", + "source": "CHERRYGROVE_POKECENTER_1F", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 85, + "freeFramesAfter": 104, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "east-map-seam", + "camera": "first", + "source": "CHERRYGROVE_CITY", + "destination": "ROUTE_29", + "freeFramesBefore": 104, + "freeFramesAfter": 123, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "west-map-seam", + "camera": "first", + "source": "ROUTE_29", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 123, + "freeFramesAfter": 126, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_MART", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_MART", + "freeFramesBefore": 126, + "freeFramesAfter": 135, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_MART", + "camera": "third", + "source": "CHERRYGROVE_MART", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 167, + "freeFramesAfter": 186, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-entry-CHERRYGROVE_POKECENTER1_F", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "CHERRYGROVE_POKECENTER_1F", + "freeFramesBefore": 186, + "freeFramesAfter": 195, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "door-exit-CHERRYGROVE_POKECENTER1_F", + "camera": "third", + "source": "CHERRYGROVE_POKECENTER_1F", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 211, + "freeFramesAfter": 230, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "east-map-seam", + "camera": "third", + "source": "CHERRYGROVE_CITY", + "destination": "ROUTE_29", + "freeFramesBefore": 230, + "freeFramesAfter": 249, + "busyAfter": false, + "nativeOverlayAfter": false + }, + { + "kind": "west-map-seam", + "camera": "third", + "source": "ROUTE_29", + "destination": "CHERRYGROVE_CITY", + "freeFramesBefore": 249, + "freeFramesAfter": 252, + "busyAfter": false, + "nativeOverlayAfter": false + } + ], + "menus": 4, + "ownedWindowProofs": 4, + "proofLabels": [ + "initial-map", + "second-map", + "resized", + "map-return" + ], + "proofLabelsDoNotImplyResize": true, + "originalModUnchanged": true, + "verifiedModFiles": 27329, + "modelRendererInstalled": false, + "resultSha256": "1662374c40fbdd10c25cfd11a2cc3ce379f5224d9b1941615ad3f47153be8653", + "verificationSha256": "2eaee97dd6480a937fd4463205cafb0e687b212e3c64416fb0a8260f9cff85d9" + } + ], + "setup": "Fresh owned profiles; introduction skipped; initial exterior approaches selected by QA. Door traversal, interior walking, exit carpets and connection crossings use real input and native owners. Camera yaw uses the supplied look API, not OS mouse hardware.", + "limits": [ + "Not all-story, forced-terrain GPU, all maps, Windows or complete visual parity acceptance", + "Initial backward-facing door scenario failed the blank-frame guard; test approach was corrected without weakening the guard", + "Fresh-save New Bark west approach did not settle; its story/owner scenario remains open and is not accepted by this matrix", + "OpenGL precedes the separate launcher merge; Metal uses the combined fork including that merge", + "Four legacy capture labels identify two buildings in two cameras; no resize is claimed", + "Generated Stadium extension remains uninstalled; no private source, ROM data, assets, images or saves are published" + ] +} From dff670c9c41a96f7c907f5e5addb82e12d589c6f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:59:31 +0100 Subject: [PATCH 23/34] Qualify an isolated higher-upvalue runtime for native 3D mods --- .../johto-native-runtime-upvalues.json | 190 ++++++++++++++++++ tools/runtime-upvalue-check/README.md | 57 ++++++ tools/runtime-upvalue-check/c_api.c | 35 ++++ tools/runtime-upvalue-check/check.lua | 72 +++++++ .../luajit-upvalues.patch | 12 ++ 5 files changed, 366 insertions(+) create mode 100644 docs/qa/native-foundation/johto-native-runtime-upvalues.json create mode 100644 tools/runtime-upvalue-check/README.md create mode 100644 tools/runtime-upvalue-check/c_api.c create mode 100644 tools/runtime-upvalue-check/check.lua create mode 100644 tools/runtime-upvalue-check/luajit-upvalues.patch diff --git a/docs/qa/native-foundation/johto-native-runtime-upvalues.json b/docs/qa/native-foundation/johto-native-runtime-upvalues.json new file mode 100644 index 000000000..26ccaf62d --- /dev/null +++ b/docs/qa/native-foundation/johto-native-runtime-upvalues.json @@ -0,0 +1,190 @@ +{ + "status": "experimental qualification; not distributed", + "scope": "Higher-upvalue LuaJIT dependency allows an unchanged supplied Gen2 renderer extension to compile and initialize in owned Crystal GPU tests", + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "patch": "tools/runtime-upvalue-check/luajit-upvalues.patch", + "patchSha256": "022b346f64aa620a04c0ccfee616088b747e340ac3ac3485680a7eb30e9652dd", + "capacity": { + "stock": 60, + "experimental": 120 + }, + "build": { + "platform": "macOS arm64", + "MACOSX_DEPLOYMENT_TARGET": "11.0", + "flags": "-DLUAJIT_ENABLE_LUA52COMPAT", + "candidateLibrarySha256": "d30fa0c327754e1de28a92ba4e65fe3f60427dfefc8c591a58398a32f2d83cdd", + "originalLoveLuaSha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc", + "originalLoveUnchanged": true, + "separateSignedQaApp": true, + "baseLoveCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "stockBundledLuaJitVersion": "2.1.1781602682" + }, + "compileOnly": { + "generatedBytes": 161419, + "stock": false, + "experimental": true, + "moduleExecuted": false + }, + "closureChecks": [ + { + "variant": "stock", + "checks": 160250, + "minimum": 60, + "cases": [ + { + "upvalues": 1, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 8, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 32, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 60, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 61, + "compiles": false + }, + { + "upvalues": 64, + "compiles": false + }, + { + "upvalues": 96, + "compiles": false + }, + { + "upvalues": 120, + "compiles": false + } + ], + "logSha256": "28a59fb5dcc323eabc5264bdc831427a733365f1894b9dc0e6db42b37a7b1cbd" + }, + { + "variant": "extended", + "checks": 320964, + "minimum": 120, + "cases": [ + { + "jit": true, + "upvalues": 1, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 8, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 32, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 60, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 61, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 64, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 96, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 120, + "compiles": true, + "interpreter": true + } + ], + "logSha256": "baca1c129315e49184b69f020b15a441d1d7c2220f260c313c73f7730733ca72" + } + ], + "cApi": { + "captures": [ + 60, + 61, + 64, + 96, + 120 + ], + "callsPerCount": 10000, + "passed": true + }, + "engineQuick": { + "source": "6fc36bab7b3abb44db2dc97e743c9753fb146cfb", + "engineSuites": 343, + "sdkSuites": 3, + "allQuickTiersPassed": true, + "logSha256": "a009b7651c67f40387f5df17a8d36f686d3fc9f36c596f6dba173213776883e8" + }, + "gpu": [ + { + "backend": "OpenGL", + "engineSource": "aa669f69136d1db579937e767a2ff63beb755ad1", + "checks": 16, + "cameras": [ + "first", + "third" + ], + "rendererExtensionInstalled": true, + "ownedWindowProofs": 2, + "unchangedModFiles": 27329, + "resultSha256": "74c3d1495f0fb690bf5ea8b81045b0f099dcba5f199d6f67ab2003244f915b1a", + "verificationSha256": "9f74b72918b4e9d16add6704d8ca9b0322b21ef005f4de35a2eddd61c844a8c8" + }, + { + "backend": "Metal", + "engineSource": "aa669f69136d1db579937e767a2ff63beb755ad1", + "checks": 16, + "cameras": [ + "first", + "third" + ], + "rendererExtensionInstalled": true, + "ownedWindowProofs": 2, + "unchangedModFiles": 27329, + "resultSha256": "d3056fff2acecb0ec70c9c7bbe884c438f92c38758c00fc169b77653b6aa83bc", + "verificationSha256": "c9dd571f1a3702ca4a104a33d42a9b8d42dc9acba17e164d902d9db01eb42926" + } + ], + "initialProbeCorrection": "A debug.setupvalue check first omitted the stock LuaJIT trace flush required before changing a constant captured value. The corrected probe explicitly flushes, retaining interpreter/JIT, GC, shared captures and bytecode checks; it does not disable JIT for large closures.", + "limits": [ + "Compiler and GPU checks are separate: GPU uses a copied LOVE12 app with a new arm64 Lua library", + "No Windows or Intel macOS/universal framework/packaging acceptance yet", + "No complete Pokemon model, all effects, battles, movement or all-story parity claim", + "Stock release unchanged; installing the source PR does not replace its Lua runtime", + "No private mod source/assets, generated renderer source, ROM data, screenshots or saves published" + ] +} diff --git a/tools/runtime-upvalue-check/README.md b/tools/runtime-upvalue-check/README.md new file mode 100644 index 000000000..7d849092a --- /dev/null +++ b/tools/runtime-upvalue-check/README.md @@ -0,0 +1,57 @@ +# Experimental runtime upvalue qualification + +The native engine's current stock LÖVE runtime accepts at most 60 captured +locals per Lua function. A supplied Gen2 structural renderer extension exceeds +that limit, although its original base module compiles. Increasing the engine +API surface cannot make that generated function compile in a VM with the same +limit. + +The patch here is an **experimental dependency patch**, not an engine-side +source rewriter. It applies to LuaJIT commit +`a4c064539ea931274d407e1249b4bb575424a0f4` and raises `LJ_MAX_UPVAL` to 120. +The recorder's own source notes a 127 limit; this is not proof that every +architecture is safe at 120. The normal runtime/release is not replaced by +these tools. No supplied renderer source/assets or cartridge data are included. + +Always use a separate clean dependency checkout/build directory. On macOS the +local experiment builds with `MACOSX_DEPLOYMENT_TARGET=11.0` and +`make -j4 XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT`. Its LÖVE test app is a separate +copy with an arm64-only replacement Lua framework and ad-hoc signing. The +original stock app and dependency checkout remain unchanged. This is not a +universal macOS or Windows release recipe. + +From the engine repository root, run both interpreters: + +```sh +/path/to/stock/luajit tools/runtime-upvalue-check/check.lua 60 +/path/to/experimental/luajit tools/runtime-upvalue-check/check.lua 120 +``` + +The ROM-free Lua probe exercises interpreter and JIT calls, shared sibling +captures, repeated full GC, high-index debug reads/writes and bytecode round +trips at 1, 8, 32, 60, 61, 64, 96 and 120 captures. Unsupported counts above the +selected minimum are reported; rejection at/below the requested minimum fails. +Debug replacement of constant upvalues flushes existing traces first, matching +[stock LuaJIT's debug-surgery behavior](https://github.com/LuaJIT/LuaJIT/issues/968). +An earlier probe omitted that flush and failed at 64 in its JIT phase; that +result is retained as an invalid test assumption, not a successful VM result +or a reason to silently disable JIT for large closures. + +Compile `c_api.c` against the *same candidate headers and static library*, then +run it with 60, 61, 64, 96 and 120 as arguments. It pushes those captures through +the actual Lua C API, repeatedly invokes the C closure and collects garbage. +For example on macOS: + +```sh +cc -I/path/to/candidate/src tools/runtime-upvalue-check/c_api.c \ + /path/to/candidate/src/libluajit.a -lm -o /path/to/own-output/c-api-upvalues +/path/to/own-output/c-api-upvalues 120 +``` + +These microchecks must accompany engine regressions and real GPU verification. +A generated extension compiling/initializing is not proof that all its Pokemon +models, movement, combat, shadows and other effects are equivalent to Gen1. +Windows/x64, Intel macOS, architecture packaging, dependency ABI compatibility, +bytecode interchange and broader native renderer/gameplay coverage remain +release gates. See the source-bound runtime experiment receipt under +`docs/qa/native-foundation/` for completed local evidence and exact limits. diff --git a/tools/runtime-upvalue-check/c_api.c b/tools/runtime-upvalue-check/c_api.c new file mode 100644 index 000000000..d9839047a --- /dev/null +++ b/tools/runtime-upvalue-check/c_api.c @@ -0,0 +1,35 @@ +/* Public Lua 5.1 C API boundary probe: no engine/mod/ROM inputs. */ +#include +#include +#include "lua.h" +#include "lauxlib.h" +#include "lualib.h" +static int capacity; +static int captured_sum(lua_State *L) { + lua_Number sum = 0; + int i; + for (i = 1; i <= capacity; ++i) sum += lua_tonumber(L, lua_upvalueindex(i)); + lua_pushnumber(L, sum); + return 1; +} +int main(int argc, char **argv) { + lua_State *L = luaL_newstate(); + int i, round; + capacity = argc > 1 ? atoi(argv[1]) : 60; + if (!L || capacity < 1 || capacity > 120) return 2; + luaL_openlibs(L); + if (!lua_checkstack(L, capacity + 4)) return 3; + for (i = 1; i <= capacity; ++i) lua_pushnumber(L, i); + lua_pushcclosure(L, captured_sum, capacity); + lua_setglobal(L, "captured_sum"); + for (round = 0; round < 10000; ++round) { + lua_getglobal(L, "captured_sum"); + if (lua_pcall(L, 0, 1, 0)) return 4; + if (lua_tonumber(L, -1) != capacity * (capacity + 1) / 2) return 5; + lua_pop(L, 1); + if (round % 251 == 0) lua_gc(L, LUA_GCCOLLECT, 0); + } + lua_close(L); + printf("C_API_UPVALUES %d PASS\n", capacity); + return 0; +} diff --git a/tools/runtime-upvalue-check/check.lua b/tools/runtime-upvalue-check/check.lua new file mode 100644 index 000000000..7f0616597 --- /dev/null +++ b/tools/runtime-upvalue-check/check.lua @@ -0,0 +1,72 @@ +-- ROM-free runtime probe. A higher minimum is an explicit experimental gate; +-- the stock runtime is still expected to support the default minimum of 60. +local minimum = tonumber(arg and arg[1]) or 60 +assert(minimum >= 1 and minimum <= 120, 'minimum must be between 1 and 120') +local Json = require('src.link.Json') +local compile = loadstring or load +local report = {runtime = jit and jit.version or _VERSION, minimum = minimum, + cases = {}, checks = 0} +local function check(value, reason) + assert(value, reason); report.checks = report.checks + 1 +end +local function factory(count) + local lines, terms = {}, {} + for i = 1, count do + lines[#lines + 1] = ('local v%d = %d'):format(i, i) + terms[#terms + 1] = 'v' .. i + end + local sum = table.concat(terms, '+') + lines[#lines + 1] = ('return function(d) v%d=v%d+d; return %s end, ' + .. 'function(d) v1=v1+d; return %s end'):format(count, count, sum, sum) + return compile(table.concat(lines, '\n'), '@synthetic-shared-upvalues-' .. count) +end +local wasJit = jit and jit.status() +for _, count in ipairs({1, 8, 32, 60, 61, 64, 96, 120}) do + local chunk, why = factory(count) + local result = {upvalues = count, compiles = chunk ~= nil} + if not chunk then + check(count > minimum, 'required closure rejected: ' .. tostring(why)) + check(tostring(why):find('upvalues', 1, true), 'unexpected compiler rejection') + else + for _, mode in ipairs({'interpreter', 'jit'}) do + if mode == 'interpreter' then if jit then jit.off(); jit.flush() end + elseif jit then jit.on(); jit.flush(); jit.opt.start('hotloop=1', 'hotexit=1') end + local first, second = chunk() + local found = 0 + while debug.getupvalue(first, found + 1) do found = found + 1 end + check(found == count, 'closure captures the requested number of values') + local expected = count * (count + 1) / 2 + for step = 1, 10000 do + expected = expected + 1 + check(first(1) == expected, 'first closure retains all captured values') + expected = expected + 1 + check(second(1) == expected, 'sibling closure shares the captured cells') + if step % 251 == 0 then collectgarbage('collect') end + end + local name, value = debug.getupvalue(first, found) + check(name ~= nil and type(value) == 'number', 'high-index debug read') + -- Match stock LuaJIT's debug-surgery contract: a previously constant + -- upvalue may be embedded in a trace. Flush before replacing it. + -- https://github.com/LuaJIT/LuaJIT/issues/968 + if jit then jit.flush() end + check(debug.setupvalue(first, found, value + 7) == name, 'high-index debug write') + expected = expected + 7 + local observed = second(0) + check(observed == expected, ('debug write reaches the shared sibling cell (%d, %s: %s vs %s)') + :format(count, mode, tostring(observed), tostring(expected))) + local dumped = string.dump(first) + local restored = assert(compile(dumped, '@restored-shared-upvalues')) + if jit then jit.flush() end + for index = 1, found do + local _, current = debug.getupvalue(first, index) + check(debug.setupvalue(restored, index, current) ~= nil, 'restore bytecode upvalue') + end + check(restored(0) == expected, 'bytecode round trip retains high-index values') + result[mode] = true + end + end + report.cases[#report.cases + 1] = result +end +if jit then if wasJit then jit.on() else jit.off() end end +report.passed = true +print('RUNTIME_UPVALUES ' .. Json.encode(report)) diff --git a/tools/runtime-upvalue-check/luajit-upvalues.patch b/tools/runtime-upvalue-check/luajit-upvalues.patch new file mode 100644 index 000000000..18cd521b1 --- /dev/null +++ b/tools/runtime-upvalue-check/luajit-upvalues.patch @@ -0,0 +1,12 @@ +diff --git a/src/lj_def.h b/src/lj_def.h +--- a/src/lj_def.h ++++ b/src/lj_def.h +@@ -66,7 +66,7 @@ + #define LJ_MAX_BCINS (1<<26) /* Max. # of bytecode instructions. */ + #define LJ_MAX_SLOTS 250 /* Max. # of slots in a Lua func. */ + #define LJ_MAX_LOCVAR 200 /* Max. # of local variables. */ +-#define LJ_MAX_UPVAL 60 /* Max. # of upvalues. */ ++#define LJ_MAX_UPVAL 120 /* Max. # of upvalues. */ + + #define LJ_MAX_IDXCHAIN 100 /* __index/__newindex chain limit. */ + #define LJ_STACK_EXTRA (5+3*LJ_FR2) /* Extra stack space (metamethods). */ From 874090719640288b4fd317d110e1962dde8d5ee5 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:10:13 +0100 Subject: [PATCH 24/34] Build and qualify native runtimes across desktop architectures --- .github/workflows/runtime-upvalues.yml | 61 +++++++++++ tools/runtime-upvalue-check/README.md | 28 +++++ tools/runtime-upvalue-check/qualify.py | 145 +++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 .github/workflows/runtime-upvalues.yml create mode 100644 tools/runtime-upvalue-check/qualify.py diff --git a/.github/workflows/runtime-upvalues.yml b/.github/workflows/runtime-upvalues.yml new file mode 100644 index 000000000..d8a860d1a --- /dev/null +++ b/.github/workflows/runtime-upvalues.yml @@ -0,0 +1,61 @@ +name: Native runtime capacity +on: + pull_request: + paths: + - '.github/workflows/runtime-upvalues.yml' + - 'tools/runtime-upvalue-check/**' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: runtime-upvalues-${{ github.ref }} + cancel-in-progress: true +jobs: + qualify: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + label: linux-x64 + - runner: windows-2022 + label: windows-x64 + - runner: macos-15-intel + label: macos-x64 + - runner: macos-15 + label: macos-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + with: + path: engine + - uses: actions/checkout@v7 + with: + repository: LuaJIT/LuaJIT + ref: a4c064539ea931274d407e1249b4bb575424a0f4 + path: runtime-source + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 + if: runner.os == 'Windows' + with: + arch: x64 + - name: Build both runtimes and qualify closures, C API and engine + shell: bash + run: >- + python engine/tools/runtime-upvalue-check/qualify.py + --luajit-source runtime-source + --output "${{ runner.temp }}/runtime-capacity/candidate" + --engine-tests + - uses: actions/upload-artifact@v7 + if: always() + with: + name: runtime-capacity-${{ matrix.label }} + path: | + ${{ runner.temp }}/runtime-capacity/candidate/report.json + ${{ runner.temp }}/runtime-capacity/candidate/*.log + ${{ runner.temp }}/runtime-capacity/candidate/artifacts/ + if-no-files-found: error + retention-days: 14 diff --git a/tools/runtime-upvalue-check/README.md b/tools/runtime-upvalue-check/README.md index 7d849092a..b116e51dd 100644 --- a/tools/runtime-upvalue-check/README.md +++ b/tools/runtime-upvalue-check/README.md @@ -20,6 +20,34 @@ copy with an arm64-only replacement Lua framework and ad-hoc signing. The original stock app and dependency checkout remain unchanged. This is not a universal macOS or Windows release recipe. +## Reproducible qualification builds + +`qualify.py` builds both variants from the same clean, pinned Git input, without +modifying that checkout. It refuses existing output directories and checks that +the dependency patch applies to the pinned blobs. On local participating +checkouts it uses the shared QA storage leases. For example: + +```sh +python3 tools/runtime-upvalue-check/qualify.py \ + --luajit-source /path/to/clean/LuaJIT \ + --output .local/qa/runtime-capacity-test/candidate --engine-tests +``` + +The Windows build requires an x64 MSVC developer environment. Unix uses the +host C compiler and make; macOS defaults the deployment target to 11.0 unless +the caller explicitly supplies one. The output includes per-step logs, a +source/tool/library hash report and separate licensed stock/extended artifacts. +These are qualification artifacts, not a replacement installation or a release. + +The `Native runtime capacity` workflow schedules this same check on Linux x64, +Windows x64, Intel macOS and arm64 macOS using explicit +[GitHub runner labels](https://github.com/actions/runner-images#available-images). +Both variants must pass the closure/C API checks and the engine's quick tiers; +the workflow does not publish a GitHub release. Loading the candidate library +inside the actual packaged LÖVE host remains a separate ABI/runtime gate. + +## Individual probes + From the engine repository root, run both interpreters: ```sh diff --git a/tools/runtime-upvalue-check/qualify.py b/tools/runtime-upvalue-check/qualify.py new file mode 100644 index 000000000..a02e0e66f --- /dev/null +++ b/tools/runtime-upvalue-check/qualify.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Build and qualify stock/extended LuaJIT without changing the input checkout.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +PIN = "a4c064539ea931274d407e1249b4bb575424a0f4" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def git(source, *args): + return subprocess.check_output(["git", "-C", str(source), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--luajit-source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True, + help="new disposable candidate directory; never an installation") + parser.add_argument("--engine-tests", action="store_true") + args = parser.parse_args() + source, output = args.luajit_source.resolve(), args.output.resolve() + if git(source, "rev-parse", "HEAD") != PIN: + parser.error("LuaJIT source must be pinned to " + PIN) + original_status = git(source, "status", "--porcelain", "--untracked-files=normal") + if original_status: + parser.error("LuaJIT input checkout must be clean") + if output.exists(): + parser.error("output already exists; select a new disposable directory") + if output == ROOT or output.is_relative_to(source) or source.is_relative_to(output): + parser.error("output must be separate from source checkouts") + run = subprocess.run + if os.name != "nt" and not os.getenv("GITHUB_ACTIONS") and (ROOT / "tools/qa_storage.py").is_file(): + sys.path.insert(0, str(ROOT / "tools")) + from qa_storage import qa_source, qa_copy, qa_cache, qa_run + for path in (ROOT, source, HERE, Path(sys.executable)): + qa_source(path) + qa_cache(output.parent) + qa_copy(output) + run = qa_run + patch = HERE / "luajit-upvalues.patch" + # Check the pinned Git blobs, independently of Windows checkout CRLF rules. + subprocess.run(["git", "-C", str(source), "apply", "--check", "--cached", str(patch)], check=True) + archive = subprocess.check_output(["git", "-C", str(source), "archive", PIN]) + output.mkdir(parents=True, exist_ok=False) + artifacts = output / "artifacts" + artifacts.mkdir() + report = {"experimental": True, "luaJitSource": PIN, + "engineSource": git(ROOT, "rev-parse", "HEAD"), + "platform": platform.system(), "machine": platform.machine(), + "toolsSha256": {p.name: digest(p) for p in sorted(HERE.iterdir()) if p.is_file()}, + "variants": []} + + def command(label, argv, cwd, env=None, timeout=180): + result = run([str(value) for value in argv], cwd=cwd, env=env, + capture_output=True, text=True, timeout=timeout) + (output / (label + ".log")).write_text(result.stdout + result.stderr, encoding="utf-8") + if result.returncode: + print((result.stdout + result.stderr)[-6000:], flush=True) + result.check_returncode() + return result.stdout + + for label, capacity in (("stock", 60), ("extended", 120)): + build = output / label + build.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive)) as files: + files.extractall(build, filter="data") + header = build / "src/lj_def.h" + before = header.read_text() + needle = "#define LJ_MAX_UPVAL\t60" + if before.count(needle) != 1: + raise RuntimeError("pinned upvalue declaration changed") + if capacity != 60: + header.write_text(before.replace(needle, "#define LJ_MAX_UPVAL\t120")) + env = dict(os.environ) + if platform.system() == "Darwin": + env.setdefault("MACOSX_DEPLOYMENT_TARGET", "11.0") + if os.name == "nt": + command(label + "-build", ["cmd", "/d", "/c", "msvcbuild.bat", "lua52compat"], + build / "src", env, timeout=300) + interpreter = build / "src/luajit.exe" + library = build / "src/lua51.dll" + c_program = build / "src/c-api-upvalues.exe" + command(label + "-c-build", ["cl", "/nologo", "/MD", "/I" + str(build / "src"), + HERE / "c_api.c", build / "src/lua51.lib", "/Fe" + str(c_program)], build, env) + else: + command(label + "-build", ["make", "-j4", "XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT"], build, env, 300) + interpreter = build / "src/luajit" + library = build / "src/libluajit.so" + c_program = build / "c-api-upvalues" + links = ["-lm"] + (["-ldl"] if platform.system() == "Linux" else []) + command(label + "-c-build", ["cc", "-I" + str(build / "src"), HERE / "c_api.c", + build / "src/libluajit.a", *links, "-o", c_program], build, env) + text = command(label + "-closures", [interpreter, HERE / "check.lua", capacity], ROOT, env, 60) + closure = json.loads(next(line.removeprefix("RUNTIME_UPVALUES ") for line in text.splitlines() + if line.startswith("RUNTIME_UPVALUES "))) + if not closure.get("passed") or closure["minimum"] != capacity: + raise RuntimeError("closure qualification did not complete") + counts = [60] if capacity == 60 else [60, 61, 64, 96, 120] + for count in counts: + c_result = command(label + "-c-api-" + str(count), [c_program, count], ROOT, env, 30) + if "C_API_UPVALUES " + str(count) + " PASS" not in c_result: + raise RuntimeError("C API qualification marker absent") + variant = {"name": label, "capacity": capacity, "closure": closure, + "cApiCounts": counts, "librarySha256": digest(library), + "headerSha256": digest(header)} + if args.engine_tests: + engine_env = {k: v for k, v in env.items() if not k.startswith("POKEPORT_")} + engine_env["LUA"] = interpreter.as_posix() + text = command(label + "-engine", ["bash", "scripts/test.sh", "--quick"], ROOT, engine_env, 900) + match = re.search(r"engine: (\d+)/(\d+) suites passed", text) + if not match or match[1] != match[2] or "ALL TIERS PASSED" not in text: + raise RuntimeError("engine quick tiers did not all pass") + variant["engineSuites"] = int(match[1]) + staged = artifacts / label + staged.mkdir() + for path in (interpreter, library, c_program): + shutil.copy2(path, staged / path.name) + shutil.copy2(build / "COPYRIGHT", staged / "COPYRIGHT-LuaJIT") + report["variants"].append(variant) + print("QUALIFIED", label, capacity, flush=True) + if git(source, "status", "--porcelain", "--untracked-files=normal") != original_status: + raise RuntimeError("input dependency checkout changed") + report["sourceUnchanged"] = True + report["passed"] = True + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report), flush=True) + + +if __name__ == "__main__": + main() From e2037946c79ec72708353b71eab102974293b70a Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:20:31 +0100 Subject: [PATCH 25/34] Qualify extended runtime inside packaged LOVE hosts --- .github/workflows/runtime-upvalues.yml | 24 ++++ tools/runtime-upvalue-check/README.md | 24 +++- tools/runtime-upvalue-check/love-conf.lua | 11 ++ tools/runtime-upvalue-check/love-main.lua | 28 ++++ tools/runtime-upvalue-check/love-thread.lua | 14 ++ tools/runtime-upvalue-check/love_abi.py | 147 ++++++++++++++++++++ tools/runtime-upvalue-check/qualify.py | 6 +- 7 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tools/runtime-upvalue-check/love-conf.lua create mode 100644 tools/runtime-upvalue-check/love-main.lua create mode 100644 tools/runtime-upvalue-check/love-thread.lua create mode 100644 tools/runtime-upvalue-check/love_abi.py diff --git a/.github/workflows/runtime-upvalues.yml b/.github/workflows/runtime-upvalues.yml index d8a860d1a..ce0ce4170 100644 --- a/.github/workflows/runtime-upvalues.yml +++ b/.github/workflows/runtime-upvalues.yml @@ -20,10 +20,16 @@ jobs: label: linux-x64 - runner: windows-2022 label: windows-x64 + love-artifact: '12514271124' + library: lua51.dll - runner: macos-15-intel label: macos-x64 + love-artifact: '12513712494' + library: libluajit.so - runner: macos-15 label: macos-arm64 + love-artifact: '12513712494' + library: libluajit.so runs-on: ${{ matrix.runner }} timeout-minutes: 25 steps: @@ -49,6 +55,22 @@ jobs: --luajit-source runtime-source --output "${{ runner.temp }}/runtime-capacity/candidate" --engine-tests + - name: Fetch pinned LOVE12 host artifact + if: matrix.love-artifact != '' + shell: bash + run: >- + curl --fail --location --retry 2 + --output "${{ runner.temp }}/love-runtime.zip" + "https://nightly.link/love2d/love/actions/artifacts/${{ matrix.love-artifact }}.zip" + - name: Qualify the actual LOVE12 host, thread VM and FFI + if: matrix.love-artifact != '' + shell: bash + run: >- + python engine/tools/runtime-upvalue-check/love_abi.py + --runtime-archive "${{ runner.temp }}/love-runtime.zip" + --qualification "${{ runner.temp }}/runtime-capacity/candidate/report.json" + --library "${{ runner.temp }}/runtime-capacity/candidate/artifacts/extended/${{ matrix.library }}" + --output "${{ runner.temp }}/runtime-love-abi/candidate" - uses: actions/upload-artifact@v7 if: always() with: @@ -57,5 +79,7 @@ jobs: ${{ runner.temp }}/runtime-capacity/candidate/report.json ${{ runner.temp }}/runtime-capacity/candidate/*.log ${{ runner.temp }}/runtime-capacity/candidate/artifacts/ + ${{ runner.temp }}/runtime-love-abi/candidate/report.json + ${{ runner.temp }}/runtime-love-abi/candidate/*.log if-no-files-found: error retention-days: 14 diff --git a/tools/runtime-upvalue-check/README.md b/tools/runtime-upvalue-check/README.md index b116e51dd..b0f347e90 100644 --- a/tools/runtime-upvalue-check/README.md +++ b/tools/runtime-upvalue-check/README.md @@ -43,8 +43,28 @@ The `Native runtime capacity` workflow schedules this same check on Linux x64, Windows x64, Intel macOS and arm64 macOS using explicit [GitHub runner labels](https://github.com/actions/runner-images#available-images). Both variants must pass the closure/C API checks and the engine's quick tiers; -the workflow does not publish a GitHub release. Loading the candidate library -inside the actual packaged LÖVE host remains a separate ABI/runtime gate. +the workflow does not publish a GitHub release. + +On Windows and both macOS architectures, `love_abi.py` additionally checks the +candidate inside a separate copy of the actual pinned LÖVE12 host. It verifies +the official artifact's SHA-256 before extraction, requires the passing engine +qualification and exact candidate library hash, then runs both stock and +extended closure probes, an FFI callback and a secondary LÖVE thread VM. Each +host uses a unique QA identity; graphics and audio are disabled. Only reports, +logs and licensed candidate components are uploaded, not an application or +game profile. macOS adjusts the copied framework's install name and ad-hoc +signs and verifies that copied app. This is a host ABI gate, not a GPU or +gameplay test, nor proof that a universal app has been packaged correctly. + +For an already qualified local macOS library: + +```sh +python3 tools/runtime-upvalue-check/love_abi.py \ + --runtime-archive /path/to/pinned/love-macos-artifact.zip \ + --qualification .local/qa/runtime-capacity-test/candidate/report.json \ + --library .local/qa/runtime-capacity-test/candidate/artifacts/extended/libluajit.so \ + --output .local/qa/runtime-host-test/candidate +``` ## Individual probes diff --git a/tools/runtime-upvalue-check/love-conf.lua b/tools/runtime-upvalue-check/love-conf.lua new file mode 100644 index 000000000..165007129 --- /dev/null +++ b/tools/runtime-upvalue-check/love-conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + local identity = assert(os.getenv('UPVALUE_TEST_IDENTITY')) + assert(identity:match('^oasc%-native%-runtime%-qa%-')) + t.identity = identity + t.version = '12.0' + t.window = false + t.modules.graphics = false + t.modules.audio = false + t.modules.joystick = false + t.modules.physics = false +end diff --git a/tools/runtime-upvalue-check/love-main.lua b/tools/runtime-upvalue-check/love-main.lua new file mode 100644 index 000000000..9c9c40dba --- /dev/null +++ b/tools/runtime-upvalue-check/love-main.lua @@ -0,0 +1,28 @@ +function love.load() + local minimum = assert(tonumber(os.getenv('UPVALUE_TEST_MINIMUM'))) + assert(love.getVersion() >= 12, 'pinned LOVE12 host required') + assert(love.filesystem.getIdentity() == os.getenv('UPVALUE_TEST_IDENTITY')) + local saved = arg + arg = {tostring(minimum)} + assert(love.filesystem.load('check.lua'))() + arg = saved + local ffi = require('ffi') + local callback = ffi.cast('double(*)(double)', function(value) return value + .5 end) + assert(callback(2.25) == 2.75, 'host FFI callback failed') + callback:free() + local channel = love.thread.newChannel() + local worker = love.thread.newThread('thread.lua') + worker:start(channel, minimum) + local result = channel:demand(10) + assert(result and result.ok and result.capacity == minimum, + worker:getError() or 'thread VM did not complete') + worker:wait() + assert(not worker:getError(), worker:getError()) + print('LOVE_RUNTIME_ABI_PASS ' .. minimum .. ' ' .. jit.arch) + love.event.quit(0) +end + +function love.errorhandler(why) + io.stderr:write(debug.traceback(tostring(why), 2) .. '\n') + return function() return 1 end +end diff --git a/tools/runtime-upvalue-check/love-thread.lua b/tools/runtime-upvalue-check/love-thread.lua new file mode 100644 index 000000000..acf447a2b --- /dev/null +++ b/tools/runtime-upvalue-check/love-thread.lua @@ -0,0 +1,14 @@ +require('love.thread') +local channel, capacity = ... +local declarations, terms = {}, {} +for index = 1, capacity do + declarations[#declarations + 1] = ('local v%d=%d'):format(index, index) + terms[#terms + 1] = 'v' .. index +end +declarations[#declarations + 1] = 'return function() return ' .. table.concat(terms, '+') .. ' end' +local factory = assert(loadstring(table.concat(declarations, '\n'))) +local fn = factory() +assert(fn() == capacity * (capacity + 1) / 2) +collectgarbage('collect') +assert(fn() == capacity * (capacity + 1) / 2) +channel:push({ok = true, capacity = capacity}) diff --git a/tools/runtime-upvalue-check/love_abi.py b/tools/runtime-upvalue-check/love_abi.py new file mode 100644 index 000000000..ac3df5808 --- /dev/null +++ b/tools/runtime-upvalue-check/love_abi.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Qualify a candidate library inside a copied, hash-pinned LOVE12 host.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import shutil +import subprocess +import sys +import uuid +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +PIN = 'a4c064539ea931274d407e1249b4bb575424a0f4' +RUNTIMES = { + 'Windows': ('1f4c6997bd93502f986f5187d877da84247617eede89d8140bcbf570bd07ea22', 'love-12.0-win64.zip'), + 'Darwin': ('f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8', 'love-macos.zip'), +} + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def safe_archive(archive): + for name in archive.namelist(): + path = PurePosixPath(name.replace('\\', '/')) + if path.is_absolute() or '..' in path.parts or (path.parts and ':' in path.parts[0]): + raise ValueError('unsafe runtime archive path') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--runtime-archive', required=True, type=Path) + parser.add_argument('--qualification', required=True, type=Path) + parser.add_argument('--library', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + host = platform.system() + if host not in RUNTIMES: + parser.error('this copied-host gate supports Windows and macOS') + archive, qualification, library, output = [path.resolve() for path in + (args.runtime_archive, args.qualification, args.library, args.output)] + expected, inner_name = RUNTIMES[host] + if digest(archive) != expected: + parser.error('runtime archive differs from the pinned LOVE12 artifact') + qualified = json.loads(qualification.read_text()) + extended = next((row for row in qualified.get('variants', []) if row['name'] == 'extended'), {}) + if not (qualified.get('passed') and qualified.get('luaJitSource') == PIN + and qualified.get('platform') == host and extended.get('capacity') == 120 + and extended.get('engineSuites', 0) > 0 and extended.get('librarySha256') == digest(library)): + parser.error('a passing same-platform engine qualification for this library is required') + if output.exists(): + parser.error('output already exists; use a new disposable candidate directory') + run = subprocess.run + profile = None + if os.name != 'nt' and not os.getenv('GITHUB_ACTIONS') and (ROOT / 'tools/qa_storage.py').is_file(): + sys.path.insert(0, str(ROOT / 'tools')) + from qa_storage import qa_source, qa_copy, qa_cache, qa_profile, qa_run + for source in (ROOT, HERE, archive, qualification, library, Path(sys.executable)): + qa_source(source) + qa_cache(output.parent) + qa_copy(output) + run, profile = qa_run, qa_profile + output.mkdir(parents=True, exist_ok=False) + project = output / 'probe' + project.mkdir() + for source, target in (('love-conf.lua', 'conf.lua'), ('love-main.lua', 'main.lua'), + ('love-thread.lua', 'thread.lua'), ('check.lua', 'check.lua')): + shutil.copy2(HERE / source, project / target) + (project / 'src/link').mkdir(parents=True) + shutil.copy2(ROOT / 'src/link/Json.lua', project / 'src/link/Json.lua') + with zipfile.ZipFile(archive) as packed: + safe_archive(packed) + if packed.namelist().count(inner_name) != 1: + raise ValueError('pinned inner runtime is absent or ambiguous') + inner = output / inner_name + inner.write_bytes(packed.read(inner_name)) + unpacked = output / 'runtime' + unpacked.mkdir() + with zipfile.ZipFile(inner) as packed: + safe_archive(packed) + if host == 'Windows': + packed.extractall(unpacked) + if host == 'Darwin': + run(['unzip', '-q', str(inner), '-d', str(unpacked)], check=True) + apps = list(unpacked.rglob('love.app')) + if len(apps) != 1: + raise ValueError('expected one copied LOVE app') + app = apps[0] + executable = app / 'Contents/MacOS/love' + target = app / 'Contents/Frameworks/Lua.framework/Versions/A/Lua' + else: + executables = list(unpacked.rglob('love.exe')) + if len(executables) != 1: + raise ValueError('expected one unfused LOVE executable') + executable = executables[0] + target = executable.parent / 'lua51.dll' + # The console wrapper forwards stdout/stderr and the child exit status. + executable = executable.with_name('lovec.exe') + if not executable.is_file(): + raise ValueError('pinned Windows console host is absent') + original = digest(target) + results = [] + for label, minimum in (('stock', 60), ('extended', 120)): + if label == 'extended': + shutil.copyfile(library, target) + if host == 'Darwin': + run(['install_name_tool', '-id', '@rpath/Lua.framework/Versions/A/Lua', str(target)], check=True) + run(['codesign', '--force', '--deep', '--sign', '-', str(app)], check=True, + capture_output=True, text=True) + run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) + identity = 'oasc-native-runtime-qa-' + uuid.uuid4().hex + if profile: + profile(Path.home() / 'Library/Application Support/LOVE' / identity) + env = {key: value for key, value in os.environ.items() if not key.startswith('POKEPORT_')} + env.update(UPVALUE_TEST_IDENTITY=identity, UPVALUE_TEST_MINIMUM=str(minimum)) + result = run([str(executable), str(project)], env=env, cwd=project, + capture_output=True, text=True, timeout=60) + (output / (label + '.log')).write_text(result.stdout + result.stderr, encoding='utf-8') + if result.returncode: + print((result.stdout + result.stderr)[-6000:], flush=True) + result.check_returncode() + if 'LOVE_RUNTIME_ABI_PASS ' + str(minimum) + ' ' not in result.stdout: + raise RuntimeError('LOVE host/thread/FFI qualification marker missing') + closure = json.loads(next(line.removeprefix('RUNTIME_UPVALUES ') for line in result.stdout.splitlines() + if line.startswith('RUNTIME_UPVALUES '))) + if not closure.get('passed'): + raise RuntimeError('host closure probe did not complete') + results.append({'variant': label, 'capacity': minimum, 'checks': closure['checks'], + 'librarySha256': digest(target), 'identity': identity}) + print('LOVE_ABI_QUALIFIED', label, minimum, flush=True) + report = {'passed': True, 'experimental': True, 'platform': host, 'machine': platform.machine(), + 'baseLoveCommit': '937eb3b21f973607ab080aa26b7ef2a6b3b2b663', + 'originalArchiveSha256': expected, 'originalLibrarySha256': original, + 'inputCandidateLibrarySha256': digest(library), 'qualificationSha256': digest(qualification), + 'scope': 'headless LOVE12 boot, closure/JIT/GC/bytecode, FFI callback and thread VM; not GPU/gameplay', + 'variants': results} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(json.dumps(report), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/runtime-upvalue-check/qualify.py b/tools/runtime-upvalue-check/qualify.py index a02e0e66f..518bd0680 100644 --- a/tools/runtime-upvalue-check/qualify.py +++ b/tools/runtime-upvalue-check/qualify.py @@ -53,8 +53,10 @@ def main(): qa_copy(output) run = qa_run patch = HERE / "luajit-upvalues.patch" - # Check the pinned Git blobs, independently of Windows checkout CRLF rules. - subprocess.run(["git", "-C", str(source), "apply", "--check", "--cached", str(patch)], check=True) + # Check pinned Git blobs and normalize the patch itself: Windows checkout + # conversion otherwise turns the patch's final empty context line into CRLF. + subprocess.run(["git", "-C", str(source), "apply", "--check", "--cached", "-"], + input=patch.read_bytes().replace(b"\r\n", b"\n"), check=True) archive = subprocess.check_output(["git", "-C", str(source), "archive", PIN]) output.mkdir(parents=True, exist_ok=False) artifacts = output / "artifacts" From fe4d349ed9b07483ac92a0dbb426db246390aeda Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:23:39 +0100 Subject: [PATCH 26/34] Keep MSVC linker ahead of Git Bash utilities --- tools/runtime-upvalue-check/qualify.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tools/runtime-upvalue-check/qualify.py b/tools/runtime-upvalue-check/qualify.py index 518bd0680..6b60688a6 100644 --- a/tools/runtime-upvalue-check/qualify.py +++ b/tools/runtime-upvalue-check/qualify.py @@ -92,8 +92,19 @@ def command(label, argv, cwd, env=None, timeout=180): if platform.system() == "Darwin": env.setdefault("MACOSX_DEPLOYMENT_TARGET", "11.0") if os.name == "nt": - command(label + "-build", ["cmd", "/d", "/c", "msvcbuild.bat", "lua52compat"], - build / "src", env, timeout=300) + # Git Bash prepends its Unix utilities to PATH, including a `link` + # command unrelated to MSVC. The vendor batch file invokes `link` + # by name, so keep the selected compiler's tools first in that child. + compiler = shutil.which("cl.exe", path=env.get("PATH")) + if not compiler or not Path(compiler).with_name("link.exe").is_file(): + raise RuntimeError("an MSVC developer environment is required") + env["PATH"] = str(Path(compiler).parent) + os.pathsep + env["PATH"] + build_log = command(label + "-build", ["cmd", "/d", "/c", "msvcbuild.bat", "lua52compat"], + build / "src", env, timeout=300) + # msvcbuild.bat can reach its failure label with exit status zero. + if "=== Successfully built LuaJIT for Windows/x64 ===" not in build_log: + print(build_log[-6000:], flush=True) + raise RuntimeError("MSVC runtime build did not complete; see build log") interpreter = build / "src/luajit.exe" library = build / "src/lua51.dll" c_program = build / "src/c-api-upvalues.exe" From 32be92173d9808c8c2b8697dfff89406a52a938a Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:27:07 +0100 Subject: [PATCH 27/34] Select Git for Windows Bash for engine qualification --- tools/runtime-upvalue-check/qualify.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/runtime-upvalue-check/qualify.py b/tools/runtime-upvalue-check/qualify.py index 6b60688a6..19caaf6b1 100644 --- a/tools/runtime-upvalue-check/qualify.py +++ b/tools/runtime-upvalue-check/qualify.py @@ -134,7 +134,14 @@ def command(label, argv, cwd, env=None, timeout=180): if args.engine_tests: engine_env = {k: v for k, v in env.items() if not k.startswith("POKEPORT_")} engine_env["LUA"] = interpreter.as_posix() - text = command(label + "-engine", ["bash", "scripts/test.sh", "--quick"], ROOT, engine_env, 900) + shell = "bash" + if os.name == "nt": + # System32/bash.exe is a WSL launcher on Windows runners. + # Use the Bash belonging to the already selected Git install. + shell = Path(git(source, "--exec-path")).parents[2] / "bin/bash.exe" + if not shell.is_file(): + raise RuntimeError("Git for Windows Bash is required for engine tiers") + text = command(label + "-engine", [shell, "scripts/test.sh", "--quick"], ROOT, engine_env, 900) match = re.search(r"engine: (\d+)/(\d+) suites passed", text) if not match or match[1] != match[2] or "ALL TIERS PASSED" not in text: raise RuntimeError("engine quick tiers did not all pass") From 976b4dd63505071a23558ac7641adc79b0cd53eb Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:37:09 +0100 Subject: [PATCH 28/34] Make shared engine fixtures portable to Windows checkouts --- tests/engine/quit_thread_shutdown.lua | 2 +- tests/engine/title_zone_seams.lua | 2 +- tools/runtime-upvalue-check/README.md | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..f9df54094 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -164,7 +164,7 @@ local function source(path) check(f ~= nil, path .. " is readable") local text = f and f:read("*a") or "" if f then f:close() end - return text + return (text:gsub("\r\n", "\n")) end local chipSrc = source("src/core/chip_worker.lua") diff --git a/tests/engine/title_zone_seams.lua b/tests/engine/title_zone_seams.lua index da454142a..d27d15812 100644 --- a/tests/engine/title_zone_seams.lua +++ b/tests/engine/title_zone_seams.lua @@ -19,7 +19,7 @@ local scissorClamped, captured do local f = io.open("src/render/Renderer.lua", "rb") check(f ~= nil, "Renderer source is readable") - local src = f and f:read("*a") or "" + local src = (f and f:read("*a") or ""):gsub("\r\n", "\n") if f then f:close() end local body = src:match("\nlocal function scissorClamped.-\nend\n") check(body ~= nil, "scissorClamped is still a single local function") diff --git a/tools/runtime-upvalue-check/README.md b/tools/runtime-upvalue-check/README.md index b0f347e90..a6c2d4b3c 100644 --- a/tools/runtime-upvalue-check/README.md +++ b/tools/runtime-upvalue-check/README.md @@ -39,6 +39,12 @@ the caller explicitly supplies one. The output includes per-step logs, a source/tool/library hash report and separate licensed stock/extended artifacts. These are qualification artifacts, not a replacement installation or a release. +On Windows the builder explicitly selects MSVC's linker and Git for Windows +Bash, avoiding the unrelated Git Unix `link` utility and System32's WSL +launcher. Shared fixture source paths accept Windows separators, and tests +that inspect source/text goldens normalize checkout CRLF only. Their native +behavior checks and reviewed fingerprint values are unchanged. + The `Native runtime capacity` workflow schedules this same check on Linux x64, Windows x64, Intel macOS and arm64 macOS using explicit [GitHub runner labels](https://github.com/actions/runner-images#available-images). From 171a7c8f5a12f04818ac760a13772d1753fe3083 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:37:09 +0100 Subject: [PATCH 29/34] Record full-renderer native room and map transition checks --- docs/native-gen2-camera-movement.md | 19 ++ .../johto-native-runtime-upvalues.json | 220 +++++++++++++++++- 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/docs/native-gen2-camera-movement.md b/docs/native-gen2-camera-movement.md index 4abe2ed48..8512aa59a 100644 --- a/docs/native-gen2-camera-movement.md +++ b/docs/native-gen2-camera-movement.md @@ -103,3 +103,22 @@ outside that early-story approach. Full story, all maps, forced-terrain GPU, Windows and complete avatar/model/occlusion parity remain open. The generated Stadium extension is still uninstalled. This checkpoint does not change the existing release assets. + +## Transitions with the experimental runtime + +The separate extended-upvalue arm64 LÖVE host now repeats the complete normal +transition matrix with `rendererInstalled == true` required. OpenGL at source +`6f0f8d2c` and Metal at `6f926f9c` each pass 213 assertions, 12 normal door/map +transitions, four native menu ownership checks and four fresh OS-window +proofs. Both selected cameras retain native ownership and return correctly. +All 27,329 supplied files remain unchanged. The +[runtime receipt](qa/native-foundation/johto-native-runtime-upvalues.json) +binds these results to the separately qualified Lua library. + +All eight room proofs were inspected and show upright geometry and NPC +figures. Black-and-white objects remain visible in some First Person frames; +complete avatar/model/occlusion and performance parity remain unaccepted. +Initial QA positions and skipped introduction still preclude all-story +acceptance. These tests use the experimental host, not the public release's +stock 60-upvalue runtime. Windows and universal packaging remain separate +gates. diff --git a/docs/qa/native-foundation/johto-native-runtime-upvalues.json b/docs/qa/native-foundation/johto-native-runtime-upvalues.json index 26ccaf62d..8779233d1 100644 --- a/docs/qa/native-foundation/johto-native-runtime-upvalues.json +++ b/docs/qa/native-foundation/johto-native-runtime-upvalues.json @@ -186,5 +186,223 @@ "No complete Pokemon model, all effects, battles, movement or all-story parity claim", "Stock release unchanged; installing the source PR does not replace its Lua runtime", "No private mod source/assets, generated renderer source, ROM data, screenshots or saves published" - ] + ], + "reproducibleLocalBuild": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "engineSource": "224fa467ed84f4348faeeb3958dca25aa3b850b6", + "platform": "Darwin", + "machine": "arm64", + "toolsSha256": { + "README.md": "a6610cd64eeb530375266ea2513fde29d4addd323b2383d8157fea95b2d13ca8", + "c_api.c": "b473dc627d45430bf4a48640775e73126371e4d5efb263f6d75e71b49743bfec", + "check.lua": "3f39d04d9b9fc9f93ad920a219b20cf1e27d62e5c200f08c29073ee7bde294a7", + "luajit-upvalues.patch": "022b346f64aa620a04c0ccfee616088b747e340ac3ac3485680a7eb30e9652dd", + "qualify.py": "4d04b556e7b32d0689ef4662c273ae211dd4a9e9f6515d320294a79f78c5d7ed" + }, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "upvalues": 1, + "compiles": true, + "interpreter": true, + "jit": true + }, + { + "upvalues": 8, + "compiles": true, + "interpreter": true, + "jit": true + }, + { + "upvalues": 32, + "compiles": true, + "interpreter": true, + "jit": true + }, + { + "upvalues": 60, + "compiles": true, + "interpreter": true, + "jit": true + }, + { + "upvalues": 61, + "compiles": false + }, + { + "upvalues": 64, + "compiles": false + }, + { + "upvalues": 96, + "compiles": false + }, + { + "upvalues": 120, + "compiles": false + } + ], + "minimum": 60, + "passed": true, + "checks": 160250 + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "7e937d1095a1298ad831265edde2c583ad622d22a3bfedaddd5142820b0b1176", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 343 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "passed": true, + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 1 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 8 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 32 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 60 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 61 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 64 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 96 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 120 + } + ], + "minimum": 120, + "checks": 320964 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "5c51a7937a97b483c903f415161575ea0a074382f7923b5dd774ba5dec9fd023", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 343 + } + ], + "sourceUnchanged": true, + "passed": true, + "reportSha256": "64f0d1937a4cf4f0c5de3baabc2a0387f535221b06484851ed755f5c1c6f893e" + }, + "copiedHostAbi": [ + { + "passed": true, + "experimental": true, + "platform": "Darwin", + "machine": "arm64", + "baseLoveCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "originalArchiveSha256": "f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8", + "originalLibrarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc", + "inputCandidateLibrarySha256": "5c51a7937a97b483c903f415161575ea0a074382f7923b5dd774ba5dec9fd023", + "qualificationSha256": "64f0d1937a4cf4f0c5de3baabc2a0387f535221b06484851ed755f5c1c6f893e", + "scope": "headless LOVE12 boot, closure/JIT/GC/bytecode, FFI callback and thread VM; not GPU/gameplay", + "variants": [ + { + "variant": "stock", + "capacity": 60, + "checks": 160250, + "librarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc" + }, + { + "variant": "extended", + "capacity": 120, + "checks": 320964, + "librarySha256": "d30fa0c327754e1de28a92ba4e65fe3f60427dfefc8c591a58398a32f2d83cdd" + } + ], + "reportSha256": "bad16a63138d43916d614f9fae021e632429a84faa4f4a982870b7e464e69dfb" + } + ], + "hostAbiLimits": "Local arm64 host gate passes. Windows and Intel macOS CI qualification is running; neither has final acceptance yet.", + "extendedRuntimeTransitions": { + "scope": "Normal Cherrygrove Mart/Center doors, native START ownership and Cherrygrove/Route 29 connections using continuous controls with the generated extension installed; initial positions selected and fresh-save introduction skipped", + "runs": [ + { + "backend": "opengl", + "engineSource": "6f0f8d2c794a5b5d036fe0d614a9cbeba3a231ee", + "checks": 213, + "normalTransitions": 12, + "menuOwnershipChecks": 4, + "ownedWindowProofs": 4, + "cameras": [ + "first", + "third" + ], + "modelRendererInstalled": true, + "unchangedModFiles": 27329, + "runtimeLibrarySha256": "d30fa0c327754e1de28a92ba4e65fe3f60427dfefc8c591a58398a32f2d83cdd", + "resultSha256": "2386287dcbe52786cfe3960b089107d454c3d4734b192ef9301e6268692dc765", + "verificationSha256": "78d2c2be2bfec3675d6041e2b9998ad91d46422013e1c631cae177329eb99142" + }, + { + "backend": "metal", + "engineSource": "6f926f9c0096d89e45dfedfc0dabcde181e40b9d", + "checks": 213, + "normalTransitions": 12, + "menuOwnershipChecks": 4, + "ownedWindowProofs": 4, + "cameras": [ + "first", + "third" + ], + "modelRendererInstalled": true, + "unchangedModFiles": 27329, + "runtimeLibrarySha256": "d30fa0c327754e1de28a92ba4e65fe3f60427dfefc8c591a58398a32f2d83cdd", + "resultSha256": "0a1b51525fbfaa3eb3048b5181516d9c81943bfbda582f7635fad529fd129d06", + "verificationSha256": "a626d81b0631c1b6e9420e8cf522e01d7a42a7153e166d9d2211427ba9ef2322" + } + ], + "visualInspection": "All eight fresh window proofs inspected: visible upright rooms and NPC figures; black-and-white objects remain visible in some First Person frames and are unresolved. No complete avatar/model/occlusion or performance acceptance.", + "failedSetup": "The first new starter referenced an already reclaimed historical QA runtime and failed before any application launch. The passing runs use the copied, locally qualified LOVE ABI app and pin the entire runtime app as an input." + } } From 680f39762ef6c22502ff5e29fdbcd83b4e631d82 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:41:07 +0100 Subject: [PATCH 30/34] Record hosted runtime checks on Linux and both Mac architectures --- .../johto-native-runtime-upvalues.json | 526 +++++++++++++++++- 1 file changed, 525 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/johto-native-runtime-upvalues.json b/docs/qa/native-foundation/johto-native-runtime-upvalues.json index 8779233d1..8d25b6a99 100644 --- a/docs/qa/native-foundation/johto-native-runtime-upvalues.json +++ b/docs/qa/native-foundation/johto-native-runtime-upvalues.json @@ -363,7 +363,7 @@ "reportSha256": "bad16a63138d43916d614f9fae021e632429a84faa4f4a982870b7e464e69dfb" } ], - "hostAbiLimits": "Local arm64 host gate passes. Windows and Intel macOS CI qualification is running; neither has final acceptance yet.", + "hostAbiLimits": "Local arm64 and hosted Intel/arm64 Mac host gates pass; Linux capacity/engine gate passes. Windows stock fixture correction is awaiting native requalification. Universal packaging and Windows GPU acceptance remain open.", "extendedRuntimeTransitions": { "scope": "Normal Cherrygrove Mart/Center doors, native START ownership and Cherrygrove/Route 29 connections using continuous controls with the generated extension installed; initial positions selected and fresh-save introduction skipped", "runs": [ @@ -404,5 +404,529 @@ ], "visualInspection": "All eight fresh window proofs inspected: visible upright rooms and NPC figures; black-and-white objects remain visible in some First Person frames and are unresolved. No complete avatar/model/occlusion or performance acceptance.", "failedSetup": "The first new starter referenced an already reclaimed historical QA runtime and failed before any application launch. The passing runs use the copied, locally qualified LOVE ABI app and pin the entire runtime app as an input." + }, + "hostedQualification": { + "runId": 40207002780, + "url": "https://github.com/Roxas2712/Gen2Recomped/actions/runs/40207002780", + "scope": "Three passed platform jobs; Windows stock engine fixtures failed on path/CRLF assumptions and the correction is awaiting the next native run. Not a four-platform pass or a packaged release.", + "results": [ + { + "artifactId": 12604786530, + "artifactName": "runtime-capacity-macos-x64", + "artifactSha256": "67a4f94490d7a5284b1d1e9aff893cdd026067ae13b4c8e23bb40be78ce656bd", + "capacityReport": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "engineSource": "94f63723288c6246802a2541df1f22551647451e", + "platform": "Darwin", + "machine": "x86_64", + "toolsSha256": { + "README.md": "d0725bc4c5a442e51db28324ab0ea90a222985aef54ccf3d1bd9de6e42142aa9", + "c_api.c": "b473dc627d45430bf4a48640775e73126371e4d5efb263f6d75e71b49743bfec", + "check.lua": "3f39d04d9b9fc9f93ad920a219b20cf1e27d62e5c200f08c29073ee7bde294a7", + "love-conf.lua": "fffc2ebea85eed77cb35c7b55910c25e755c9779258ed53766f9f027306ff2b0", + "love-main.lua": "47cd7491da83c8478ba290c6698ca3af6b8dfd9b50005b12234fa324c30b3a29", + "love-thread.lua": "db3b2eb5d7e3f7bcc1d1644c6ebdf91adbbde8992351d0e830e2e728752cd297", + "love_abi.py": "6af1a60c1e4f3dce3ba1d85cbae9b5e300417402216c0bc6c012ec3c2226eb63", + "luajit-upvalues.patch": "022b346f64aa620a04c0ccfee616088b747e340ac3ac3485680a7eb30e9652dd", + "qualify.py": "8c644b06e05ae891e736412c9659bbff3b5516a4842298709bccb9a037efd014" + }, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "cases": [ + { + "interpreter": true, + "jit": true, + "upvalues": 1, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 8, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 32, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 60, + "compiles": true + }, + { + "upvalues": 61, + "compiles": false + }, + { + "upvalues": 64, + "compiles": false + }, + { + "upvalues": 96, + "compiles": false + }, + { + "upvalues": 120, + "compiles": false + } + ], + "minimum": 60, + "passed": true, + "checks": 160250, + "runtime": "LuaJIT 2.1.1792588164" + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "22edeea3bba4b278a002784c0a0ff699ba8be7bb1fa2f5c2d5d05c0cdec1ed73", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 351 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "jit": true, + "upvalues": 1, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 8, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 32, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 60, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 61, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 64, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 96, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 120, + "compiles": true, + "interpreter": true + } + ], + "minimum": 120, + "passed": true, + "checks": 320964 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "366721589883641d92bb76a5e4c4978223ea3867de8930a8efd38d9fa8ccbdb2", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 351 + } + ], + "sourceUnchanged": true, + "passed": true + }, + "hostAbiReport": { + "passed": true, + "experimental": true, + "platform": "Darwin", + "machine": "x86_64", + "baseLoveCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "originalArchiveSha256": "f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8", + "originalLibrarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc", + "inputCandidateLibrarySha256": "366721589883641d92bb76a5e4c4978223ea3867de8930a8efd38d9fa8ccbdb2", + "qualificationSha256": "d417403956924c2679054a5474406dad60421f3aeeb19e1f890ae04dab4bd840", + "scope": "headless LOVE12 boot, closure/JIT/GC/bytecode, FFI callback and thread VM; not GPU/gameplay", + "variants": [ + { + "variant": "stock", + "capacity": 60, + "checks": 160250, + "librarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc" + }, + { + "variant": "extended", + "capacity": 120, + "checks": 320964, + "librarySha256": "3f2ef3c5de765575c381c4e554bac5fcdb850660206d11c47d02d64cdbdef802" + } + ] + } + }, + { + "artifactId": 12604700991, + "artifactName": "runtime-capacity-linux-x64", + "artifactSha256": "063448c27f9c7a09d9f2b8e71832183edb71affa422acd82508231c74508e264", + "capacityReport": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "engineSource": "94f63723288c6246802a2541df1f22551647451e", + "platform": "Linux", + "machine": "x86_64", + "toolsSha256": { + "README.md": "d0725bc4c5a442e51db28324ab0ea90a222985aef54ccf3d1bd9de6e42142aa9", + "c_api.c": "b473dc627d45430bf4a48640775e73126371e4d5efb263f6d75e71b49743bfec", + "check.lua": "3f39d04d9b9fc9f93ad920a219b20cf1e27d62e5c200f08c29073ee7bde294a7", + "love-conf.lua": "fffc2ebea85eed77cb35c7b55910c25e755c9779258ed53766f9f027306ff2b0", + "love-main.lua": "47cd7491da83c8478ba290c6698ca3af6b8dfd9b50005b12234fa324c30b3a29", + "love-thread.lua": "db3b2eb5d7e3f7bcc1d1644c6ebdf91adbbde8992351d0e830e2e728752cd297", + "love_abi.py": "6af1a60c1e4f3dce3ba1d85cbae9b5e300417402216c0bc6c012ec3c2226eb63", + "luajit-upvalues.patch": "022b346f64aa620a04c0ccfee616088b747e340ac3ac3485680a7eb30e9652dd", + "qualify.py": "8c644b06e05ae891e736412c9659bbff3b5516a4842298709bccb9a037efd014" + }, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "passed": true, + "minimum": 60, + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "jit": true, + "upvalues": 1, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 8, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 32, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 60, + "compiles": true, + "interpreter": true + }, + { + "compiles": false, + "upvalues": 61 + }, + { + "compiles": false, + "upvalues": 64 + }, + { + "compiles": false, + "upvalues": 96 + }, + { + "compiles": false, + "upvalues": 120 + } + ], + "checks": 160250 + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "8eaeb5d14d578e893d0df1685cd857d58a04307c285341c708416354583e5435", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 351 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 1 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 8 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 32 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 60 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 61 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 64 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 96 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 120 + } + ], + "minimum": 120, + "passed": true, + "checks": 320964 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "51e81b943f271639004cd21d8f3f61f52b61c01281ab813d4facfd05f41ee9be", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 351 + } + ], + "sourceUnchanged": true, + "passed": true + } + }, + { + "artifactId": 12604061670, + "artifactName": "runtime-capacity-macos-arm64", + "artifactSha256": "94e03c94f2e16008e8ed59604249d32f8b6f4f874e2ca95d113548a0e7496b40", + "capacityReport": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "engineSource": "94f63723288c6246802a2541df1f22551647451e", + "platform": "Darwin", + "machine": "arm64", + "toolsSha256": { + "README.md": "d0725bc4c5a442e51db28324ab0ea90a222985aef54ccf3d1bd9de6e42142aa9", + "c_api.c": "b473dc627d45430bf4a48640775e73126371e4d5efb263f6d75e71b49743bfec", + "check.lua": "3f39d04d9b9fc9f93ad920a219b20cf1e27d62e5c200f08c29073ee7bde294a7", + "love-conf.lua": "fffc2ebea85eed77cb35c7b55910c25e755c9779258ed53766f9f027306ff2b0", + "love-main.lua": "47cd7491da83c8478ba290c6698ca3af6b8dfd9b50005b12234fa324c30b3a29", + "love-thread.lua": "db3b2eb5d7e3f7bcc1d1644c6ebdf91adbbde8992351d0e830e2e728752cd297", + "love_abi.py": "6af1a60c1e4f3dce3ba1d85cbae9b5e300417402216c0bc6c012ec3c2226eb63", + "luajit-upvalues.patch": "022b346f64aa620a04c0ccfee616088b747e340ac3ac3485680a7eb30e9652dd", + "qualify.py": "8c644b06e05ae891e736412c9659bbff3b5516a4842298709bccb9a037efd014" + }, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 1 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 8 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 32 + }, + { + "compiles": true, + "jit": true, + "interpreter": true, + "upvalues": 60 + }, + { + "compiles": false, + "upvalues": 61 + }, + { + "compiles": false, + "upvalues": 64 + }, + { + "compiles": false, + "upvalues": 96 + }, + { + "compiles": false, + "upvalues": 120 + } + ], + "minimum": 60, + "passed": true, + "checks": 160250 + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "770c2e5c855bba6d138c24441b5df4780e892a58193ac3c824d89f2af2b3c9ed", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 351 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "passed": true, + "minimum": 120, + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "upvalues": 1, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 8, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 32, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 60, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 61, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 64, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 96, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 120, + "compiles": true, + "jit": true, + "interpreter": true + } + ], + "checks": 320964 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "b2f5783405bd6430e0a8f6839b513456cc02ff51aa73065d253c5aaf6300fc5e", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 351 + } + ], + "sourceUnchanged": true, + "passed": true + }, + "hostAbiReport": { + "passed": true, + "experimental": true, + "platform": "Darwin", + "machine": "arm64", + "baseLoveCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "originalArchiveSha256": "f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8", + "originalLibrarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc", + "inputCandidateLibrarySha256": "b2f5783405bd6430e0a8f6839b513456cc02ff51aa73065d253c5aaf6300fc5e", + "qualificationSha256": "12ab1cf7f0c3f5c33805c436ece0acd3a14a42217a60edf8895685b12da5a757", + "scope": "headless LOVE12 boot, closure/JIT/GC/bytecode, FFI callback and thread VM; not GPU/gameplay", + "variants": [ + { + "variant": "stock", + "capacity": 60, + "checks": 160250, + "librarySha256": "d99eed38c9752775028ed88b7b23285cd3b1abea4154a41b06fb2ace9c0a3edc" + }, + { + "variant": "extended", + "capacity": 120, + "checks": 320964, + "librarySha256": "8f14e8eaaafdc7e49583a0fb1bc65020148492441d0edc9be3d77a3ce5641f89" + } + ] + } + } + ] } } From 791b6a4a9d07a0ebbe93fd70ee28db533af0373a Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 16:57:11 +0100 Subject: [PATCH 31/34] Stage source-bound qualified runtimes for Windows and universal Mac --- .github/workflows/runtime-upvalues.yml | 45 +++++ tests/test_runtime_component.py | 166 +++++++++++++++++ tools/runtime-upvalue-check/README.md | 35 ++++ tools/runtime-upvalue-check/component.py | 221 +++++++++++++++++++++++ tools/runtime-upvalue-check/qualify.py | 8 + 5 files changed, 475 insertions(+) create mode 100644 tests/test_runtime_component.py create mode 100644 tools/runtime-upvalue-check/component.py diff --git a/.github/workflows/runtime-upvalues.yml b/.github/workflows/runtime-upvalues.yml index ce0ce4170..f2c3c5b09 100644 --- a/.github/workflows/runtime-upvalues.yml +++ b/.github/workflows/runtime-upvalues.yml @@ -4,6 +4,10 @@ on: paths: - '.github/workflows/runtime-upvalues.yml' - 'tools/runtime-upvalue-check/**' + - 'tools/build_native_preview.py' + - 'tools/package_native_release.py' + - 'tests/test_runtime_component.py' + - 'tests/test_native_preview.py' workflow_dispatch: permissions: contents: read @@ -36,6 +40,7 @@ jobs: - uses: actions/checkout@v7 with: path: engine + ref: ${{ github.sha }} - uses: actions/checkout@v7 with: repository: LuaJIT/LuaJIT @@ -83,3 +88,43 @@ jobs: ${{ runner.temp }}/runtime-love-abi/candidate/*.log if-no-files-found: error retention-days: 14 + components: + needs: qualify + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + path: engine + ref: ${{ github.sha }} + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/download-artifact@v8 + with: + pattern: runtime-capacity-* + path: runtime-candidates + - name: Verify component packaging guards + shell: bash + run: python -m unittest discover -s engine/tests -p test_runtime_component.py -v + - name: Combine the two qualified Mac slices + shell: bash + run: >- + python engine/tools/runtime-upvalue-check/component.py + --platform macos + --candidate runtime-candidates/runtime-capacity-macos-x64 + --candidate runtime-candidates/runtime-capacity-macos-arm64 + --output "${{ runner.temp }}/qualified-runtime/candidate/macos" + - name: Stage the qualified Windows component + shell: bash + run: >- + python engine/tools/runtime-upvalue-check/component.py + --platform windows + --candidate runtime-candidates/runtime-capacity-windows-x64 + --output "${{ runner.temp }}/qualified-runtime/candidate/windows" + - uses: actions/upload-artifact@v7 + with: + name: qualified-desktop-runtime + path: ${{ runner.temp }}/qualified-runtime/candidate/ + if-no-files-found: error + retention-days: 14 diff --git a/tests/test_runtime_component.py b/tests/test_runtime_component.py new file mode 100644 index 000000000..d2161c2e0 --- /dev/null +++ b/tests/test_runtime_component.py @@ -0,0 +1,166 @@ +import copy +import importlib.util +import json +from pathlib import Path +import struct +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("component", Path(__file__).parents[1] / "tools/runtime-upvalue-check/component.py") +C = importlib.util.module_from_spec(spec) +spec.loader.exec_module(C) + + +def thin(arch): + return struct.pack("II", 0xCAFEBABE, 2) + + struct.pack(">IIIII", 0x01000007, 0, offset, len(first), 0) + + struct.pack(">IIIII", 0x0100000C, 0, offset + len(first), len(second), 0) + + first + second) + + +class Components(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.tree = {name: "a" * 40 for name in C.ENGINE_PATHS} + + def pair(self, platform="macos", arch="arm64"): + library = thin(arch) + if platform == "windows": + data = bytearray(96) + data[:2] = b"MZ" + struct.pack_into(" len(data) or data[offset:offset + 4] != b"PE\0\0": + raise ValueError("invalid PE header") + if struct.unpack_from("" + arch = CPU.get(struct.unpack_from(endian + "I", data, 4)[0]) + if not arch: + raise ValueError("unsupported Mac architecture") + return {arch: data} + formats = {b"\xca\xfe\xba\xbe": (">", False), b"\xbe\xba\xfe\xca": ("<", False), + b"\xca\xfe\xba\xbf": (">", True), b"\xbf\xba\xfe\xca": ("<", True)} + if data[:4] not in formats or len(data) < 8: + raise ValueError("candidate is not a Mach-O library") + endian, wide = formats[data[:4]] + count = struct.unpack_from(endian + "I", data, 4)[0] + size = 32 if wide else 20 + if count != 2 or 8 + count * size > len(data): + raise ValueError("universal Mac runtime must have exactly two slices") + result = {} + for index in range(count): + at = 8 + index * size + cpu = struct.unpack_from(endian + "I", data, at)[0] + offset, length = struct.unpack_from(endian + ("QQ" if wide else "II"), data, at + 8) + if offset < 8 + count * size or not length or offset + length > len(data): + raise ValueError("invalid universal library slice") + body = data[offset:offset + length] + thin = slices(body, "macos") + arch = CPU.get(cpu) + if arch in result or set(thin) != {arch}: + raise ValueError("universal architecture headers disagree") + result.update(thin) + return result + + +def qualify(capacity_bytes, host_bytes, library_bytes, platform): + capacity, host = json.loads(capacity_bytes), json.loads(host_bytes) + system = "Windows" if platform == "windows" else "Darwin" + variants = {row["name"]: row for row in capacity.get("variants", [])} + tree = capacity.get("engineTree", {}) + if not (capacity.get("passed") is True and capacity.get("sourceUnchanged") is True + and capacity.get("luaJitSource") == PIN and capacity.get("platform") == system + and set(variants) == {"stock", "extended"} and set(tree) == set(ENGINE_PATHS) + and all(isinstance(v, str) and len(v) == 40 for v in tree.values())): + raise ValueError("passing pinned engine qualification with source-tree binding required") + for name, limit in (("stock", 60), ("extended", 120)): + row = variants[name] + expected = [60] if name == "stock" else [60, 61, 64, 96, 120] + if not (row.get("capacity") == limit and row.get("engineSuites", 0) > 0 + and row.get("closure", {}).get("passed") is True + and row["closure"].get("minimum") == limit and row.get("cApiCounts") == expected): + raise ValueError("closure, C API and engine gates must pass for both variants") + if variants["stock"]["engineSuites"] != variants["extended"]["engineSuites"]: + raise ValueError("stock and extended engine suite coverage differs") + library_hash = sha(library_bytes) + host_variants = {row["variant"]: row for row in host.get("variants", [])} + if not (host.get("passed") is True and host.get("platform") == system + and host.get("machine") == capacity.get("machine") and host.get("baseLoveCommit") == LOVE + and host.get("originalArchiveSha256") == HOST_ARCHIVES[platform] + and host.get("qualificationSha256") == sha(capacity_bytes) + and host.get("inputCandidateLibrarySha256") == library_hash + and variants["extended"].get("librarySha256") == library_hash + and set(host_variants) == {"stock", "extended"} + and host_variants["stock"].get("capacity") == 60 + and host_variants["extended"].get("capacity") == 120 + and all(row.get("checks", 0) > 0 for row in host_variants.values())): + raise ValueError("matching actual LOVE host/thread/FFI acceptance required") + arch = {"AMD64": "x86_64", "x86_64": "x86_64", "arm64": "arm64"}.get(capacity.get("machine")) + if set(slices(library_bytes, platform)) != {arch} or arch not in ARCHES[platform]: + raise ValueError("qualified machine and library architecture differ") + return arch, tree + + +def verify(manifest_path, platform, expected_tree): + manifest_path = manifest_path.resolve() + root = manifest_path.parent + manifest = json.loads(manifest_path.read_text()) + if not (manifest.get("schema") == 1 and manifest.get("platform") == platform + and manifest.get("experimental") is True and manifest.get("capacity") == 120 + and manifest.get("luaJitSource") == PIN and manifest.get("baseLoveCommit") == LOVE): + raise ValueError("unsupported qualified runtime component") + library = child(root, manifest["library"]) + data = library.read_bytes() + if sha(data) != manifest["librarySha256"]: + raise ValueError("component library checksum mismatch") + bodies = slices(data, platform) + if set(bodies) != ARCHES[platform]: + raise ValueError("component is missing a required desktop architecture") + seen = set() + for row in manifest["qualifications"]: + capacity = child(root, row["capacityReport"]).read_bytes() + host = child(root, row["hostReport"]).read_bytes() + if sha(capacity) != row["capacitySha256"] or sha(host) != row["hostSha256"]: + raise ValueError("qualification receipt checksum mismatch") + arch, tree = qualify(capacity, host, bodies[row["architecture"]], platform) + if arch in seen or arch != row["architecture"] or tree != expected_tree: + raise ValueError("component qualification does not cover this engine/architecture") + seen.add(arch) + if seen != ARCHES[platform]: + raise ValueError("component receipts do not cover all required architectures") + notice = child(root, manifest["notice"]) + if sha(notice.read_bytes()) != manifest["noticeSha256"]: + raise ValueError("LuaJIT license notice checksum mismatch") + return library, notice, manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=ARCHES, required=True) + parser.add_argument("--candidate", type=Path, action="append", required=True, + help="extracted qualification artifact directory; one per native architecture") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + candidates = [p.resolve() for p in args.candidate] + if output.exists() or any(output.is_relative_to(p) or p.is_relative_to(output) for p in candidates): + parser.error("output must be a new directory separate from all inputs") + run = subprocess.run + if os.name != "nt" and not os.getenv("GITHUB_ACTIONS") and (ROOT / "tools/qa_storage.py").is_file(): + sys.path.insert(0, str(ROOT / "tools")) + from qa_storage import qa_source, qa_copy, qa_cache, qa_run + for path in (ROOT, *candidates, Path(sys.executable)): + qa_source(path) + qa_cache(output.parent) + qa_copy(output) + run = qa_run + checked = [] + for candidate in candidates: + reports = [(p, json.loads(p.read_bytes())) for p in candidate.rglob("report.json")] + capacity = [p for p, row in reports if "luaJitSource" in row] + host = [p for p, row in reports if "baseLoveCommit" in row] + if len(capacity) != 1 or len(host) != 1: + parser.error("each artifact must contain exactly one capacity and one host report") + lib = capacity[0].parent / "artifacts/extended" / ("lua51.dll" if args.platform == "windows" else "libluajit.so") + arch, tree = qualify(capacity[0].read_bytes(), host[0].read_bytes(), lib.read_bytes(), args.platform) + checked.append((arch, tree, capacity[0], host[0], lib)) + if {row[0] for row in checked} != ARCHES[args.platform] or len(checked) != len(ARCHES[args.platform]): + parser.error("provide exactly the native architectures required by the platform") + tree = checked[0][1] + if any(row[1] != tree for row in checked): + parser.error("candidate engine source trees differ") + output.mkdir(parents=True) + (output / "reports").mkdir() + target = output / ("Lua" if args.platform == "macos" else "lua51.dll") + if args.platform == "macos": + run(["lipo", "-create", *[str(row[4]) for row in checked], "-output", str(target)], check=True) + else: + shutil.copy2(checked[0][4], target) + notice = output / "COPYRIGHT-LuaJIT" + shutil.copy2(checked[0][4].with_name("COPYRIGHT-LuaJIT"), notice) + records = [] + for arch, _, capacity, host, _ in checked: + cp, hp = "reports/" + arch + "-capacity.json", "reports/" + arch + "-host.json" + shutil.copy2(capacity, output / cp) + shutil.copy2(host, output / hp) + records.append(dict(architecture=arch, capacityReport=cp, hostReport=hp, + capacitySha256=sha(capacity.read_bytes()), hostSha256=sha(host.read_bytes()))) + manifest = dict(schema=1, experimental=True, platform=args.platform, capacity=120, + luaJitSource=PIN, baseLoveCommit=LOVE, library=target.name, + librarySha256=sha(target.read_bytes()), notice=notice.name, + noticeSha256=sha(notice.read_bytes()), qualifications=records) + path = output / "component.json" + path.write_text(json.dumps(manifest, indent=2) + "\n") + verify(path, args.platform, tree) + print("QUALIFIED_COMPONENT", path, flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/runtime-upvalue-check/qualify.py b/tools/runtime-upvalue-check/qualify.py index 19caaf6b1..a8ba49c33 100644 --- a/tools/runtime-upvalue-check/qualify.py +++ b/tools/runtime-upvalue-check/qualify.py @@ -34,6 +34,10 @@ def main(): parser.add_argument("--engine-tests", action="store_true") args = parser.parse_args() source, output = args.luajit_source.resolve(), args.output.resolve() + engine_paths = ("main.lua", "conf.lua", "src", "assets", "data", "tests") + engine_status = git(ROOT, "status", "--porcelain", "--untracked-files=no", "--", *engine_paths) + if engine_status: + parser.error("engine runtime and test inputs must be committed before qualification") if git(source, "rev-parse", "HEAD") != PIN: parser.error("LuaJIT source must be pinned to " + PIN) original_status = git(source, "status", "--porcelain", "--untracked-files=normal") @@ -63,6 +67,8 @@ def main(): artifacts.mkdir() report = {"experimental": True, "luaJitSource": PIN, "engineSource": git(ROOT, "rev-parse", "HEAD"), + "engineTree": {name: git(ROOT, "rev-parse", "HEAD:" + name) + for name in ("main.lua", "conf.lua", "src", "assets", "data")}, "platform": platform.system(), "machine": platform.machine(), "toolsSha256": {p.name: digest(p) for p in sorted(HERE.iterdir()) if p.is_file()}, "variants": []} @@ -155,6 +161,8 @@ def command(label, argv, cwd, env=None, timeout=180): print("QUALIFIED", label, capacity, flush=True) if git(source, "status", "--porcelain", "--untracked-files=normal") != original_status: raise RuntimeError("input dependency checkout changed") + if git(ROOT, "status", "--porcelain", "--untracked-files=no", "--", *engine_paths) != engine_status: + raise RuntimeError("engine runtime/test inputs changed during qualification") report["sourceUnchanged"] = True report["passed"] = True (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") From e40e8dfbc24054c0ca7372149c1cc3bd286c8077 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 17:08:47 +0100 Subject: [PATCH 32/34] Read Mac runtime deployment requirements from native libraries --- tests/test_runtime_component.py | 9 +++++- tools/runtime-upvalue-check/README.md | 3 ++ tools/runtime-upvalue-check/component.py | 37 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_runtime_component.py b/tests/test_runtime_component.py index d2161c2e0..99adc3eb0 100644 --- a/tests/test_runtime_component.py +++ b/tests/test_runtime_component.py @@ -13,7 +13,7 @@ def thin(arch): return struct.pack("" + commands, total = struct.unpack_from(endian + "II", data, 16) + at, end = 32, 32 + total + found = [] + if end > len(data): + raise ValueError("truncated Mach-O load commands") + for _ in range(commands): + if at + 8 > end: + raise ValueError("truncated Mach-O load command") + cmd, size = struct.unpack_from(endian + "II", data, at) + if size < 8 or at + size > end: + raise ValueError("invalid Mach-O load command") + if cmd == 0x32 and size >= 24: # LC_BUILD_VERSION + target, version = struct.unpack_from(endian + "II", data, at + 8) + if target != 1: + raise ValueError("runtime library targets a different Apple platform") + found.append(version) + elif cmd == 0x24 and size >= 16: # LC_VERSION_MIN_MACOSX + found.append(struct.unpack_from(endian + "I", data, at + 8)[0]) + at += size + if not found: + raise ValueError("Mac runtime has no deployment-version command") + versions.extend(found) + version = max(versions) + return "%d.%d.%d" % (version >> 16, (version >> 8) & 255, version & 255) + + def verify(manifest_path, platform, expected_tree): manifest_path = manifest_path.resolve() root = manifest_path.parent @@ -138,6 +168,11 @@ def verify(manifest_path, platform, expected_tree): bodies = slices(data, platform) if set(bodies) != ARCHES[platform]: raise ValueError("component is missing a required desktop architecture") + if platform == "macos": + minimum = macos_minimum(bodies) + if manifest.get("minimumMacOS", minimum) != minimum: + raise ValueError("component deployment version disagrees with its library") + manifest["minimumMacOS"] = minimum seen = set() for row in manifest["qualifications"]: capacity = child(root, row["capacityReport"]).read_bytes() @@ -211,6 +246,8 @@ def main(): luaJitSource=PIN, baseLoveCommit=LOVE, library=target.name, librarySha256=sha(target.read_bytes()), notice=notice.name, noticeSha256=sha(notice.read_bytes()), qualifications=records) + if args.platform == "macos": + manifest["minimumMacOS"] = macos_minimum(slices(target.read_bytes(), "macos")) path = output / "component.json" path.write_text(json.dumps(manifest, indent=2) + "\n") verify(path, args.platform, tree) From 2d7f711d3f38d845df527d697bedf0a581aaa6a7 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 17:24:21 +0100 Subject: [PATCH 33/34] Record qualified desktop packages and packaged-runtime GPU checks --- .github/workflows/runtime-upvalues.yml | 3 +- .../johto-native-runtime-upvalues.json | 632 +++++++++++++++++- tools/runtime-upvalue-check/README.md | 19 +- 3 files changed, 639 insertions(+), 15 deletions(-) diff --git a/.github/workflows/runtime-upvalues.yml b/.github/workflows/runtime-upvalues.yml index f2c3c5b09..a22b19261 100644 --- a/.github/workflows/runtime-upvalues.yml +++ b/.github/workflows/runtime-upvalues.yml @@ -9,10 +9,11 @@ on: - 'tests/test_runtime_component.py' - 'tests/test_native_preview.py' workflow_dispatch: + workflow_call: permissions: contents: read concurrency: - group: runtime-upvalues-${{ github.ref }} + group: runtime-upvalues-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: qualify: diff --git a/docs/qa/native-foundation/johto-native-runtime-upvalues.json b/docs/qa/native-foundation/johto-native-runtime-upvalues.json index 8d25b6a99..5eb19ad0f 100644 --- a/docs/qa/native-foundation/johto-native-runtime-upvalues.json +++ b/docs/qa/native-foundation/johto-native-runtime-upvalues.json @@ -181,11 +181,11 @@ ], "initialProbeCorrection": "A debug.setupvalue check first omitted the stock LuaJIT trace flush required before changing a constant captured value. The corrected probe explicitly flushes, retaining interpreter/JIT, GC, shared captures and bytecode checks; it does not disable JIT for large closures.", "limits": [ - "Compiler and GPU checks are separate: GPU uses a copied LOVE12 app with a new arm64 Lua library", - "No Windows or Intel macOS/universal framework/packaging acceptance yet", - "No complete Pokemon model, all effects, battles, movement or all-story parity claim", - "Stock release unchanged; installing the source PR does not replace its Lua runtime", - "No private mod source/assets, generated renderer source, ROM data, screenshots or saves published" + "Earlier arm64-only experiments and later packaged universal-runtime runs are source-bound separately below.", + "Windows x64 and Intel/ARM Mac native capacity, C API, engine and LOVE host/thread/FFI gates pass; Linux capacity/engine gates pass. Windows GPU and Intel GPU gameplay remain unverified.", + "No complete Pokemon model, all effects, battles, movement, all-story, long-session or physical input parity claim.", + "Published 20261025.1 still contains stock LuaJIT. Qualified package candidates below are not published; a source PR does not replace an installed runtime.", + "No private mod source/assets, generated renderer source, ROM data, screenshots or saves published." ], "reproducibleLocalBuild": { "experimental": true, @@ -363,7 +363,7 @@ "reportSha256": "bad16a63138d43916d614f9fae021e632429a84faa4f4a982870b7e464e69dfb" } ], - "hostAbiLimits": "Local arm64 and hosted Intel/arm64 Mac host gates pass; Linux capacity/engine gate passes. Windows stock fixture correction is awaiting native requalification. Universal packaging and Windows GPU acceptance remain open.", + "hostAbiLimits": "Four-platform native matrix and universal component staging pass. Actual Windows and both Mac LOVE host/thread/FFI gates pass; those headless gates do not establish GPU gameplay.", "extendedRuntimeTransitions": { "scope": "Normal Cherrygrove Mart/Center doors, native START ownership and Cherrygrove/Route 29 connections using continuous controls with the generated extension installed; initial positions selected and fresh-save introduction skipped", "runs": [ @@ -928,5 +928,625 @@ } } ] + }, + "qualifiedDesktopPackages": { + "status": "Qualified package candidates built and GPU-tested on Mac ARM; not published", + "qualificationRun": 40209358578, + "qualificationUrl": "https://github.com/Roxas2712/Gen2Recomped/actions/runs/40209358578", + "qualificationArtifact": 12605630405, + "qualificationArtifactSha256": "bcdb42a88593b611c3eda0330c0bd06782b9b86637296af2ee807143d59fd89b", + "nativeReports": [ + { + "platform": "windows", + "architecture": "x86_64", + "capacityReportSha256": "a0d4d466b2b1fac719b53520b82830ab5e8b4ed6d896812d21ac592519bbc162", + "hostReportSha256": "583d3d9ca200139b18af88e64378e1cd66980556937c0a2fbdf093bc58c299bb", + "engineSource": "f0bf6dcdb33a5fac00eb8ee05fcc74f2ab0e9e9c", + "engineTree": { + "main.lua": "494e960f2ce65e919d309a0ec3b5fb0c495c889d", + "conf.lua": "63e543d1399fb85974ddd0ae8c5017a5108d87b8", + "src": "424ef2eb57f3cf8617424a64842fd165e0cc4a04", + "assets": "6751562356c0f060a3560c8f90fe3ae30311e043", + "data": "b8e37ffce30d5c2e7afd187c809979b59af13183" + }, + "capacityPassed": true, + "hostPassed": true, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "jit": true, + "upvalues": 1, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 8, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 32, + "compiles": true, + "interpreter": true + }, + { + "jit": true, + "upvalues": 60, + "compiles": true, + "interpreter": true + }, + { + "compiles": false, + "upvalues": 61 + }, + { + "compiles": false, + "upvalues": 64 + }, + { + "compiles": false, + "upvalues": 96 + }, + { + "compiles": false, + "upvalues": 120 + } + ], + "minimum": 60, + "passed": true, + "checks": 160250 + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "078f675d75a03c23a1589eee3760a3e537c622a05dc2aaeeb98e684a5c8fc08a", + "headerSha256": "09bfd5642546fd116fa31d29baa1396bfbadc4d88b224813faf80c68f64343b5", + "engineSuites": 353 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "upvalues": 1, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 8, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 32, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 60, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 61, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 64, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 96, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 120, + "compiles": true, + "jit": true, + "interpreter": true + } + ], + "checks": 320964, + "passed": true, + "minimum": 120 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "ae050530ef3cd02a5821cfee0dec75908114cf423126fe9e9ba0c8a8cb782260", + "headerSha256": "3fc299742016f7ea0332eed14b9281a1ec4bf721e98979be49d1144a941e8169", + "engineSuites": 353 + } + ] + }, + { + "platform": "macos", + "architecture": "x86_64", + "capacityReportSha256": "4fb405084d4129f56ee8338e923badd993063044a95bcc166f87e5b7dca7849d", + "hostReportSha256": "0e91c3ec1c07d919e0e1f65599734a778564b0e52eddc54d7c04a98fe2922682", + "engineSource": "f0bf6dcdb33a5fac00eb8ee05fcc74f2ab0e9e9c", + "engineTree": { + "main.lua": "494e960f2ce65e919d309a0ec3b5fb0c495c889d", + "conf.lua": "63e543d1399fb85974ddd0ae8c5017a5108d87b8", + "src": "424ef2eb57f3cf8617424a64842fd165e0cc4a04", + "assets": "6751562356c0f060a3560c8f90fe3ae30311e043", + "data": "b8e37ffce30d5c2e7afd187c809979b59af13183" + }, + "capacityPassed": true, + "hostPassed": true, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "minimum": 60, + "passed": true, + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "upvalues": 1, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 8, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 32, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 60, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 61, + "compiles": false + }, + { + "upvalues": 64, + "compiles": false + }, + { + "upvalues": 96, + "compiles": false + }, + { + "upvalues": 120, + "compiles": false + } + ], + "checks": 160250 + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "22edeea3bba4b278a002784c0a0ff699ba8be7bb1fa2f5c2d5d05c0cdec1ed73", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 353 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "passed": true, + "minimum": 120, + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "upvalues": 1, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 8, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 32, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 60, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 61, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 64, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 96, + "compiles": true, + "jit": true, + "interpreter": true + }, + { + "upvalues": 120, + "compiles": true, + "jit": true, + "interpreter": true + } + ], + "checks": 320964 + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "366721589883641d92bb76a5e4c4978223ea3867de8930a8efd38d9fa8ccbdb2", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 353 + } + ] + }, + { + "platform": "macos", + "architecture": "arm64", + "capacityReportSha256": "7befd36e136d9a1db5fd6bfcc8ce2b0cc63f21e9264ff84efcf276c17de49e23", + "hostReportSha256": "6566a87c37a06db67453fa4e8d0b5bce8e8b12aa2ea37d37f3f58cbb56c80337", + "engineSource": "f0bf6dcdb33a5fac00eb8ee05fcc74f2ab0e9e9c", + "engineTree": { + "main.lua": "494e960f2ce65e919d309a0ec3b5fb0c495c889d", + "conf.lua": "63e543d1399fb85974ddd0ae8c5017a5108d87b8", + "src": "424ef2eb57f3cf8617424a64842fd165e0cc4a04", + "assets": "6751562356c0f060a3560c8f90fe3ae30311e043", + "data": "b8e37ffce30d5c2e7afd187c809979b59af13183" + }, + "capacityPassed": true, + "hostPassed": true, + "variants": [ + { + "name": "stock", + "capacity": 60, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "jit": true, + "interpreter": true, + "upvalues": 1, + "compiles": true + }, + { + "jit": true, + "interpreter": true, + "upvalues": 8, + "compiles": true + }, + { + "jit": true, + "interpreter": true, + "upvalues": 32, + "compiles": true + }, + { + "jit": true, + "interpreter": true, + "upvalues": 60, + "compiles": true + }, + { + "upvalues": 61, + "compiles": false + }, + { + "upvalues": 64, + "compiles": false + }, + { + "upvalues": 96, + "compiles": false + }, + { + "upvalues": 120, + "compiles": false + } + ], + "minimum": 60, + "checks": 160250, + "passed": true + }, + "cApiCounts": [ + 60 + ], + "librarySha256": "770c2e5c855bba6d138c24441b5df4780e892a58193ac3c824d89f2af2b3c9ed", + "headerSha256": "a7e86384bfa9638da06e4db5166551e75494ceae0e8bd0505d365cba7f83af85", + "engineSuites": 353 + }, + { + "name": "extended", + "capacity": 120, + "closure": { + "runtime": "LuaJIT 2.1.1792588164", + "cases": [ + { + "interpreter": true, + "jit": true, + "upvalues": 1, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 8, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 32, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 60, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 61, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 64, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 96, + "compiles": true + }, + { + "interpreter": true, + "jit": true, + "upvalues": 120, + "compiles": true + } + ], + "checks": 320964, + "minimum": 120, + "passed": true + }, + "cApiCounts": [ + 60, + 61, + 64, + 96, + 120 + ], + "librarySha256": "b2f5783405bd6430e0a8f6839b513456cc02ff51aa73065d253c5aaf6300fc5e", + "headerSha256": "793f34ccc7668e553ee8a5aa65c3a80c821b292aa98501d63235b7fb91d96948", + "engineSuites": 353 + } + ] + } + ], + "latestNativeMatrix": { + "runId": 40210398926, + "url": "https://github.com/Roxas2712/Gen2Recomped/actions/runs/40210398926", + "headSha": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "conclusion": "success", + "jobs": [ + { + "name": "qualify (windows-2022, windows-x64, 12514271124, lua51.dll)", + "status": "completed", + "conclusion": "success" + }, + { + "name": "qualify (macos-15-intel, macos-x64, 12513712494, libluajit.so)", + "status": "completed", + "conclusion": "success" + }, + { + "name": "qualify (macos-15, macos-arm64, 12513712494, libluajit.so)", + "status": "completed", + "conclusion": "success" + }, + { + "name": "qualify (ubuntu-24.04, linux-x64)", + "status": "completed", + "conclusion": "success" + }, + { + "name": "components", + "status": "completed", + "conclusion": "success" + } + ] + }, + "packageSource": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "releaseId": "20261025.1", + "engineVersion": "0.8.46", + "payloadSha256": "33b90e1c60c636422dff98d44b6ba1e12fccb189f208e1bdce7884856f17bf32", + "identicalPayloads": true, + "packages": { + "windows": { + "engineVersion": "0.8.46", + "sourceCommit": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "runtimeCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "runtimeArtifact": 12514271124, + "runtimeSha256": "1f4c6997bd93502f986f5187d877da84247617eede89d8140bcbf570bd07ea22", + "platform": "windows", + "previewIdentity": "Gen2Recomp-NativePreview-20261025-1", + "payloadSha256": "33b90e1c60c636422dff98d44b6ba1e12fccb189f208e1bdce7884856f17bf32", + "archiveSha256": "025bf3001a0cb01d00ae4ec739daddcc7de14d038eddf77b6f5d7c04a4c65fb8", + "modsIncluded": false, + "modsEnabled": true, + "legacyMigration": false, + "automaticInstallation": false, + "qualifiedRuntime": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "capacity": 120, + "architectures": [ + "x86_64" + ], + "componentManifestSha256": "faab135398688f1fd51fd3c532428537349031ae3ffc482b02ab2f05c68370d9", + "inputLibrarySha256": "ae050530ef3cd02a5821cfee0dec75908114cf423126fe9e9ba0c8a8cb782260", + "engineTreeVerified": true, + "qualifications": [ + { + "architecture": "x86_64", + "capacityReport": "reports/x86_64-capacity.json", + "hostReport": "reports/x86_64-host.json", + "capacitySha256": "a0d4d466b2b1fac719b53520b82830ab5e8b4ed6d896812d21ac592519bbc162", + "hostSha256": "583d3d9ca200139b18af88e64378e1cd66980556937c0a2fbdf093bc58c299bb" + } + ], + "installedLibrarySha256": "ae050530ef3cd02a5821cfee0dec75908114cf423126fe9e9ba0c8a8cb782260" + } + }, + "macos": { + "engineVersion": "0.8.46", + "sourceCommit": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "runtimeCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "runtimeArtifact": 12513712494, + "runtimeSha256": "f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8", + "platform": "macos", + "previewIdentity": "Gen2Recomp-NativePreview-20261025-1", + "payloadSha256": "33b90e1c60c636422dff98d44b6ba1e12fccb189f208e1bdce7884856f17bf32", + "archiveSha256": "d28364e3a658f72075dc53e3ff91f549f5fa452544dc0353bf2a88dea554c7b5", + "modsIncluded": false, + "modsEnabled": true, + "legacyMigration": false, + "automaticInstallation": false, + "qualifiedRuntime": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "capacity": 120, + "architectures": [ + "arm64", + "x86_64" + ], + "componentManifestSha256": "d853a2fa4e7a2318250ae1029bdaef39a18db808361ad67dab0aa7dc8403cffa", + "inputLibrarySha256": "2ccdcc40c0b2a02e12abc8069a9bf9523c1978a8efd46a248f15bf67c629748d", + "engineTreeVerified": true, + "qualifications": [ + { + "architecture": "x86_64", + "capacityReport": "reports/x86_64-capacity.json", + "hostReport": "reports/x86_64-host.json", + "capacitySha256": "4fb405084d4129f56ee8338e923badd993063044a95bcc166f87e5b7dca7849d", + "hostSha256": "0e91c3ec1c07d919e0e1f65599734a778564b0e52eddc54d7c04a98fe2922682" + }, + { + "architecture": "arm64", + "capacityReport": "reports/arm64-capacity.json", + "hostReport": "reports/arm64-host.json", + "capacitySha256": "7befd36e136d9a1db5fd6bfcc8ce2b0cc63f21e9264ff84efcf276c17de49e23", + "hostSha256": "6566a87c37a06db67453fa4e8d0b5bce8e8b12aa2ea37d37f3f58cbb56c80337" + } + ], + "minimumMacOS": "11.0.0", + "installedLibrarySha256": "0b9e147ab903851cb488391ba7e6979f1e36b424e51dd4db48e5c97979a94f5c" + } + } + }, + "staticChecks": { + "macStrictSignature": true, + "macArchitectures": [ + "x86_64", + "arm64" + ], + "windowsFusedPayload": true, + "windowsQualifiedLibrary": true + }, + "sourceBindingRejection": "An earlier 8dd374aa candidate was refused because its engine tree lacked the concurrently merged mod-target contracts. Main was merged as f7a86975; exact main/conf/src/assets/data trees then matched qualification. Guards were retained.", + "gpu": [ + { + "backend": "opengl", + "engineSource": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "checks": 213, + "normalTransitions": 12, + "menuOwnershipChecks": 4, + "ownedWindowProofs": 4, + "cameras": [ + "first", + "third" + ], + "modelRendererInstalled": true, + "modFilesVerified": 27329, + "modSourceUnmodified": true, + "resultSha256": "bff2afc9abfb6bbb98a5918532b4eb7bf8ca1d5f81e3f153de46f7b031264d18", + "verificationSha256": "a9f061109c97379fe184b5a6df9b14b67dcb122cac406e53b235c80ae58a7c14", + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "releaseProfileNeverLaunched": true, + "visualInspection": "All four images inspected: upright rooms/NPCs; no complete occlusion/model parity acceptance. OpenGL first-person Center view pitched toward the ceiling and includes a black/white object. Metal includes transient deprecated-shader warnings and a dark right-edge object in later proofs." + }, + { + "backend": "metal", + "engineSource": "f7a8697556a6bc61a068cd8c975ce316a454cafb", + "checks": 213, + "normalTransitions": 12, + "menuOwnershipChecks": 4, + "ownedWindowProofs": 4, + "cameras": [ + "first", + "third" + ], + "modelRendererInstalled": true, + "modFilesVerified": 27329, + "modSourceUnmodified": true, + "resultSha256": "576bcf6866ef73260efe50246238d421aafaba1174ab6bfea153a7c59b47f2a9", + "verificationSha256": "0aea9a0a55e198ab319bc7836861cc1ddb64b1fb63dd905d3ac676473311ea37", + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "releaseProfileNeverLaunched": true, + "visualInspection": "All four images inspected: upright rooms/NPCs; no complete occlusion/model parity acceptance. OpenGL first-person Center view pitched toward the ceiling and includes a black/white object. Metal includes transient deprecated-shader warnings and a dark right-edge object in later proofs." + } + ] } } diff --git a/tools/runtime-upvalue-check/README.md b/tools/runtime-upvalue-check/README.md index 90c9d17ed..bb6afc764 100644 --- a/tools/runtime-upvalue-check/README.md +++ b/tools/runtime-upvalue-check/README.md @@ -10,15 +10,17 @@ The patch here is an **experimental dependency patch**, not an engine-side source rewriter. It applies to LuaJIT commit `a4c064539ea931274d407e1249b4bb575424a0f4` and raises `LJ_MAX_UPVAL` to 120. The recorder's own source notes a 127 limit; this is not proof that every -architecture is safe at 120. The normal runtime/release is not replaced by -these tools. No supplied renderer source/assets or cartridge data are included. +architecture is safe at 120. The tools do not modify an existing installation. +Packaging must explicitly select qualified components as described below. +No supplied renderer source/assets or cartridge data are included. Always use a separate clean dependency checkout/build directory. On macOS the local experiment builds with `MACOSX_DEPLOYMENT_TARGET=11.0` and `make -j4 XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT`. Its LÖVE test app is a separate copy with an arm64-only replacement Lua framework and ad-hoc signing. The -original stock app and dependency checkout remain unchanged. This is not a -universal macOS or Windows release recipe. +original stock app and dependency checkout remain unchanged. That local +experiment alone does not qualify Windows or a universal Mac app; the native +matrix and component staging below cover those separate requirements. ## Reproducible qualification builds @@ -143,7 +145,8 @@ cc -I/path/to/candidate/src tools/runtime-upvalue-check/c_api.c \ These microchecks must accompany engine regressions and real GPU verification. A generated extension compiling/initializing is not proof that all its Pokemon models, movement, combat, shadows and other effects are equivalent to Gen1. -Windows/x64, Intel macOS, architecture packaging, dependency ABI compatibility, -bytecode interchange and broader native renderer/gameplay coverage remain -release gates. See the source-bound runtime experiment receipt under -`docs/qa/native-foundation/` for completed local evidence and exact limits. +Native platform, architecture packaging and dependency ABI gates must pass +for the selected source before packaging. They do not establish Windows GPU, +complete native renderer/gameplay, long-session or saved-bytecode migration +acceptance. See the source-bound runtime experiment receipt under +`docs/qa/native-foundation/` for completed evidence and exact limits. From b9c0545abc2ba4a7c2d5ec4e134b31e5b8742f7a Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 18:11:58 +0100 Subject: [PATCH 34/34] Record published desktop runtime and native 3D battle qualification --- .../johto-native-runtime-upvalues.json | 276 +++++++++++++++++- 1 file changed, 275 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/johto-native-runtime-upvalues.json b/docs/qa/native-foundation/johto-native-runtime-upvalues.json index 5eb19ad0f..09458b9b0 100644 --- a/docs/qa/native-foundation/johto-native-runtime-upvalues.json +++ b/docs/qa/native-foundation/johto-native-runtime-upvalues.json @@ -184,7 +184,7 @@ "Earlier arm64-only experiments and later packaged universal-runtime runs are source-bound separately below.", "Windows x64 and Intel/ARM Mac native capacity, C API, engine and LOVE host/thread/FFI gates pass; Linux capacity/engine gates pass. Windows GPU and Intel GPU gameplay remain unverified.", "No complete Pokemon model, all effects, battles, movement, all-story, long-session or physical input parity claim.", - "Published 20261025.1 still contains stock LuaJIT. Qualified package candidates below are not published; a source PR does not replace an installed runtime.", + "The existing 20261025.1 desktop assets were refreshed with the qualified runtime from 0acb23b7. Earlier f7a86975 candidates below remain historical; installing source alone does not replace an installed runtime.", "No private mod source/assets, generated renderer source, ROM data, screenshots or saves published." ], "reproducibleLocalBuild": { @@ -1548,5 +1548,279 @@ "visualInspection": "All four images inspected: upright rooms/NPCs; no complete occlusion/model parity acceptance. OpenGL first-person Center view pitched toward the ceiling and includes a black/white object. Metal includes transient deprecated-shader warnings and a dark right-edge object in later proofs." } ] + }, + "publishedDesktopRefresh": { + "release": "https://github.com/Roxas2712/Gen2Recomped/releases/tag/native-preview-20261025.1", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "tagUnchanged": true, + "originalTagSource": "0780731e0a925618538d9048524042ca2680ac85", + "assetSha256": { + "Gen2Recomped-NativePreview.love": "7477b6900223ab9124140b0582ffe28bcc2d4867a8044add4f97b82bf8923ba0", + "Gen2Recomped-NativePreview-20261025.1-macos.zip": "3df1b969fa78bfa7536169d766824bb6af4ce2fb94e1785abb9f7e13148c0a14", + "Gen2Recomped-NativePreview-20261025.1-windows.zip": "cbba903e71aa2d44f6bf4ef3a11f8f154f5edb33757b4c3656d2d2a073e742ef", + "build-manifest.json": "d42fc204f2654778aeb531fef33c74ed00305e7d067d5e4043b14d554a5c29b8", + "README-Testrelease.txt": "a459424647fcc4dc22883a5517652756d766c05b99fb21283367eaad66bb8ac3", + "SHA256SUMS.txt": "67e7b84ed2981bc4e3e4095271f32fd4938a3faf0807e1a0f21f201174f6d772" + }, + "nativeQualificationRun": 40211783006, + "nativeQualificationArtifactSha256": "d8feb669745ef1f0d79c39e09363f4de485f10108bd2b77c96140093d41a13a0", + "builds": [ + { + "engineVersion": "0.8.46", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "runtimeCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "runtimeArtifact": 12514271124, + "runtimeSha256": "1f4c6997bd93502f986f5187d877da84247617eede89d8140bcbf570bd07ea22", + "platform": "windows", + "previewIdentity": "Gen2Recomp-NativePreview-20261025-1", + "payloadSha256": "7477b6900223ab9124140b0582ffe28bcc2d4867a8044add4f97b82bf8923ba0", + "archiveSha256": "cbba903e71aa2d44f6bf4ef3a11f8f154f5edb33757b4c3656d2d2a073e742ef", + "archive": "Gen2Recomped-NativePreview-20261025.1-windows.zip", + "buildVerified": true, + "launchVerified": false, + "realDeviceVerified": false, + "extendedUpvaluesQualified": true, + "modsIncluded": false, + "modsEnabled": true, + "legacyMigration": false, + "automaticInstallation": false, + "qualifiedRuntime": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "capacity": 120, + "architectures": [ + "x86_64" + ], + "componentManifestSha256": "055bea4f3c67c942ec14d056ed1ad9fd507ea109e591a4d1174066cc68bf8402", + "inputLibrarySha256": "18f020622b623364f0a82cfafbaa0f95d7cb490c42f19c70fba4768e78a042d0", + "engineTreeVerified": true, + "qualifications": [ + { + "architecture": "x86_64", + "capacityReport": "reports/x86_64-capacity.json", + "hostReport": "reports/x86_64-host.json", + "capacitySha256": "9b0a2bd48b9f246b0c789a01af0b2a81a9658b3aed148ddf98868f1b367ac036", + "hostSha256": "a81c79211e55b0ea968b780603e0cd46cd3fe526400f9cd5b51fc75c86890b87" + } + ], + "installedLibrarySha256": "18f020622b623364f0a82cfafbaa0f95d7cb490c42f19c70fba4768e78a042d0" + } + }, + { + "engineVersion": "0.8.46", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "runtimeCommit": "937eb3b21f973607ab080aa26b7ef2a6b3b2b663", + "runtimeArtifact": 12513712494, + "runtimeSha256": "f70f60d162eda6931b0b28e2b094b83b741986f0feac08d33a10be7c57e724f8", + "platform": "macos", + "previewIdentity": "Gen2Recomp-NativePreview-20261025-1", + "payloadSha256": "7477b6900223ab9124140b0582ffe28bcc2d4867a8044add4f97b82bf8923ba0", + "archiveSha256": "3df1b969fa78bfa7536169d766824bb6af4ce2fb94e1785abb9f7e13148c0a14", + "archive": "Gen2Recomped-NativePreview-20261025.1-macos.zip", + "buildVerified": true, + "launchVerified": false, + "realDeviceVerified": false, + "extendedUpvaluesQualified": true, + "modsIncluded": false, + "modsEnabled": true, + "legacyMigration": false, + "automaticInstallation": false, + "qualifiedRuntime": { + "experimental": true, + "luaJitSource": "a4c064539ea931274d407e1249b4bb575424a0f4", + "capacity": 120, + "architectures": [ + "arm64", + "x86_64" + ], + "componentManifestSha256": "bd096f8b087d651cef7c4073f17b20f1683d2e8999618f1c5dd4862e58f36a89", + "inputLibrarySha256": "2ccdcc40c0b2a02e12abc8069a9bf9523c1978a8efd46a248f15bf67c629748d", + "engineTreeVerified": true, + "qualifications": [ + { + "architecture": "x86_64", + "capacityReport": "reports/x86_64-capacity.json", + "hostReport": "reports/x86_64-host.json", + "capacitySha256": "abc0cb8e017c158caf0f20e9aaaaa984b0ecbfc566f78a591558f1168c2b3450", + "hostSha256": "e2940b54371e12778f6a99fff8e16d604ca04e4ad005dbe100b3f66afd355d69" + }, + { + "architecture": "arm64", + "capacityReport": "reports/arm64-capacity.json", + "hostReport": "reports/arm64-host.json", + "capacitySha256": "a640daa7ee0063f7ec5e713a2f8b6c1ea890e1f04672b8633aee37698e6edf6e", + "hostSha256": "da47a386ef1a48e47c4ff867db38fafa69150a5046ce8d16614ba3a7b9198a85" + } + ], + "minimumMacOS": "11.0.0", + "installedLibrarySha256": "0b9e147ab903851cb488391ba7e6979f1e36b424e51dd4db48e5c97979a94f5c" + } + } + ], + "gpu": [ + { + "scenario": "transitions", + "backend": "opengl", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "checks": 213, + "windowProofs": 4, + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "resultSha256": "aa42f1a8b26e1a210893f83e4d8e6a7bdfcc43952f3ba8ff1f5b6bd472427ef0", + "verificationSha256": "3d36233c696041cecc716373a13fa2e290eb87b5a5ba049b47699d9ae6c104e6", + "transitions": 12, + "menuChecks": 4 + }, + { + "scenario": "transitions", + "backend": "metal", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "checks": 213, + "windowProofs": 4, + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "resultSha256": "89bba065a8d182564f9cb10fc9123603f0a6fb471b1cd8ef8c2cff200c04d480", + "verificationSha256": "b20b777bb570dbec65d05ef4e2161a96d832194df5b0c056258ec2f63ffc635b", + "transitions": 12, + "menuChecks": 4 + }, + { + "scenario": "battle", + "backend": "opengl", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "checks": 20, + "windowProofs": 4, + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "resultSha256": "d12b85d44ccd1359ca091d08d74702392f7c826778a68ad6b5bda0356f5dd236", + "verificationSha256": "2f1fc9be736750500060b06822005503edf10a9784c613a7950b984f5334265c", + "cases": [ + { + "damage": 19, + "enemySpecies": "SPECIES_019", + "provider": { + "fallbacks": 0, + "installed": true, + "frames": 912, + "active": false + }, + "camera": "first", + "phase": "messages", + "fieldRig": { + "yaw": 1.5707963267948966, + "pitch": 0.17453292519943295, + "engaged": true, + "cardBlend": 1, + "driving": true, + "blend": 1, + "hidePlayer": true + }, + "playerSpecies": "SPECIES_152", + "finished": "win" + }, + { + "damage": 19, + "enemySpecies": "SPECIES_019", + "provider": { + "fallbacks": 0, + "installed": true, + "frames": 1795, + "active": false + }, + "camera": "third", + "phase": "messages", + "fieldRig": { + "yaw": 1.5707963267948966, + "pitch": 0.17453292519943295, + "engaged": true, + "cardBlend": 1, + "driving": true, + "blend": 1, + "hidePlayer": false + }, + "playerSpecies": "SPECIES_152", + "finished": "win" + } + ], + "visiblePokemon": "Native colored sprite cards on the 3D map; no full volumetric-model parity claim" + }, + { + "scenario": "battle", + "backend": "metal", + "sourceCommit": "0acb23b7ae467c6bef6555bb1b3caa689712cfc7", + "checks": 20, + "windowProofs": 4, + "qaBootstrapScope": "Exact packaged engine src and stamped version, tests copied from the same commit; generated preview entry points replaced by bounded QA identity bootstrap. Own runtime copy has game.love removed and is re-signed; Lua library remains byte-identical to the package.", + "resultSha256": "ca8b923c115cb462bd66276ed4ca77524b17e2cbde57c8d45d5ec07e05e96aef", + "verificationSha256": "da8c29436523cee79cd0d871e65fb979c95a4b1759be59707fd31b4e3c0dee31", + "cases": [ + { + "finished": "win", + "enemySpecies": "SPECIES_019", + "damage": 18, + "phase": "messages", + "playerSpecies": "SPECIES_152", + "provider": { + "installed": true, + "frames": 928, + "active": false, + "fallbacks": 0 + }, + "fieldRig": { + "engaged": true, + "yaw": 1.5707963267948966, + "hidePlayer": true, + "cardBlend": 1, + "pitch": 0.17453292519943295, + "blend": 1, + "driving": true + }, + "camera": "first" + }, + { + "finished": "win", + "enemySpecies": "SPECIES_019", + "damage": 18, + "phase": "messages", + "playerSpecies": "SPECIES_152", + "provider": { + "installed": true, + "frames": 1816, + "active": false, + "fallbacks": 0 + }, + "fieldRig": { + "engaged": true, + "yaw": 1.5707963267948966, + "hidePlayer": false, + "cardBlend": 1, + "pitch": 0.17453292519943295, + "blend": 1, + "driving": true + }, + "camera": "third" + } + ], + "visiblePokemon": "Native colored sprite cards on the 3D map; no full volumetric-model parity claim" + } + ], + "originalModFilesUnchanged": 27329, + "visualInspection": "All 16 current-main window proofs inspected. Actual native colored Pokemon cards and upright battle HUDs are visible in both cameras/backends. First Person hides the player; Third Person shows the player. After-damage images show return to the field. Overworld black/white actors, some bottom-edge command clipping and Metal deprecation overlays remain unresolved; no full volumetric-model or performance parity claim.", + "failedQaAssumptions": [ + "First staged probe used symbolic species names; corrected to the native imported SPECIES_### keys.", + "Second staged probe selected the first learned move GROWL and incorrectly expected damage. The passing probe records the imported learnset and explicitly chooses its learned RAZOR_LEAF using native input." + ], + "openNativeInterfaces": [ + "party follower predicate/current lifecycle facade", + "HP presentation step protocol", + "battle animation view/capture presentation", + "PC box/mail integration", + "registered item and field action/fly picker protocols" + ], + "limits": [ + "Battle party, initial map and intro are explicit QA setup. Ordinary visible contacts, capture, party switching, RUN, story, all maps, long sessions and physical input are not established by this staged probe.", + "Window labels resized/map-return are historical capture tags, not evidence of a resize.", + "OpenGL outdoor render CPU samples around 40ms and spikes around 100ms remain; Metal also recorded a ~1s hitch. No controlled performance comparison or 60FPS acceptance.", + "Windows x64 and Intel Mac native ABI/headless qualification and packaging pass; GPU gameplay was executed only on Mac ARM.", + "Mod-reported overworld capture is disabled by its own RC policy; an engine facade must not force it active.", + "The original release tag remains at 0780731e; refreshed binary sources are identified by the manifest/README and commit link. No original mod source/assets, ROM data, screenshots or saves are published." + ] } }