From 980f2767168511701085f186e652d8041eba5f1d Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 21:51:00 +0100 Subject: [PATCH 1/4] Connect compatibility session and frame callbacks to native game owners --- docs/native-session-runtime.md | 97 ++++++++ main.lua | 34 ++- src/compat/gen1/SessionView.lua | 109 +++++++++ src/core/Game.lua | 132 +++++++---- src/core/Game3.lua | 269 ++++++++++++++++++++++ src/core/game3/runtime.lua | 212 +++++++++++++++++ src/mods/Events.lua | 8 +- src/mods/Hooks.lua | 8 +- src/mods/Loader.lua | 20 ++ src/mods/ModSeams.lua | 14 +- tests/engine/native_session_callbacks.lua | 1 + tests/engine/native_session_dispatch.lua | 1 + tests/engine/native_session_runtime.lua | 1 + tools/session-runtime/callback-check.lua | 119 ++++++++++ tools/session-runtime/check.lua | 117 ++++++++++ tools/session-runtime/dispatch-check.lua | 112 +++++++++ tools/session-runtime/fixture.lua | 20 ++ 17 files changed, 1222 insertions(+), 52 deletions(-) create mode 100644 docs/native-session-runtime.md create mode 100644 src/compat/gen1/SessionView.lua create mode 100644 src/core/Game3.lua create mode 100644 src/core/game3/runtime.lua create mode 100644 tests/engine/native_session_callbacks.lua create mode 100644 tests/engine/native_session_dispatch.lua create mode 100644 tests/engine/native_session_runtime.lua create mode 100644 tools/session-runtime/callback-check.lua create mode 100644 tools/session-runtime/check.lua create mode 100644 tools/session-runtime/dispatch-check.lua create mode 100644 tools/session-runtime/fixture.lua diff --git a/docs/native-session-runtime.md b/docs/native-session-runtime.md new file mode 100644 index 000000000..4d3b9b055 --- /dev/null +++ b/docs/native-session-runtime.md @@ -0,0 +1,97 @@ +# Native game/session and frame boundaries + +`src.core.Game3` is a distinct class with per-native-owner facade instances. +It does not alias the central `Game`, instantiate a second engine or bootstrap +Game when imported. Explicit owner resolution rejects retired handles. A source +session is scoped to its native save, dataset, map table and edition; replacing +one retires its observations and queued callbacks. + +## Native ownership + +The native Game retains its frame, fixed-step, input, script, battle, audio and +save owners. Captured base methods run with that native Game, while source +wrappers receive their stable facade. A wrapper may hold an entry. Repeated base +calls in one entry retain the first native result, including nil-bearing return +lists, instead of advancing again. Native failures cannot be hidden or retried. +The native frame's existing collector step also exposes the source `stepGC` +seam; there is no additional collector running beside it. + +The runtime services one deferred batch at `Game:_step`, after input promotion +and stack recovery. New callbacks wait for the next native phase. Nested drains, +save/dataset/loader changes, stop, teardown and replacement batches cannot revive +an old cohort. A source runtime wrapper may hold that field phase. Callback +failures preserve diagnostics and owner attribution without replaying the batch. +`Runtime.update` cannot start a separate simulation outside that phase. + +Native playtime remains seconds. The source view projects hours/minutes/seconds +and fractional seconds without changing the save representation or integrating +another accumulator. Repeated RTC/VBlank observations do not tick another clock. +The VBlank property here observes serviced native phases; complete source RTC +and time-event interfaces are separate from this lifecycle boundary. + +## Callback and input interoperability + +Requiring the Game3/runtime namespace identifies the mod's expected game-handle +contract through the existing mod seam journal. This does not require changing a +manifest or recognizing a particular mod ID. Its game-bearing events and direct +game hook arguments receive the facade. Its `next` calls unwrap those handles +before native participants or the native body run. Other participants receive +the original game/payload, and custom event payloads keep their own contract. +Rollback and teardown remove that namespace selection. Record/context families +other than these explicit game/save boundaries still require their own adapters. + +Desktop mouse wrappers run before the native mouse path; native synthesized touch +is not also delivered as another source touch. Physical touch, editors, importers, +boot reports and the native pointer owners retain their entry paths. Pointer and +frame failures restore their scopes. Other generations bypass the source facade. + +Native title callbacks lend single-use actions to a source intro. A delayed +action still invokes the original native new-game/continue callback once and +declines if its save, dataset, loader or boot scope was replaced. Native boot UI, +naming, RNG, map entry and persistence are not copied into this facade. +Source reset callbacks run before mod wrappers unwind; cleanup is idempotent. + +## Session surface and remaining work + +The initial session view covers edition/generation, canonical current map and +position, player header, money/coins, bike/surf observations, native options/mod +state and the playtime projection. Supported scalar writes are typed and change +only the corresponding in-memory native fields. Coordinates, map changes and +clock replacement remain native commands. Temporary presentation extensions and +preview replacements never replace the underlying native save/stack. Persistence +continues through the existing native services and `mod.save`. + +This is not the complete source session schema. Party, bag, storage, dex, numeric +script stores, complete session enumeration/deep-copy, preview isolation, custom +boot UI and further field/actor/battle/audio services still need their typed +adapters. A differently shaped native collection is not exposed under a source +name. The data facade supplies the prior owned map views; other data schemas need +their own contracts. Unchanged-mod startup, full renderer/gameplay, GPU and +performance acceptance remain incomplete. + +## Checks + +Run with LuaJIT from the repository root: + +``` +luajit tools/session-runtime/check.lua +luajit tools/session-runtime/dispatch-check.lua +luajit tools/session-runtime/callback-check.lua +luajit tests/run_engine.lua +luajit tests/run_modkit.lua +``` + +These ROM-free checks exercise the actual native Game/StateStack/player phases, +frame clock/audio/GC ownership, title callbacks with a synthetic disk reader, +failure/replacement paths, all five editions, other-generation isolation, and +unchanged SDK consumers sharing callbacks with a native peer. They do not open +player profiles. Native QA must use the shared 8 GiB free-space guard. + +The same callback consumer runs on API 2 and API 3 where supported, selected by +`SESSION_RUNTIME_API`; its default is the host's actual advertised API level. +Integration prerequisites are the early native owner/player boundary (#254), +source map identities (#239), and owned map read views (#251 with dependencies) +when the data facade is used. The five-edition checks also need the separate +cartridge registrations (#183, #186, #187). Loading an original API-3 mod on +upstream additionally requires the existing API-3 stack; this boundary does not +raise or bypass the advertised mod API version. diff --git a/main.lua b/main.lua index 18325ffdf..2caac2267 100644 --- a/main.lua +++ b/main.lua @@ -934,7 +934,7 @@ function love.wheelmoved(x, y) Game:wheelmoved(x, y) end -function love.mousepressed(x, y, button, istouch) +local function nativeMousePressed(x, y, button, istouch) if bootReport then return dismissBootReport() end if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second @@ -958,7 +958,7 @@ function love.mousepressed(x, y, button, istouch) end end -function love.mousereleased(x, y, button, istouch) +local function nativeMouseReleased(x, y, button, istouch) if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) @@ -985,7 +985,7 @@ function love.mousereleased(x, y, button, istouch) end end -function love.mousemoved(x, y, dx, dy, istouch) +local function nativeMouseMoved(x, y, dx, dy, istouch) -- FREE-CAMERA LOOK FIRST, and only when one is up: `Game:cameraLook` -- answers false on every other map and camera, so the touch path below -- keeps every case it had. Warp back to centre after a real look so the @@ -1016,6 +1016,34 @@ function love.mousemoved(x, y, dx, dy, istouch) end end +-- Source mouse wrappers see one desktop event. The native pointer path keeps +-- its touch synthesis, without also reporting that synthesis as a second +-- source touch event. Editors/importers and physical touch keep their owners. +function love.mousepressed(x, y, button, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch) + and facade and facade._pointer then + return facade._pointer('mousepressed',Game,nativeMousePressed,x,y,button,istouch) + end + return nativeMousePressed(x,y,button,istouch) +end +function love.mousereleased(x, y, button, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch) + and facade and facade._pointer then + return facade._pointer('mousereleased',Game,nativeMouseReleased,x,y,button,istouch) + end + return nativeMouseReleased(x,y,button,istouch) +end +function love.mousemoved(x, y, dx, dy, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch or Game.lookWarping) + and facade and facade._pointer then + return facade._pointer('mousemoved',Game,nativeMouseMoved,x,y,dx,dy,istouch) + end + return nativeMouseMoved(x,y,dx,dy,istouch) +end + function love.textinput(text) if TouchEditor then return end if Importer then return Importer:textinput(text) end diff --git a/src/compat/gen1/SessionView.lua b/src/compat/gen1/SessionView.lua new file mode 100644 index 000000000..89fecf5cd --- /dev/null +++ b/src/compat/gen1/SessionView.lua @@ -0,0 +1,109 @@ +-- Typed observations of one native GBA save, never another save/simulation. +-- Collection schemas (party, bag, script stores) have separate adapters; do +-- not lend differently shaped native tables under those source names. +local Native=require('src.compat.gen1.NativeSession') +local Identity=require('src.compat.gen1.MapIdentity') +local Version=require('src.core.GameVersion') +local M={} +local records=setmetatable({},{__mode='k'}) +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0 and n or 0 end + return number(value.hours)*3600+number(value.minutes,value.mins)*60 + +number(value.seconds,value.secs)+number(value.frames,value.vblanks)/60 + end + local n=tonumber(value);return finite(n)and n>=0 and n or 0 +end +function M.new(game) + local owner,why=Native.game(game);if not owner then return nil,why end + local data,save,edition=owner.data,owner.save,Version.get() + if type(data)~='table'or type(data.maps)~='table'or type(save)~='table'then return nil,'native save data unavailable'end + if not Identity.lookup(edition,0,0)then return nil,'native source map catalogue unavailable'end + local binding=Identity.bind(data.maps,edition) + local record={owner=owner,data=data,save=save,edition=edition,maps=data.maps,clock={}} + local view={} + function record:valid() + if self.retired then return false end + if Version.get()~=edition or owner.data~=data or owner.save~=save or data.maps~=self.maps then + self.retired=true;return false + end + return true + end + function record:seconds()return M.seconds(save)end + function record:world() + if not self:valid()then return end + local _,world,player=Native.resolve(owner);return world,player + end + function record:map() + local world=self:world();local id=world and world.map.id or(save.player and save.player.map) + return id and binding:legacyId(id) + end + local function current() + if not record:valid()then return nil end + return save.player or{},record:world() + end + setmetatable(view,{ + __index=function(_,key) + local player,world,actor=current();if not player then return nil end + if key=='version'then return edition end + if key=='generation'then return 3 end + if key=='engine'then return 'game3'end + if key=='map'then return record:map()end + if key=='x'or key=='y'then return actor and actor[key=='x'and'cellX'or'cellY']or player[key]end + if key=='facing'then return actor and actor.facing or player.facing end + if key=='name'then return player.name end + if key=='rivalName'then return player.rival end + if key=='trainerId'then return player.id end + if key=='gender'then return player.gender end + if key=='money'or key=='coins'or key=='modData'or key=='meta'or key=='options'then return save[key]end + if key=='biking'then return save.onBike==true end + if key=='bikeType'then return actor and actor.acroBike and'acro'or save.bikeType end + if key=='surfing'then if actor then return actor.surfing==true end;return player.surfing==true end + if key=='underwater'then return world and world.map.def.mapType=='UNDERWATER'or false end + if key=='elevation'then return actor and actor.elevation or player.elevation end + local seconds=record:seconds();local whole=math.floor(seconds) + if key=='playTime'or key=='playtime'then + local clock=record.clock + clock.hours,clock.minutes,clock.seconds=math.floor(whole/3600),math.floor(whole/60)%60,whole%60 + clock.vblanks=math.floor((seconds-whole)*60+.000001) + return clock + end + if key=='hours'or key=='playTimeHours'then return math.floor(whole/3600)end + if key=='minutes'or key=='playTimeMinutes'then return math.floor(whole/60)%60 end + if key=='seconds'or key=='playTimeSeconds'then return whole%60 end + end, + __newindex=function(t,key,value) + assert(record:valid(),'native session view retired') + local player=save.player + if key=='money'or key=='coins'then + assert(finite(value)and value>=0 and value%1==0 and value<4294967296,'invalid session '..key) + save[key]=value + elseif key=='name'or key=='rivalName'then + assert(player and type(value)=='string'and#value<=10 and not value:find('%c'),'invalid player name') + player[key=='name'and'name'or'rival']=value + elseif key=='gender'then + assert(player and(value==0 or value==1),'invalid player gender');player.gender=value + elseif key=='trainerId'then + assert(player and finite(value)and value>=0 and value<=65535 and value%1==0,'invalid native trainer id');player.id=value + elseif key=='biking'then + assert(type(value)=='boolean','invalid bike state');save.onBike=value + elseif key=='map'or key=='x'or key=='y'or key=='facing'or key=='elevation'or key=='surfing' + or key=='version'or key=='generation'or key=='engine'or key=='options'or key=='modData'or key=='meta' + or key=='playTime'or key=='playtime'or key=='hours'or key=='minutes'or key=='seconds' + or key=='playTimeHours'or key=='playTimeMinutes'or key=='playTimeSeconds'then + error('session '..key..' belongs to its native command owner',2) + else + -- Temporary presentation/preview fields stay detached. Persistence + -- belongs to mod.save; this never adds fields to a player's save. + rawset(t,key,value) + end + end, + }) + records[view]=record + return view,record +end +function M.record(view)return records[view]end +function M.release(view)local record=records[view];if record then record.retired=true end end +return M diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..386958354 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -233,59 +233,74 @@ end -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation -function Game:makeTitleState() +function Game:newGame() local OverworldState = require("src.world.OverworldController") + while self.stack:top() do self.stack:pop() end + if self.overworld then self.overworld.fadeOverlay = nil end + -- New Game keeps the standalone options.lua preferences + self.save = SaveData.newGame(self:bootConfig()) + -- no bucket carry-over: mod state from an abandoned session must + -- not leak into a fresh slot; mods seed via save.created instead + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) + self:applyOptions(self.save.options) + self.stack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, + self.save.player.facing) + local newGameScreen = bootScreens(self).newGame + if newGameScreen == nil and not require("src.core.GameVersion").isGen2() then + newGameScreen = "OakSpeech" + end + if newGameScreen then + Screens.push(self, newGameScreen, function() end) + else + -- PRISM HAS NO OAK SPEECH, and that is why its character customisation + -- never appeared. Data.lua sets boot.screens.newGame = false for any + -- dataset with no OakText* -- Prism writes its own introduction in + -- engine/intro_menu.asm rather than replaying the professor's -- so + -- OakSpeech does not run, and the `customize_player` step lives INSIDE + -- OakSpeech. The step was correct and simply unreachable. + -- + -- IntroductionSpeech calls PlayerCustomization before it asks the + -- player's name, and the name prompt on this path comes from the + -- intro map's own script, so pushing it here puts it in the same + -- place: after New Game, before the map runs. + -- pcall, not a bare require: a dataset can want this screen without + -- the build shipping it (that is exactly how NEW GAME on Prism went + -- from "no customisation menu" to "module not found" the moment the + -- branch became reachable). A missing screen must cost the player + -- the customisation, not the save file they were starting. + local okCust, Cust = pcall(require, "src.ui.PrismCustomization") + if okCust and type(Cust) == "table" and Cust.available(self) then + Screens.push(self, "PrismCustomization", function() end) + elseif not okCust then + require("src.core.Logger").error( + "player customisation screen unavailable: %s", tostring(Cust)) + end + end +end + +function Game:continueGame(loaded, recovered) + if not loaded then return self:newGame() end + return self:restoreSave(loaded, recovered) +end + +function Game:makeTitleState() local factory = Screens.get(self, bootScreens(self).title or "TitleState") local title = factory.new(self, { onNewGame = function() - while self.stack:top() do self.stack:pop() end - if self.overworld then self.overworld.fadeOverlay = nil end - -- New Game keeps the standalone options.lua preferences - self.save = SaveData.newGame(self:bootConfig()) - -- no bucket carry-over: mod state from an abandoned session must - -- not leak into a fresh slot; mods seed via save.created instead - self:adoptSave(self.save) - ModRuntime.emit("save.created", { save = self.save }) - self:applyOptions(self.save.options) - self.stack:push(OverworldState, self.save.player.map, - self.save.player.x, self.save.player.y, - self.save.player.facing) - local newGameScreen = bootScreens(self).newGame - if newGameScreen == nil and not require("src.core.GameVersion").isGen2() then - newGameScreen = "OakSpeech" - end - if newGameScreen then - Screens.push(self, newGameScreen, function() end) - else - -- PRISM HAS NO OAK SPEECH, and that is why its character customisation - -- never appeared. Data.lua sets boot.screens.newGame = false for any - -- dataset with no OakText* -- Prism writes its own introduction in - -- engine/intro_menu.asm rather than replaying the professor's -- so - -- OakSpeech does not run, and the `customize_player` step lives INSIDE - -- OakSpeech. The step was correct and simply unreachable. - -- - -- IntroductionSpeech calls PlayerCustomization before it asks the - -- player's name, and the name prompt on this path comes from the - -- intro map's own script, so pushing it here puts it in the same - -- place: after New Game, before the map runs. - -- pcall, not a bare require: a dataset can want this screen without - -- the build shipping it (that is exactly how NEW GAME on Prism went - -- from "no customisation menu" to "module not found" the moment the - -- branch became reachable). A missing screen must cost the player - -- the customisation, not the save file they were starting. - local okCust, Cust = pcall(require, "src.ui.PrismCustomization") - if okCust and type(Cust) == "table" and Cust.available(self) then - Screens.push(self, "PrismCustomization", function() end) - elseif not okCust then - require("src.core.Logger").error( - "player customisation screen unavailable: %s", tostring(Cust)) - end - end + local facade=package.loaded['src.core.Game3'] + local run=function()return self:newGame()end + if facade and facade._bootAction then return facade._bootAction(self,{action='new_game'},run)end + return run() end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then - self:restoreSave(loaded, recovered) + local facade=package.loaded['src.core.Game3'] + local run=function()return self:continueGame(loaded,recovered)end + if facade and facade._bootAction then return facade._bootAction(self,{action='continue'},run)end + return run() end end, }) @@ -358,6 +373,11 @@ function Game:_step(dt) end -- AN EMPTY STACK IS NOT A GAME. if self:ensureStack() then return end + -- Source callbacks observe this native phase, after input promotion and + -- stack recovery. A wrapped runtime may hold it; no second loop is run. + local compatibility = package.loaded['src.core.game3.runtime'] + if compatibility and compatibility._fixedBoundary + and compatibility._fixedBoundary(self, dt) == false then return end local closeStep = FrameProfile.section(" logic: stack:update") self.stack:update(dt) closeStep() @@ -647,7 +667,8 @@ function Game:update(dt) -- assumed. if collectgarbage then local close = FrameProfile.section(" update: gc step") - collectgarbage("step", 1) + local facade=package.loaded['src.core.Game3'] + if not(facade and facade._stepGC and facade._stepGC(self))then collectgarbage("step", 1)end close() end end @@ -2017,4 +2038,23 @@ function Game:restoreSave(loaded, recovered) end end +-- Require nothing on an ordinary boot. Source Game3 wrappers receive their +-- distinct facade while the captured native method retains its own owner. +for _, name in ipairs({ 'update', 'draw', '_step', 'keypressed', 'keyreleased', + 'textinput', 'wheelmoved', 'touchpressed', 'touchmoved', 'touchreleased', + 'gamepadpressed', 'gamepadreleased', 'gamepadaxis', 'joystickpressed', + 'joystickreleased', 'joystickaxis', 'joystickhat', 'joystickremoved', + 'focus', 'visible', 'returnToTitle', 'returnToLauncher', 'restartWithMods', + 'writeOptions', 'applyOptions', 'writeSave', '_resolveLogicSpeed', + 'logicSpeed', 'speedLocked', 'zoomStep' }) do + local key, native = name, Game[name] + if type(native) == 'function' then + Game[key] = function(self, ...) + local facade = package.loaded['src.core.Game3'] + if facade and facade._dispatch then return facade._dispatch(key, self, native, ...) end + return native(self, ...) + end + end +end + return Game diff --git a/src/core/Game3.lua b/src/core/Game3.lua new file mode 100644 index 000000000..8b07ba2ed --- /dev/null +++ b/src/core/Game3.lua @@ -0,0 +1,269 @@ +-- A distinct facade for source wrappers. The existing Game still owns every +-- simulation, frame and IO operation; requiring this cannot bootstrap it. +local Native=require('src.compat.gen1.NativeSession') +local Views=require('src.compat.gen1.SessionView') +local Version=require('src.core.GameVersion') +local G={} +local entries=setmetatable({},{__mode='k'}) +local owners=setmetatable({},{__mode='k'}) +local scopes={} +local bases={} +local bootLeases=setmetatable({},{__mode='k'}) +local mouseBodies=setmetatable({},{__mode='k'}) +local callbackOwners={} +local callbackOwner +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function G._callbackOwner()return callbackOwner end +function G._withCallbackOwner(owner,fn,...) + local previous=callbackOwner;callbackOwner=owner + local result=pack(pcall(fn,...));callbackOwner=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +local function methodOwner(name) + local seams=package.loaded['src.mods.ModSeams'];local journal=seams and seams._journal or{} + for i=#journal,1,-1 do + local change=journal[i] + if change.tbl==G and change.key==name and change.after==G[name]then return change.owner end + end +end +local function entry(view) + local e=entries[view] + assert(e and not e.retired,'native Game3 facade retired or unbound') + return e +end +function G.native(value) + if value==nil then return package.loaded['src.core.Game']end + local e=entries[value] + if e then return not e.retired and e.owner or nil end + if type(value)=='table'and type(rawget(value,'game'))=='table'then return G.native(rawget(value,'game'))end + return value +end +local function session(e) + if e.session then + local record=Views.record(e.session) + if record and record:valid()then return e.session end + Views.release(e.session);e.session=nil + end + e.session=Views.new(e.owner) + return e.session +end +local function forward(view,name,...) + local e=entry(view);local c=scopes[name] + if c and c.view==view then + if c.called then + if c.failed then error(c.failure,0)end + if c.result then return unpack(c.result,1,c.result.n)end + return + end + c.called=true + local result=pack(pcall(c.native,e.owner,...)) + if not result[1]then c.failed=true;c.failure=result[2];error(result[2],0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + local method=e.owner[name] + if type(method)~='function'then return nil,'native method unavailable: '..name end + return method(e.owner,...) +end +for _,name in ipairs({'update','draw','keypressed','keyreleased','textinput','wheelmoved', + 'touchpressed','touchmoved','touchreleased','gamepadpressed','gamepadreleased','gamepadaxis', + 'joystickpressed','joystickreleased','joystickaxis','joystickhat','joystickremoved','focus','visible', + 'mousepressed','mousemoved','mousereleased','writeOptions','applyOptions','writeSave','returnToTitle', + 'logicSpeed','speedLocked','_resolveLogicSpeed','zoomStep', + 'returnToLauncher','restartWithMods'})do + local key=name;G[key]=function(view,...)return forward(view,key,...)end;bases[key]=G[key] +end +function G.fixedUpdate(view,...)return forward(view,'_step',...)end +bases.fixedUpdate=G.fixedUpdate +function G.reset(view) + local e=entry(view) + local runtime=package.loaded['src.core.game3.runtime'];if runtime and runtime.release then runtime.release()end + if e.session then Views.release(e.session);e.session=nil end + e.overrides={};e.overridden={};e.bootEpoch={} +end +bases.reset=G.reset +function G._handleBootAction(view,action) + local e=entry(view);local lease=type(action)=='table'and bootLeases[action] + if not lease or lease.view~=view or lease.epoch~=e.bootEpoch or action.action~=lease.kind then + return nil,'boot action belongs to a retired or absent native title callback' + end + if lease.failed then error(lease.failed,0)end + if lease.done then return unpack(lease.result,1,lease.result.n)end + if lease.owner.data~=lease.data or lease.owner.save~=lease.save or lease.owner.mods~=lease.loader then + return nil,'native title callback retired' + end + if not lease.done then + lease.done=true + local ok,why=xpcall(function()lease.result=pack(lease.callback())end,debug.traceback) + if not ok then lease.failed=why;error(why,0)end + end + return unpack(lease.result,1,lease.result.n) +end +bases._handleBootAction=G._handleBootAction +function G._bootAction(owner,action,callback) + if Version.generation()~=3 or G._handleBootAction==bases._handleBootAction then return callback()end + local view=G.forNative(owner);if not view then return callback()end + local e=entries[view] + bootLeases[action]={view=view,owner=owner,data=owner.data,save=owner.save,loader=owner.mods, + epoch=e.bootEpoch,kind=action.action,callback=callback} + return G._dispatch('_handleBootAction',owner,function()return bases._handleBootAction(view,action)end,action) +end +function G.stepGC()if collectgarbage then return collectgarbage('step',1)end end +function G._stepGC() + if Version.generation()~=3 then return false end + G.stepGC();return true +end +function G.forNative(value) + local native=G.native(value) + if value~=nil and native==nil then return nil,'native Game3 facade retired'end + local owner,why=Native.game(native);if not owner then return nil,why end + local existing=owners[owner] + if existing and not entries[existing].retired then + local e=entries[existing] + if e.loader==owner.mods or e.resetting then return existing end + G._notifyReset(owner);e.retired=true;if e.session then Views.release(e.session)end + end + local view={};local e={owner=owner,overrides={},overridden={},loader=owner.mods,bootEpoch={}};entries[view]=e;owners[owner]=view + setmetatable(view,{ + __index=function(_,key) + if G[key]~=nil then return G[key]end + if e.retired then return nil end + if e.overridden[key]then return e.overrides[key]end + if key=='mods'then return e.loader end + if key=='generation'then return 3 end + if key=='session'or key=='save'then return session(e)end + if key=='phase'then return Native.resolve(owner)and'field'or'boot'end + if key=='options'then return owner.save and owner.save.options end + if key=='data'then + if e.dataset~=owner.data then + e.dataset=owner.data + local dataset=e.dataset + e.dataView=setmetatable({}, {__index=function(_,field) + if owner.data~=dataset then return nil end + if field=='maps'then return require('src.core.game3.map').definitions(owner)end + return dataset and dataset[field] + end}) + end + return e.dataView + end + local value=owner[key] + if type(value)~='function'then return value end + end, + __newindex=function(t,key,value) + assert(not e.retired,'native Game3 facade retired') + if key=='data'or key=='generation'or key=='phase'then error('Game3 '..key..' belongs to its native owner',2)end + if key=='speedOverride'then + assert(value==nil or value==false or(type(value)=='number'and value>0 and value launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. + notifyCompatibilitySession() ModSeams.unwindAll() + retireCompatibilitySession() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1653,7 +1671,9 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + notifyCompatibilitySession() local undone = select(1, ModSeams.unwindAll()) + retireCompatibilitySession() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index f4d06a01c..b490e431d 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -105,9 +105,10 @@ end -- open a window. Returns a token for close(). Never throws. function ModSeams.open(owner) local ok, token = pcall(function() - local snap = {} + local snap, seen = {}, {} for _, entry in ipairs(watched()) do snap[#snap + 1] = { t = entry.t, label = entry.label, before = copyOf(entry.t) } + seen[entry.t] = true end -- an engine module required for the FIRST time inside this window is -- snapshotted the moment it loads, before the mod gets it back to wrap @@ -116,6 +117,17 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) + -- Importing the distinct GBA game/runtime namespace declares that + -- this owner's game-bearing callbacks use that facade. No manifest + -- rewrite or mod-id-specific conversion is needed. + if name == 'src.core.Game3' or name == 'src.core.game3.runtime' then + local facade = package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeMod then facade._observeMod(owner) end + if type(facade)=='table' and not seen[facade] and not fresh[facade] then + fresh[facade]=true + snap[#snap+1]={t=facade,label='src.core.Game3',before=copyOf(facade)} + end + end if had == nil and type(name) == "string" and name:match("^src%.") and type(result) == "table" and not fresh[result] then fresh[result] = true diff --git a/tests/engine/native_session_callbacks.lua b/tests/engine/native_session_callbacks.lua new file mode 100644 index 000000000..3755dd50b --- /dev/null +++ b/tests/engine/native_session_callbacks.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/callback-check.lua') diff --git a/tests/engine/native_session_dispatch.lua b/tests/engine/native_session_dispatch.lua new file mode 100644 index 000000000..f6bd3deb4 --- /dev/null +++ b/tests/engine/native_session_dispatch.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/dispatch-check.lua') diff --git a/tests/engine/native_session_runtime.lua b/tests/engine/native_session_runtime.lua new file mode 100644 index 000000000..7b2a23926 --- /dev/null +++ b/tests/engine/native_session_runtime.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/check.lua') diff --git a/tools/session-runtime/callback-check.lua b/tools/session-runtime/callback-check.lua new file mode 100644 index 000000000..337a974b3 --- /dev/null +++ b/tools/session-runtime/callback-check.lua @@ -0,0 +1,119 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Json=require('src.link.Json') +local F=require('tools.session-runtime.fixture') +local f=F.new('emerald');local native=f.game +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +assert(api==2 or api==3,'session callback probe requires API 2 or 3') +T.eq(package.loaded['src.core.Game3'],nil,'first runtime import also exercises automatic facade journaling') +local files={} +local function mod(id,priority,body) + files['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=api, + entry='main.lua',games={'gen3'},priority=priority,permissions={'engine_internals'}}) + files['mods/'..id..'/main.lua']=body +end +mod('bad_source',200,[[ + local mod=... + require('src.core.game3.runtime') + require('src.core.Game3').update=function()error('rolled back source wrapper')end + error('intentional source entry failure') +]]) +mod('source_game',100,[[ + local mod=... + local R=require('src.core.game3.runtime') + local G=require('src.core.Game3') + local base=G.update;mod.exports.base=base;mod.exports.frames=0 + G.update=function(g,dt)mod.exports.frames=mod.exports.frames+1;return base(g,dt)end + mod.events:on('game.ready',function(p) + mod.exports.payload=p;mod.exports.game=p.game;mod.exports.phase=p.phase + R.defer(function() + R.defer(function()error('nested source deferred failure')end) + end) + end) + mod.events:on('save.created',function(p)mod.exports.save=p.save end) + mod.events:on('save.loading',function(p)mod.exports.raw=p.raw end) + mod.events:on('mod.native_peer.probe',function(p)mod.exports.custom=p end) + mod.hooks:wrap('core.update',function(nextFn,game,dt,...) + assert(game==mod.exports.game,'source hook game differs from source ready handle') + local value,a,b=nextFn(game,dt,...) + assert(value==game and a==nil and b=='native result','source next did not project native results') + return value,a,b + end,100) + mod.hooks:wrap('input.step',function(nextFn,game,dt) + nextFn(game,dt) + error('intentional source post-next failure') + end,100) +]]) +mod('native_peer',0,[[ + local mod=... + mod.events:on('game.ready',function(p)mod.exports.payload=p;mod.exports.game=p.game end) + mod.events:on('save.created',function(p)mod.exports.save=p.save end) + mod.hooks:wrap('core.update',function(nextFn,game,...) + mod.exports.hookGame=game + return nextFn(game,...) + end,0) +]]) +local run=T.sdk.loadMods({'mods/bad_source','mods/source_game','mods/native_peer'}, + {data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) +T.eq(#run.errors,1,'intentional failed source mod is the only entry error') +T.check(run.loader.exports.source_game~=nil) +local G=require('src.core.Game3');local R=require('src.core.game3.runtime') +T.eq(G._usesCallbacks('bad_source'),false,'rollback retires namespace callback selection') +T.eq(G._usesCallbacks('source_game'),true);T.eq(G._usesCallbacks('native_peer'),false) +native.mods=run.loader;run.loader.game=native +local src,peer=run.loader.exports.source_game,run.loader.exports.native_peer +local payload=setmetatable({game=native},{__index=native}) +run.loader.events:emit('game.ready',payload) +local view=G.forNative(native) +T.eq(src.game,view);T.eq(src.phase,'field');T.eq(src.payload.input,native.input) +T.eq(peer.game,native);T.eq(peer.payload,payload,'native peer receives the exact original event object') +T.eq(payload.game,native);T.eq(rawget(payload,'phase'),nil,'projection cannot rewrite a shared payload') +local copies={save=native.save};run.loader.events:emit('save.created',copies) +T.eq(src.save,view.session);T.eq(peer.save,native.save);T.eq(copies.save,native.save) +local loaded={player={map='TEST',name='LOADED',id=51},playTime=12,options={}} +run.loader.events:emit('save.loading',{raw=loaded}) +T.eq(src.raw.name,'LOADED');src.raw.money=7;T.eq(loaded.money,7,'typed loading view targets only its supplied raw save') +T.eq(native.save.money,300) +local custom={game=native};run.loader.events:emit('mod.native_peer.probe',custom) +T.eq(src.custom,custom,'custom event payload contracts remain unchanged') +local resource={gpu='not a game'};local vanilla=0 +local value,a,b=run.loader.hooks:call('core.update',function(game,dt,empty,texture) + T.eq(game,native);T.eq(dt,.125);T.eq(empty,nil);T.eq(texture,resource) + T.eq(G._callbackOwner(),nil,'native downstream code does not inherit a source callback owner') + vanilla=vanilla+1;return game,nil,'native result' +end,native,.125,nil,resource) +T.eq(value,native);T.eq(a,nil);T.eq(b,'native result');T.eq(vanilla,1);T.eq(peer.hookGame,native) +local calls=0 +value,a,b=run.loader.hooks:call('input.step',function(game) + T.eq(game,native);calls=calls+1;return 41,nil,9 +end,native,1/60) +T.eq(value,41);T.eq(a,nil);T.eq(b,9);T.eq(calls,1,'a source post-next error cannot retry the native body') +T.eq(G._callbackOwner(),nil,'callback failures restore owner context') +T.eq(G._dispatch('update',native,function(owner,dt)T.eq(owner,native);return dt end,.25),.25) +T.eq(src.frames,1,'a failed first runtime import did not leave its facade wrapper installed') +-- The real Game:_step boundary preserves callback attribution across batches. +local Touch=require('src.core.TouchControls');local poll=Touch.pollTouches;Touch.pollTouches=function()end +local errors=#run.errors +native:_step(1/60);native:_step(1/60) +T.eq(#run.errors,errors+1);T.check(run.errors[#run.errors]:find('source_game:',1,true)~=nil) +T.check(run.errors[#run.errors]:find('nested source deferred failure',1,true)~=nil) +-- Native desktop touch synthesis must not become a second source touch. +local baseTouch,baseMouse=G.touchpressed,G.mousepressed +local visible,press=Touch.visible,Touch.touchpressed +local nativeTouches,sourceTouches,sourceMouse=0,0,0 +Touch.visible=function()return false end +Touch.touchpressed=function()nativeTouches=nativeTouches+1 end +G.touchpressed=function(game,...)sourceTouches=sourceTouches+1;return baseTouch(game,...)end +G.mousepressed=function(game,...)sourceMouse=sourceMouse+1;return baseMouse(game,...)end +G._pointer('mousepressed',native,function(x,y)native:touchpressed('mouse',x,y)end,10,11,1,false) +T.eq(nativeTouches,1);T.eq(sourceMouse,1);T.eq(sourceTouches,0) +native:touchpressed(42,10,11);T.eq(nativeTouches,2);T.eq(sourceTouches,1) +T.raises(function()G._pointer('mousepressed',native,function()error('intentional native pointer failure')end,1,2,1,false)end,'intentional native pointer failure') +native:touchpressed('mouse',10,11);T.eq(sourceTouches,2,'pointer failure cannot leak synthetic-touch suppression') +G.touchpressed,G.mousepressed=baseTouch,baseMouse +Touch.visible,Touch.touchpressed,Touch.pollTouches=visible,press,poll +require('src.mods.Loader').teardown() +T.eq(G._usesCallbacks('source_game'),false);T.eq(G.update,src.base) +T.eq(R.isActive(),false) +run.release() +T.finish('source game callbacks with a native peer through SDK API '..api) diff --git a/tools/session-runtime/check.lua b/tools/session-runtime/check.lua new file mode 100644 index 000000000..49986cc2c --- /dev/null +++ b/tools/session-runtime/check.lua @@ -0,0 +1,117 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local R=require('src.core.game3.runtime') +local G=require('src.core.Game3') +local NativeSession=require('src.compat.gen1.NativeSession') +local Views=require('src.compat.gen1.SessionView') +local NativeRuntime=require('src.mods.Runtime') +T.eq(package.loaded['src.core.Game'],nil,'queries never bootstrap the native Game') +T.eq(R.isActive(),false);T.eq(R.getSession(),nil);T.eq(R.defer(function()end),false) +T.eq(R.update(),nil);T.eq(R.drainDeferred(),0) +local F=require('tools.session-runtime.fixture') +local Touch=require('src.core.TouchControls');local poll=Touch.pollTouches;Touch.pollTouches=function()end +local baseUpdate,baseFixed,baseRuntime,baseReset,baseDraw=G.update,G.fixedUpdate,R.update,G.reset,G.draw +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + R.release();G.release() + local f=F.new(edition);local native=f.game;local view=assert(G.forNative(native)) + T.check(G~=native and view~=native,'source class/instance are distinct from the native Game') + T.eq(G.forNative(native),view);T.eq(G.native(view),native);T.eq(NativeSession.game(view),native) + T.eq(view.input,native.input);T.eq(view.generation,3);T.eq(view.phase,'field') + view.speedOverride=3;T.eq(native.speedOverride,3) + T.raises(function()view.speedOverride=0/0 end,'invalid native speed override') + view.speedOverride=nil;T.eq(native.speedOverride,nil) + local s=assert(R.getSession());T.eq(s,view.session);T.eq(s,view.save) + T.eq(s.map,f.canonical);T.eq(s.x,4);T.eq(s.y,4);T.eq(s.name,'NATIVE') + T.eq(s.trainerId,37);T.eq(s.gender,0);T.eq(s.money,300);T.eq(s.coins,4) + local before=native.save.playTime + for _=1,10 do + local clock=R.pumpRtc(native,10) + T.eq(clock.hours,1);T.eq(clock.minutes,1);T.eq(clock.seconds,1);T.eq(R._playTimeAcc,.25) + T.eq(R.tickVblank(s),0,'repeated clock queries cannot tick another owner') + end + T.eq(native.save.playTime,before,'clock projection never rewrites the native save') + s.money=91;s.name='SOURCE';s.gender=1 + T.eq(native.save.money,91);T.eq(native.save.player.name,'SOURCE');T.eq(native.save.player.gender,1) + T.raises(function()s.money=0/0 end,'invalid session');T.raises(function()s.x=99 end,'native command owner') + T.eq(f.player.cellX,4,'source session writes cannot teleport the native actor') + s.presentation={foo=1};T.eq(native.save.presentation,nil,'presentation extensions stay detached') + T.eq(s.party,nil,'a differently shaped native party is never lent as a source party') + native.save.playTime={hours=2,mins=2,secs=3,vblanks=15} + T.eq(Views.seconds(native.save),7323.25);T.eq(R._playTimeAcc,.25) + T.eq(type(native.save.playTime),'table','old save clocks are observed without normalization writes') + native.save.playTime=before + -- Preview leases shadow even nil, without touching the real stack/save. + local nativeStack=native.stack + view.stack=nil;T.eq(view.stack,nil);T.eq(native.stack,nativeStack) + view.stack=nativeStack + local scratch={name='PREVIEW'};R.session=scratch;T.eq(R.getSession(),scratch) + R.session=s;T.eq(R.getSession(),s);T.eq(native.save.player.name,'SOURCE') + local seq={} + T.eq(R.defer(function() + seq[#seq+1]='first';T.eq(R.drainDeferred(),0,'nested drains cannot run the next batch') + R.defer(function()seq[#seq+1]='next'end) + end),true) + R.defer(function()seq[#seq+1]='second'end) + T.eq(R.drainDeferred(),0,'only a native fixed step can drain callbacks') + local clock=native.save.playTime;native:_step(1/60) + T.same(seq,{'first','second'});T.eq(f.nativeUpdates,1);T.eq(f.inputSteps,1) + T.eq(native.save.playTime,clock+1/60);T.eq(R.tickVblank(),1) + native:_step(1/60);T.same(seq,{'first','second','next'});T.eq(R.tickVblank(),2) + NativeRuntime.errors={};NativeRuntime.currentMod='runtime_probe' + R.defer(function()error('intentional deferred failure')end) + R.defer(function()seq[#seq+1]='after failure'end) + NativeRuntime.currentMod=nil + native:_step(1/60) + T.eq(seq[#seq],'after failure');T.check(NativeRuntime.errors[1]:find('runtime_probe:',1,true)~=nil) + -- A callback may replace the save. No later callback from its old cohort runs. + R.defer(function()native.save={player=native.save.player,playTime=7,options={}}end) + R.defer(function()error('old save callback escaped retirement')end) + local old=s;native:_step(1/60) + T.eq(native.save.playTime,7,'a retired callback phase cannot tick the replacement save') + T.eq(old.map,nil);T.raises(function()old.money=200 end,'retired') + T.check(R.getSession()~=old,'replacement save receives a fresh source session view') + -- Runtime wrappers can hold the native field, then resume its single owner. + local updates=f.nativeUpdates;clock=native.save.playTime;local serial=R.tickVblank() + R.update=function()end;native:_step(1/60) + T.eq(f.nativeUpdates,updates);T.eq(native.save.playTime,clock);T.eq(R.tickVblank(),serial) + R.update=baseRuntime;native:_step(1/60);T.eq(f.nativeUpdates,updates+1) + -- The actual native fixed-step entry invokes its source wrapper. Calling + -- the captured base twice cannot double input, player, script or clock work. + G.fixedUpdate=function(game,dt)T.eq(game,view);baseFixed(game,dt);return baseFixed(game,dt)end + updates=f.nativeUpdates;clock=native.save.playTime;local steps=f.inputSteps + native:_step(1/60) + T.eq(f.nativeUpdates,updates+1);T.eq(f.inputSteps,steps+1);T.eq(native.save.playTime,clock+1/60) + G.fixedUpdate=baseFixed + -- Real native modal ownership, including the start/save focus exceptions. + native.stack:push({screenId='StartMenu',update=function()end}) + T.eq(R.uiBusy(),true);T.eq(R.fieldScreenOpen(),false) + native.stack:push({screenId='SummaryMenu',update=function()end}) + T.eq(R.fieldScreenOpen(),true) + R.setUi({preview=true});T.eq(R.uiBusy(),true);R.setUi(nil) + native.stack:pop();native.stack:pop();T.eq(R.uiBusy(),false) + local menus=0;local lighting=package.loaded['src.core.game3.league_lighting'] + package.loaded['src.core.game3.league_lighting']={stop=function()menus=menus+1 end} + R.noteFieldFocus(true);R.noteFieldFocus(true);T.eq(menus,1) + R.noteFieldFocus(false);package.loaded['src.core.game3.league_lighting']=lighting + -- Holding a batch across source preview leases preserves its attribution. + R.defer(function()seq[#seq+1]='restored lease'end) + local deferred=R._deferred;R._deferred={};native:_step(1/60) + T.check(seq[#seq]~='restored lease');R._deferred=deferred;native:_step(1/60) + T.eq(seq[#seq],'restored lease') + R.defer(function()error('exit callback should be cancelled')end) + local calls=0 + require('src.mods.ModSeams').run('runtime_probe',function() + G.reset=function(game)calls=calls+1;T.eq(game.mods,native.mods);return baseReset(game)end + G.update=function(...)return baseUpdate(...)end + end) + require('src.mods.Loader').teardown() + T.eq(calls,1,'native teardown invokes source cleanup before wrappers unwind') + T.eq(G.update,baseUpdate);T.eq(G.reset,baseReset);T.eq(R.isActive(),false) + T.eq(NativeSession.game(view),nil,'retired views cannot fall through to a new native owner') + T.eq(G.forNative(view),nil) + T.raises(function()baseFixed(view,1/60)end,'retired') +end +Touch.pollTouches=poll +NativeRuntime.errors=nil +T.finish('native source session lifecycle and fixed-step runtime') diff --git a/tools/session-runtime/dispatch-check.lua b/tools/session-runtime/dispatch-check.lua new file mode 100644 index 000000000..c6f231047 --- /dev/null +++ b/tools/session-runtime/dispatch-check.lua @@ -0,0 +1,112 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local G=require('src.core.Game3') +local R=require('src.core.game3.runtime') +local F=require('tools.session-runtime.fixture') +local Fixed=require('src.core.FixedStep') +local Touch=require('src.core.TouchControls');Touch.pollTouches=function()end +local baseUpdate,baseDraw,baseBoot,baseReset,baseFixed=G.update,G.draw,G._handleBootAction,G.reset,G.fixedUpdate +local baseGC=G.stepGC +local Music=require('src.core.Music');local music=Music.update;local audio=0 +Music.update=function()audio=audio+1 end +local Tilt=require('src.render.Tilt');local tilt=Tilt.update;Tilt.update=function()end +local Pipes=require('src.render.Pipelines');local pipes=Pipes.update;Pipes.update=function()end +local discord=package.loaded['src.core.DiscordPresence'];package.loaded['src.core.DiscordPresence']={update=function()end} +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + R.release();G.release();local f=F.new(edition);local native=f.game;local view=G.forNative(native) + local stick,speed=native.updateCameraStick,native.logicSpeed + native.updateCameraStick=function()end;native.logicSpeed=function()return 1 end + Fixed:init(function(dt)native:step(dt)end) + local gc,wrapped=0,0 + G.stepGC=function()gc=gc+1 end + G.update=function(game,dt) + wrapped=wrapped+1;T.eq(game,view,'real native frame enters the source facade') + baseUpdate(game,dt);baseUpdate(game,dt) + end + local clock=native.save.playTime;local oldAudio=audio + native:update(1/60) + T.eq(wrapped,1);T.eq(f.nativeUpdates,1);T.eq(f.inputSteps,1) + T.eq(native.save.playTime,clock+1/60);T.eq(audio,oldAudio+1,'audio retains its native real-time owner') + T.eq(gc,1,'source collector seam replaces the single native GC step') + G.update=function()wrapped=wrapped+1 end + native:update(1/60) + T.eq(wrapped,2);T.eq(f.nativeUpdates,1);T.eq(audio,oldAudio+1);T.eq(gc,1,'a held source frame never runs a hidden second base') + G.update=baseUpdate + native.updateCameraStick,native.logicSpeed=stick,speed + -- Exact forwarding of nil-bearing results through captured native methods. + local draws=0 + G.draw=function(game,...)T.eq(game,view);return baseDraw(game,...)end + local a,b,c=G._dispatch('draw',native,function(owner,n) + draws=draws+1;T.eq(owner,native);return n,nil,n+1 + end,17) + T.eq(a,17);T.eq(b,nil);T.eq(c,18);T.eq(draws,1) + G.draw=function(game) + baseDraw(game,4);return baseDraw(game,99) + end + a,b,c=G._dispatch('draw',native,function(_,n)draws=draws+1;return n,nil,n+1 end) + T.eq(a,4);T.eq(b,nil);T.eq(c,5);T.eq(draws,2,'repeated base calls retain the first native results') + G.draw=function()error('intentional facade failure')end + T.raises(function()G._dispatch('draw',native,function()error('unexpected native draw')end)end,'intentional facade failure') + G.draw=function(game)return baseDraw(game,3)end + T.eq(G._dispatch('draw',native,function(_,n)return n end),3,'failure cannot leak an active dispatch scope') + G.draw=baseDraw + -- A source wrapper may catch an error for cleanup, but cannot hide or + -- retry a failing native body and report the frame as successful. + local attempts=0 + G.draw=function(game) + T.eq(pcall(baseDraw,game),false) + T.eq(pcall(baseDraw,game),false) + return 'caught' + end + T.raises(function()G._dispatch('draw',native,function()attempts=attempts+1;error('intentional native body failure')end)end,'intentional native body failure') + T.eq(attempts,1);G.draw=baseDraw + -- Exercise callbacks created by the actual native title factory. Only the + -- screen constructor and disk reader are replaced with synthetic fixtures. + local Screens=require('src.ui.Screens');local get=Screens.get + local callbacks + Screens.get=function()return {new=function(game,cb)T.eq(game,native);callbacks=cb;return {}end}end + native:makeTitleState();Screens.get=get + local new,continue=native.newGame,native.continueGame + local boots=0;local loaded={synthetic=true};local pending + native.newGame=function(owner)T.eq(owner,native);boots=boots+1;return 'new',nil,7 end + native.continueGame=function(owner,value,recovered) + T.eq(owner,native);T.eq(value,loaded);T.eq(recovered,true);boots=boots+1;return 'continued',nil,9 + end + G._handleBootAction=function(game,action)pending={game,action}end + callbacks.onNewGame();T.eq(boots,0,'source intro can defer the actual native new-game action') + a,b,c=baseBoot(pending[1],pending[2]);T.eq(a,'new');T.eq(b,nil);T.eq(c,7);T.eq(boots,1) + baseBoot(pending[1],pending[2]);T.eq(boots,1,'one title action can be adopted only once') + local Save=require('src.core.SaveData');local load=Save.load + Save.load=function()return loaded,true end + callbacks.onContinue();T.eq(boots,1) + a,b,c=baseBoot(pending[1],pending[2]);T.eq(a,'continued');T.eq(c,9);T.eq(boots,2) + callbacks.onContinue();local stale=pending + native.save={player=native.save.player,playTime=0,options={}} + T.eq(baseBoot(stale[1],stale[2]),nil);T.eq(boots,2,'a save change retires an unconsumed intro action') + Save.load=load;native.newGame,native.continueGame=new,continue + G._handleBootAction=baseBoot;G.stepGC=baseGC + -- A new mod loader on the same native Game cannot keep the old facade, + -- deferred queue or source cleanup owner alive. + local oldLoader=native.mods;local reset=0 + G.reset=function(game)reset=reset+1;T.eq(game.mods,oldLoader);return baseReset(game)end + native.mods={exports={another=true}} + local replacement=G.forNative(native) + T.check(replacement~=view);T.eq(replacement.mods,native.mods);T.eq(reset,1) + T.eq(G.native(view),nil) + G.reset=baseReset +end +G.stepGC=baseGC;G.update=baseUpdate;G.draw=baseDraw +-- Importing and wrapping the source facade cannot redirect other generations. +for _,edition in ipairs({'gold','silver','crystal'})do + R.release();G.release();local f=F.new(edition);local calls=0 + G.fixedUpdate=function()calls=calls+1;error('source fixed step reached a native generation')end + local clock=f.game.save.playTime + f.game:_step(1/60) + T.eq(calls,0);T.eq(f.nativeUpdates,1);T.eq(f.game.save.playTime,clock+1/60) + T.eq(G.forNative(f.game),nil);T.eq(R.isActive(),false) +end +G.fixedUpdate=baseFixed +Music.update=music;Tilt.update=tilt;Pipes.update=pipes +package.loaded['src.core.DiscordPresence']=discord +T.finish('native Game3 frame dispatch, delayed title adoption and generation isolation') diff --git a/tools/session-runtime/fixture.lua b/tools/session-runtime/fixture.lua new file mode 100644 index 000000000..b9474fe8c --- /dev/null +++ b/tools/session-runtime/fixture.lua @@ -0,0 +1,20 @@ +-- Real native Game/StateStack/player phase with synthetic session metadata. +local F={} +function F.new(edition) + local f=require('tools.player-runtime.fixture').new(edition) + local Identity=require('src.compat.gen1.MapIdentity') + local prefix=({firered='FR',leafgreen='FR',emerald='EM',ruby='RU',sapphire='SA'})[edition] + local canonical=prefix and(prefix..((edition=='firered'or edition=='leafgreen')and'_PALLET_TOWN'or'_LITTLEROOT_TOWN')) + local _,group,number=Identity.lookup(edition,canonical or'') + f.canonical=canonical;f.map.def.group,f.map.def.number=group,number + local g=f.game;g.mods={exports={runtime_probe={}}} + g.save.player={map='TEST',x=4,y=4,facing='right',name='NATIVE',rival='RIVAL',id=37,gender=0} + g.save.playTime=3661.25;g.save.options={};g.save.modData={};g.save.money=300;g.save.coins=4 + g.stack=setmetatable({states={f.world}},{__index=require('src.core.StateStack')}) + f.nativeUpdates,f.inputSteps=0,0 + function f.world:update(dt)f.nativeUpdates=f.nativeUpdates+1;self:stepBody(false)end + function f.input:step()f.inputSteps=f.inputSteps+1 end + g.audioAccum=0 + return f +end +return F From d24427c84e07a4c55a0da113f6512426068977a5 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 22:00:40 +0100 Subject: [PATCH 2/4] Decline unbound source construction without borrowing the current game --- docs/native-session-runtime.md | 4 +++- src/core/Game3.lua | 5 ++++- tools/session-runtime/check.lua | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/native-session-runtime.md b/docs/native-session-runtime.md index 4d3b9b055..bcf7632eb 100644 --- a/docs/native-session-runtime.md +++ b/docs/native-session-runtime.md @@ -2,7 +2,9 @@ `src.core.Game3` is a distinct class with per-native-owner facade instances. It does not alias the central `Game`, instantiate a second engine or bootstrap -Game when imported. Explicit owner resolution rejects retired handles. A source +Game when imported. Standalone construction without an explicit native owner +declines instead of silently sharing another game; full source construction +remains separate. Explicit owner resolution rejects retired handles. A source session is scoped to its native save, dataset, map table and edition; replacing one retires its observations and queued callbacks. diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 8b07ba2ed..62ce047cf 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -167,7 +167,10 @@ function G.forNative(value) }) return view end -function G.new(owner)return G.forNative(owner)end +function G.new(owner) + if owner==nil then return nil,'native game creation belongs to the host'end + return G.forNative(owner) +end function G._dispatch(name,owner,native,...) local sourceName=name=='_step'and'fixedUpdate'or name if mouseBodies[owner]and(name=='touchpressed'or name=='touchmoved'or name=='touchreleased') diff --git a/tools/session-runtime/check.lua b/tools/session-runtime/check.lua index 49986cc2c..97887c5e8 100644 --- a/tools/session-runtime/check.lua +++ b/tools/session-runtime/check.lua @@ -9,6 +9,7 @@ local NativeRuntime=require('src.mods.Runtime') T.eq(package.loaded['src.core.Game'],nil,'queries never bootstrap the native Game') T.eq(R.isActive(),false);T.eq(R.getSession(),nil);T.eq(R.defer(function()end),false) T.eq(R.update(),nil);T.eq(R.drainDeferred(),0) +T.eq(G.new(),nil,'a source constructor cannot silently share another live game') local F=require('tools.session-runtime.fixture') local Touch=require('src.core.TouchControls');local poll=Touch.pollTouches;Touch.pollTouches=function()end local baseUpdate,baseFixed,baseRuntime,baseReset,baseDraw=G.update,G.fixedUpdate,R.update,G.reset,G.draw From e999f0adad8903a8c32f35ef785ba6f7bb9b0889 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Mon, 26 Oct 2026 00:30:46 +0100 Subject: [PATCH 3/4] Bridge numeric script state and iterable session schemas --- docs/native-script-state.md | 55 +++++++++++ src/compat/gen1/ScriptMap.lua | 57 +++++++++++ src/compat/gen1/ScriptStore.lua | 125 +++++++++++++++++++++++++ src/compat/gen1/SessionView.lua | 38 +++++++- src/compat/gen1/TableIteration.lua | 34 +++++++ src/mods/Loader.lua | 5 +- src/mods/ModSeams.lua | 3 + tests/engine/native_script_session.lua | 1 + tests/engine/native_script_store.lua | 1 + tools/script-stores/check.lua | 94 +++++++++++++++++++ tools/script-stores/session-check.lua | 93 ++++++++++++++++++ 11 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 docs/native-script-state.md create mode 100644 src/compat/gen1/ScriptMap.lua create mode 100644 src/compat/gen1/ScriptStore.lua create mode 100644 src/compat/gen1/TableIteration.lua create mode 100644 tests/engine/native_script_session.lua create mode 100644 tests/engine/native_script_store.lua create mode 100644 tools/script-stores/check.lua create mode 100644 tools/script-stores/session-check.lua diff --git a/docs/native-script-state.md b/docs/native-script-state.md new file mode 100644 index 000000000..067222f55 --- /dev/null +++ b/docs/native-script-state.md @@ -0,0 +1,55 @@ +# Native numeric script state and session schemas + +The source-facing session now exposes numeric `flags` and `vars` maps. These +are typed views over the existing native save, not a second script store. +Reading a missing value does not allocate native state. Writes run the native +flag commands and variable writer, preserving flag events, live visibility, +edition-specific league aliases and derived object-graphics slots. Context +variables stay outside persistent session maps and snapshots; the special +range ends at 0x8014 in Kanto and 0x8015 in Hoenn. + +Whole-map replacements validate every key/value and conflicting numeric/string +alias before any command runs. They preserve native-only named flags and +context variables. Session enumeration includes its existing scalar schema, +numeric state, detached playtime and presentation fields. Recursive copy code +can obtain an isolated flags/vars snapshot without borrowing native tables. +The session retires its maps when its game data, maps, save, edition or mod +loader changes, and on explicit release. + +LuaJIT's Lua-5.1 `pairs` and `next` do not consult `__pairs`. `TableIteration` +installs dispatchers before the mod mutation journals open. Only registered +views opt in; ordinary inputs go to the original host functions, including +the host's own treatment of metatables. `pairs(view)` uses an owned snapshot. +Numeric maps implement `next(view, key)` through a typed native cursor, without +quadratic snapshot reconstruction or lending the native table. Deleting the +current variable while iterating keeps the underlying native cursor semantics. +Registry callbacks do not capture their own weak key. + +Mutation-journal iteration is captured independently of later global wrappers. +Failed entry wrappers unwind before rollback iterates registries or calls +optional resource services. Native mods may wrap the iteration functions in +the usual forwarding style; rollback and teardown retain the infrastructure +and take back the mod's wrapper. + +## Validation + +``` +luajit tools/script-stores/check.lua +luajit tools/script-stores/session-check.lua +``` + +The backend suite exercises all five editions through the real native command +implementations, compiler and runner. The session suite verifies live reads, +numeric writes, replacements, aliases, special-variable exclusion, ordinary +table semantics, weak-key lifetime, recursive copies, direct cursor mutation +and real SDK entry rollback with a native iteration-wrapper peer. + +## Remaining scripting integration + +This adds session schemas used by the forthcoming scripting-space bridge. +It does not supply `src.core.game3.scripting.space`, a replacement VM, symbol +catalogues, complete party/bag/storage schemas, detached preview construction +or all source event/field/battle/UI/audio services. The unchanged original +package still stops at the scripting-space import. Full renderer, five-edition +gameplay, native-mod coexistence and performance acceptance remain open. +QA uses isolated identities, preserves player data and keeps 8 GiB free. diff --git a/src/compat/gen1/ScriptMap.lua b/src/compat/gen1/ScriptMap.lua new file mode 100644 index 000000000..84c14cd3c --- /dev/null +++ b/src/compat/gen1/ScriptMap.lua @@ -0,0 +1,57 @@ +-- Numeric source-facing tables, with synchronous native command writes. +-- Iteration uses owned values; neither the save nor a script context is lent. +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local function key(value) + value=tonumber(value) + if type(value)=='number'and value==value and value%1==0 and value>=0 and value<=65535 then return value end +end +function M.new(backend,kind) + assert(kind=='flags'or kind=='vars','invalid script map kind') + local state={backend=backend,kind=kind};local view={} + local function readable(k) + return k and(kind=='flags'or k>=0x4000 and not backend:isSpecial(k)) + end + setmetatable(view,{ + __index=function(_,k) + k=key(k);if not readable(k)then return nil end + if kind=='flags'then return backend:flagValue(k)end + return backend:varValue(k) + end, + __newindex=function(_,k,value) + k=key(k);assert(readable(k),'invalid persistent script map key') + if kind=='flags'then if value==nil then value=false end;backend:setFlag(k,value) + elseif value==nil then backend:clearVar(k) + else backend:setVar(k,value)end + end, + }) + return Iteration.register(view,function(s)return s.backend:snapshot()[s.kind]end,state,function(s,k) + if s.kind=='flags'then return s.backend:nextFlag(k)end + return s.backend:nextVar(k) + end) +end +function M.replace(backend,kind,value) + assert(kind=='flags'or kind=='vars','invalid script map kind') + assert(type(value)=='table','invalid persistent script map') + -- Validate and normalize the complete replacement before any command can + -- mutate native state. No special variables belong to a persistent map. + local normalized={} + for k,v in pairs(value)do + local n=key(k) + assert(n and(kind=='flags'or n>=0x4000 and not backend:isSpecial(n)),'invalid persistent script map key') + if kind=='flags'then assert(type(v)=='boolean','invalid script flag value') + else assert(type(v)=='number'and v==v and v%1==0 and math.abs(v)<=9007199254740991,'invalid script variable value')end + assert(normalized[n]==nil or normalized[n]==v,'conflicting script map aliases') + normalized[n]=v + end + local current=backend:snapshot()[kind] + for k in pairs(current)do + if normalized[k]==nil then + if kind=='flags'then backend:setFlag(k,false)else backend:clearVar(k)end + end + end + for k,v in pairs(normalized)do + if kind=='flags'then backend:setFlag(k,v)else backend:setVar(k,v)end + end +end +return M diff --git a/src/compat/gen1/ScriptStore.lua b/src/compat/gen1/ScriptStore.lua new file mode 100644 index 000000000..f4be99b1a --- /dev/null +++ b/src/compat/gen1/ScriptStore.lua @@ -0,0 +1,125 @@ +-- Numeric script state over one native save. Writes use the native commands +-- so visibility, flag events, league aliases and graphics slots stay coherent. +-- This is a backend for the scripting facade, not a second store or VM. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local rawNext=next +local function integer(n,lo,hi) + return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi +end +local function id(value,lo) + value=tonumber(value) + assert(integer(value,lo or 0,65535),'invalid numeric script id') + return value +end +local function special(key,edition) + local high=(edition=='firered'or edition=='leafgreen')and 0x8014 or 0x8015 + return key>=0x8000 and key<=high +end +function M.new(game) + local owner,why=Native.game(game);if not owner then return nil,why end + local save,data,mods,edition=owner.save,owner.data,owner.mods,Version.get() + if type(save)~='table'or type(data)~='table'then return nil,'native script save unavailable'end + local maps=data.maps;local released=false;local store={} + local function valid() + return not released and Version.get()==edition and owner.save==save + and owner.data==data and owner.mods==mods and data.maps==maps + end + local function current() + assert(valid(),'native script store retired') + end + local function context() + current() + -- Resolve at the command boundary, not when the adapter was created: + -- a connection or warp may have installed another native world. + local _,world=Native.resolve(owner) + return {game=owner,save=save,overworld=world} + end + function store:valid()return valid()end + function store:release()released=true end + function store:isSpecial(key)current();return special(id(key),edition)end + function store:flagValue(key) + current();key=id(key) + if type(save.flags)~='table'then return nil end + local value=save.flags[('FLAG_G3_%04X'):format(key)] + if type(value)=='boolean'then return value end + end + function store:getFlag(key) + current();key=id(key) + return type(save.flags)=='table'and save.flags[('FLAG_G3_%04X'):format(key)]==true or false + end + function store:setFlag(key,on) + current();key=id(key);assert(type(on)=='boolean','invalid script flag value') + assert(type(save.flags)=='table','native flag table unavailable') + require('src.script.Gen3Commands') + local commands=require('src.script.Commands') + local name=('FLAG_G3_%04X'):format(key) + if on then commands.g3_set_flag(context(),name) + else commands.g3_clear_flag(context(),name)end + return true + end + function store:getVar(key) + current();key=id(key) + if key<0x4000 then return key end + local value=type(save.gen3Vars)=='table'and save.gen3Vars[key] + return integer(value,-9007199254740991,9007199254740991)and value or 0 + end + function store:varValue(key) + current();key=id(key,0x4000) + local value=type(save.gen3Vars)=='table'and save.gen3Vars[key] + if integer(value,-9007199254740991,9007199254740991)then return value end + end + function store:setVar(key,value) + current();key=id(key,0x4000) + assert(integer(value,-9007199254740991,9007199254740991),'invalid script variable value') + require('src.script.Gen3Commands').setVar(save,key,value%65536) + return true + end + function store:clearVar(key) + current();key=id(key,0x4000) + -- Use the native writer even for removal: object-graphics variables own + -- a derived slot table which must not retain their previous value. + require('src.script.Gen3Commands').setVar(save,key,0) + save.gen3Vars[key]=nil + return true + end + function store:nextFlag(previous) + current() + if type(save.flags)~='table'then return nil end + local name=previous~=nil and ('FLAG_G3_%04X'):format(id(previous))or nil + while true do + local value;name,value=rawNext(save.flags,name) + if name==nil then return nil end + local hex=type(name)=='string'and name:match('^FLAG_G3_(%x%x%x%x)$') + local n=hex and tonumber(hex,16) + if n and name==('FLAG_G3_%04X'):format(n)and type(value)=='boolean'then return n,value end + end + end + function store:nextVar(previous) + current() + if type(save.gen3Vars)~='table'then return nil end + local key=previous~=nil and id(previous,0x4000)or nil + while true do + local value;key,value=rawNext(save.gen3Vars,key) + if key==nil then return nil end + if integer(key,0x4000,65535)and not special(key,edition) + and integer(value,-9007199254740991,9007199254740991)then return key,value end + end + end + function store:snapshot() + current();local flags,vars={},{} + for name,value in pairs(save.flags or{})do + local hex=type(name)=='string'and name:match('^FLAG_G3_(%x%x%x%x)$') + local key=hex and tonumber(hex,16) + if key and name==('FLAG_G3_%04X'):format(key)and type(value)=='boolean'then flags[key]=value end + end + for key,value in pairs(save.gen3Vars or{})do + if integer(key,0x4000,65535)and not special(key,edition) + and integer(value,-9007199254740991,9007199254740991)then vars[key]=value end + end + return {flags=flags,vars=vars} + end + return store +end +return M diff --git a/src/compat/gen1/SessionView.lua b/src/compat/gen1/SessionView.lua index 89fecf5cd..d67233399 100644 --- a/src/compat/gen1/SessionView.lua +++ b/src/compat/gen1/SessionView.lua @@ -4,6 +4,9 @@ local Native=require('src.compat.gen1.NativeSession') local Identity=require('src.compat.gen1.MapIdentity') local Version=require('src.core.GameVersion') +local ScriptStore=require('src.compat.gen1.ScriptStore') +local ScriptMap=require('src.compat.gen1.ScriptMap') +local Iteration=require('src.compat.gen1.TableIteration') local M={} local records=setmetatable({},{__mode='k'}) local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0 and value%1==0 and value<4294967296,'invalid session '..key) save[key]=value + elseif key=='flags'or key=='vars'then + ScriptMap.replace(scripts,key,value) elseif key=='name'or key=='rivalName'then assert(player and type(value)=='string'and#value<=10 and not value:find('%c'),'invalid player name') player[key=='name'and'name'or'rival']=value @@ -101,9 +110,32 @@ function M.new(game) end end, }) + record.scripts=scripts + local fields={'version','generation','engine','map','x','y','facing','name','rivalName','trainerId','gender', + 'money','coins','modData','meta','options','biking','bikeType','surfing','underwater','elevation', + 'playTime','flags','vars'} + Iteration.register(view,function(state,source) + assert(state:valid(),'native session view retired') + local out={} + -- Include detached presentation fields without recursing through the + -- registered iterator. No registry callback captures its own weak key. + for k,v in Iteration.rawPairs(source)do out[k]=v end + for _,key in ipairs(fields)do + local v=source[key] + if key=='flags'or key=='vars'then v=Iteration.snapshot(v) + elseif key=='playTime'and v then + v={hours=v.hours,minutes=v.minutes,seconds=v.seconds,vblanks=v.vblanks} + end + out[key]=v + end + return out + end,record) records[view]=record return view,record end function M.record(view)return records[view]end -function M.release(view)local record=records[view];if record then record.retired=true end end +function M.release(view) + local record=records[view] + if record then record.retired=true;record.scripts:release()end +end return M diff --git a/src/compat/gen1/TableIteration.lua b/src/compat/gen1/TableIteration.lua new file mode 100644 index 000000000..0eb992296 --- /dev/null +++ b/src/compat/gen1/TableIteration.lua @@ -0,0 +1,34 @@ +-- LuaJIT's Lua-5.1 pairs/next do not consult __pairs. Only registered typed +-- views opt into snapshots; all ordinary tables keep their original C path. +-- Load this infrastructure before opening any mod mutation journal. +local nativePairs,nativeNext=pairs,next +local views=setmetatable({},{__mode='k'}) +local M={rawPairs=nativePairs,rawNext=nativeNext} +function M.register(view,reader,state,nextReader) + assert(type(view)=='table'and type(reader)=='function','invalid typed table view') + views[view]={reader=reader,state=state,nextReader=nextReader} + return view +end +function M.unregister(view)views[view]=nil end +function M.snapshot(view) + local spec=views[view] + if not spec then return nil end + local result=spec.reader(spec.state,view) + assert(type(result)=='table'and result~=view,'invalid typed table snapshot') + return result +end +local function viewPairs(value) + local spec=views[value] + if not spec then return nativePairs(value)end + return nativePairs(M.snapshot(value)) +end +local function viewNext(value,key) + local spec=views[value] + if not spec then return nativeNext(value,key)end + if spec.nextReader then return spec.nextReader(spec.state,key)end + return nativeNext(M.snapshot(value),key) +end +-- Installed once at module load, before Loader's entry/lifecycle snapshots. +-- Later mod wrappers remain free to forward to these just like normal pairs. +pairs,next=viewPairs,viewNext +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index bea05b889..037fa96e4 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,8 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +-- Install typed-view iteration before mod mutation journals open. +require("src.compat.gen1.TableIteration") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1196,6 +1198,8 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + -- Failed global/module wrappers must unwind before registry cleanup. + ModSeams.unwindOwner(modId) local facade=package.loaded['src.core.Game3'] if facade and facade._retireMod then facade._retireMod(modId)end for name in pairs(self.journal or self.content) do @@ -1489,7 +1493,6 @@ function Loader:load(data) Runtime.currentMod = nil if not success then self:_rollback(modId) - ModSeams.unwindOwner(modId) end self.journal = nil if success then diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index b490e431d..5132a3a77 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -44,6 +44,9 @@ -- still the mod's to guard; this takes away the common case, not the need. local ModSeams = {} +-- Mutation journals must remain operable while an entry has temporarily +-- replaced global iteration (including a wrapper that throws). +local pairs,next=pairs,next -- every change, oldest first: { tbl, key, before, after, owner } local journal = {} diff --git a/tests/engine/native_script_session.lua b/tests/engine/native_script_session.lua new file mode 100644 index 000000000..4476e608e --- /dev/null +++ b/tests/engine/native_script_session.lua @@ -0,0 +1 @@ +dofile('tools/script-stores/session-check.lua') diff --git a/tests/engine/native_script_store.lua b/tests/engine/native_script_store.lua new file mode 100644 index 000000000..9db13d505 --- /dev/null +++ b/tests/engine/native_script_store.lua @@ -0,0 +1 @@ +dofile('tools/script-stores/check.lua') diff --git a/tools/script-stores/check.lua b/tools/script-stores/check.lua new file mode 100644 index 000000000..8a81d7e70 --- /dev/null +++ b/tools/script-stores/check.lua @@ -0,0 +1,94 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +T.eq(package.loaded['src.core.Game'],nil) +local Store=require('src.compat.gen1.ScriptStore') +T.eq(package.loaded['src.core.Game'],nil,'requiring numeric stores cannot bootstrap Game') +love=require('tests.love_stub') +local Version=require('src.core.GameVersion') +local G3=require('src.script.Gen3Commands') +local Commands=require('src.script.Commands') +local Events=require('src.mods.Events') +local Runtime=require('src.mods.Runtime') +local events=Events.new();local announcements={} +events:on('flag.changed',function(e)announcements[#announcements+1]=e end) +Runtime.events=events +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local f=require('tools.session-runtime.fixture').new(edition) + local game=f.game;game.save.flags={};game.save.gen3Vars=nil + local store=assert(Store.new(game));T.eq(store:valid(),true) + T.eq(store:getVar(0x4075),0);T.eq(game.save.gen3Vars,nil,'reading an absent variable cannot allocate native state') + T.eq(store:getVar(37),37,'low script operands remain immediate values') + T.eq(store:getFlag(0x807),false) + local hide={index=1,eventFlag='FLAG_G3_0807'};f.map.def.objects={hide} + local visibleSyncs=0 + function f.world:syncObjectVisibility(obj)assert(obj==hide);visibleSyncs=visibleSyncs+1 end + local before=#announcements + store:setFlag(0x807,true);T.eq(game.save.flags.FLAG_G3_0807,true) + T.eq(visibleSyncs,1,'numeric writes use native live visibility commands') + T.eq(#announcements,before+1) + store:setFlag(0x807,true);T.eq(#announcements,before+1,'unchanged writes preserve native event deduplication') + Commands.g3_clear_flag({game=game,save=game.save,overworld=f.world},'FLAG_G3_0807') + T.eq(store:getFlag(0x807),false,'native command changes are immediately observable') + store:setFlag(0x4b8,true) + local frlg=edition=='firered'or edition=='leafgreen' + T.eq(game.save.flags.EVENT_BEAT_LORELEIS_ROOM_TRAINER_0,frlg and true or nil,'native league aliases remain edition scoped') + store:setFlag(0x4b8,false) + if frlg then T.eq(game.save.flags.EVENT_BEAT_LORELEIS_ROOM_TRAINER_0,false)end + store:setVar(0x4075,-1);T.eq(G3.getVar(game.save,0x4075),65535) + G3.setVar(game.save,0x4075,37);T.eq(store:getVar('0x4075'),37) + store:setVar(0x4010,89);T.eq(game.save.gen3ObjectSprites[0],89) + store:clearVar(0x4010);T.eq(game.save.gen3ObjectSprites[0],nil);T.eq(game.save.gen3Vars[0x4010],nil) + store:setVar(0x8000,12);store:setVar(0x8015,13);store:setVar(0x8016,14) + game.save.flags.NATIVE_ONLY=true + game.save.gen3Vars[0x4077]={mustNotEscape=true} + T.eq(store:getVar(0x4077),0,'invalid native values cannot lend a mutable table') + local snap=store:snapshot();T.eq(snap.flags[0x807],false);T.eq(snap.flags.NATIVE_ONLY,nil) + T.eq(snap.vars[0x4077],nil) + T.eq(snap.vars[0x4075],37);T.eq(snap.vars[0x8000],nil) + T.eq(snap.vars[0x8015],frlg and 13 or nil,'special-variable range follows the edition') + T.eq(snap.vars[0x8016],14) + snap.flags[0x807]=true;snap.vars[0x4075]=999 + T.eq(store:getFlag(0x807),false);T.eq(store:getVar(0x4075),37,'snapshots are detached') + -- Use the actual native compiler and runner, not a fabricated VM: script + -- writes and adapter writes must meet in the same live command store. + game.data.map_scripts={source='RomExtractorGen3',scripts={ + NATIVE_WRITE={{'setvar',0x4075,41},{'addvar',0x4075,1},{'setflag',0x807},{'end'}}, + NATIVE_READ={{'copyvar',0x4076,0x4075},{'end'}}, + }} + local compiler=require('src.script.Gen3ScriptVM') + local runner=require('src.script.ScriptRunner').new(game,f.world) + runner:run(assert(compiler.compile(game.data,'NATIVE_WRITE'))) + T.eq(runner:isRunning(),false);T.eq(store:getVar(0x4075),42);T.eq(store:getFlag(0x807),true) + store:setVar(0x4075,123) + runner:run(assert(compiler.compile(game.data,'NATIVE_READ'))) + T.eq(runner:isRunning(),false);T.eq(G3.getVar(game.save,0x4076),123) + local nextWorld={isOverworld=true,map=f.map,player=f.player} + local newSync=0 + function nextWorld:syncObjectVisibility(obj)assert(obj==hide);newSync=newSync+1 end + game.stack.states={nextWorld} + store:setFlag(0x807,false);T.eq(newSync,1,'writes resolve the current world after a transition') + for _,bad in ipairs({-1,65536,.5,0/0,math.huge,'not an id'})do + T.raises(function()store:getFlag(bad)end,'invalid numeric','invalid flag id declines') + T.raises(function()store:setVar(bad,1)end,'invalid numeric','invalid variable id declines') + end + for _,bad in ipairs({.5,0/0,math.huge,'1'})do + T.raises(function()store:setVar(0x4075,bad)end,'invalid script','invalid variable data declines') + end + T.raises(function()store:setFlag(0x807,1)end,'invalid script','numeric truthiness cannot accidentally set a flag') + store:release();T.eq(store:valid(),false) + T.raises(function()store:snapshot()end,'retired','explicit release retires observations too') + for _,change in ipairs({ + function()game.save={}end, + function()game.data={}end, + function()game.mods={}end, + function()game.data.maps={}end, + function()Version.set('crystal')end, + })do + local live=assert(Store.new(game));change();T.eq(live:valid(),false) + T.raises(function()live:setVar(0x4075,1)end,'retired','retired store cannot write the next cohort') + -- Restore the fixture's selected generation for the next independent binding. + Version.set(edition) + end +end +Version.set('crystal');T.eq(Store.new({data={},save={}}),nil,'earlier generations never inherit a GBA script binding') +T.finish('native numeric script-store backend') diff --git a/tools/script-stores/session-check.lua b/tools/script-stores/session-check.lua new file mode 100644 index 000000000..32cacb7f5 --- /dev/null +++ b/tools/script-stores/session-check.lua @@ -0,0 +1,93 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local basePairs,baseNext=pairs,next +local T=require('tests.modkit') -- Loader installs iteration before mod journals. +local Iteration=require('src.compat.gen1.TableIteration') +local Views=require('src.compat.gen1.SessionView') +local Version=require('src.core.GameVersion') +local G3=require('src.script.Gen3Commands') +local function copy(v,seen) + if type(v)~='table'then return v end + seen=seen or{};if seen[v]then return seen[v]end + local out={};seen[v]=out;for k,x in pairs(v)do out[copy(k,seen)]=copy(x,seen)end;return out +end +-- Ordinary tables retain the host semantics, including ignored __pairs. +local ordinary=setmetatable({a=1,b=false},{__pairs=function()return baseNext,{metamethod=true},nil end}) +local function collect(iter,state,key)local out={};for k,v in iter,state,key do out[k]=v end;return out end +T.same(collect(pairs(ordinary)),collect(basePairs(ordinary))) +local first=baseNext(ordinary);T.eq(next(ordinary),first);T.eq(next(ordinary,first),baseNext(ordinary,first)) +T.raises(function()pairs(17)end,'table expected','ordinary invalid pairs input keeps its native failure') +-- The registry cannot retain an otherwise unreachable view. +local weak=setmetatable({},{__mode='v'}) +do local v={};weak[1]=v;Iteration.register(v,function()return{one=1}end)end +collectgarbage('collect');collectgarbage('collect');T.eq(weak[1],nil) +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local f=require('tools.session-runtime.fixture').new(edition);local game=f.game + game.save.flags={FLAG_G3_0807=true,FLAG_G3_0808=false,NATIVE_ONLY=true} + game.save.gen3Vars={[0x4075]=37,[0x4076]=9,[0x8000]=12,[0x8015]=13} + local s=assert(Views.new(game));local flags,vars=s.flags,s.vars + T.check(flags~=game.save.flags);T.check(vars~=game.save.gen3Vars) + T.eq(flags[0x807],true);T.eq(flags['2056'],false);T.eq(flags[0x809],nil) + T.eq(vars[0x4075],37);T.eq(vars[0x8000],nil);T.eq(vars.unrecognized,nil) + T.same(collect(pairs(flags)),{[0x807]=true,[0x808]=false},'source iteration exposes numeric flag state only') + local frlg=edition=='firered'or edition=='leafgreen' + local expected={[0x4075]=37,[0x4076]=9};if frlg then expected[0x8015]=13 end + T.same(collect(pairs(vars)),expected) + T.same(collect(next,flags,nil),collect(pairs(flags)),'direct next uses the native numeric flag cursor') + T.same(collect(next,vars,nil),expected,'direct next skips context-owned special variables') + flags[0x807]=nil;T.eq(game.save.flags.FLAG_G3_0807,false);T.eq(flags[0x807],false) + flags['0x808']=true;T.eq(game.save.flags.FLAG_G3_0808,true) + vars[0x4010]=89;T.eq(game.save.gen3ObjectSprites[0],89) + vars[0x4010]=nil;T.eq(game.save.gen3ObjectSprites[0],nil) + -- Deleting the current key while using next must retain the native cursor, + -- rather than reconstructing an incompatible table on every call. + local walked=0;local k=next(vars) + while k do vars[k]=nil;walked=walked+1;k=next(vars,k)end + T.eq(walked,frlg and 3 or 2);T.eq(vars[0x8000],nil);T.eq(game.save.gen3Vars[0x8000],12) + vars[0x4075]=41;G3.setVar(game.save,0x4075,42);T.eq(vars[0x4075],42) + local iter,state,start=pairs(vars);vars[0x4075]=43 + T.eq(collect(iter,state,start)[0x4075],42,'pairs freezes its own scalar iteration values') + T.eq(vars[0x4075],43) + -- Whole-field assignment is a typed native replacement, not a borrowed map. + s.flags={[0x809]=true};T.eq(flags[0x807],false);T.eq(flags[0x808],false);T.eq(flags[0x809],true) + T.eq(game.save.flags.NATIVE_ONLY,true,'replacing source flags preserves native-only names') + s.vars={['0x4010']=7,[0x4076]=65537} + T.eq(vars[0x4075],nil);T.eq(vars[0x4076],1);T.eq(game.save.gen3ObjectSprites[0],7) + T.eq(game.save.gen3Vars[0x8000],12,'persistent replacements never clear native special variables') + local before=copy(s.flags);local nativeVars=copy(game.save.gen3Vars) + T.raises(function()s.flags={[0x809]=false,[0x810]='bad'}end,'invalid script flag','replacement validates before mutation') + T.same(copy(s.flags),before) + T.raises(function()s.vars={[0x4076]=2,[0x8000]=0}end,'invalid persistent','special variables cannot be persisted') + T.same(game.save.gen3Vars,nativeVars) + T.raises(function()vars[0x8000]=1 end,'invalid persistent','context variables reject persistent writes') + T.raises(function()s.flags={[0x809]=true,['2057']=false}end,'conflicting','ambiguous aliases reject before mutation') + local snapshot=copy(s) + T.eq(snapshot.version,edition);T.eq(snapshot.flags[0x809],true);T.eq(snapshot.vars[0x4076],1) + snapshot.flags[0x809]=false;snapshot.vars[0x4076]=999 + T.eq(flags[0x809],true);T.eq(vars[0x4076],1,'unchanged recursive-copy code obtains detached numeric state') + s.previewMarker={value=1};T.eq(copy(s).previewMarker.value,1) + local oldClock=copy(s).playTime;game.save.playTime=9000;T.eq(oldClock.hours,1,'enumerated playtime is detached') + Views.release(s) + T.raises(function()flags[0x809]=false end,'retired','retained maps cannot write after session release') + T.raises(function()pairs(vars)end,'retired','retained maps cannot enumerate after session release') +end +-- A native peer may wrap pairs normally. Entry rollback must keep the engine +-- dispatcher and another mod's wrapper while taking back the failed wrapper. +local f=require('tools.session-runtime.fixture').new('emerald') +local session=assert(Views.new(f.game));f.game.save.gen3Vars={[0x4075]=37} +local J=require('src.link.Json');local files={} +local api=require('src.core.Version').modApi +local function mod(id,priority,body) + files['mods/'..id..'/manifest.json']=J.encode({id=id,name=id,version='1.0.0',api=api,entry='main.lua',priority=priority,permissions={'engine_internals'}}) + files['mods/'..id..'/main.lua']=body +end +mod('native_pairs_peer',0,[[local mod=...;local old=pairs;mod.exports.old=old;pairs=function(...)return old(...)end]]) +mod('failed_pairs',100,[[pairs=function()error('failed pairs wrapper survived')end;error('intentional iteration entry failure')]]) +local enginePairs=pairs +local run=T.sdk.loadMods({'mods/native_pairs_peer','mods/failed_pairs'},{fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +T.eq(#run.errors,1);T.eq(copy(session.vars)[0x4075],37) +T.eq(run.loader.exports.native_pairs_peer.old,enginePairs,'mod snapshots see infrastructure as their baseline') +require('src.mods.Loader').teardown();T.eq(pairs,enginePairs,'native wrappers unwind back to the dispatcher') +T.same(collect(pairs(ordinary)),collect(basePairs(ordinary))) +run.release();Views.release(session) +Version.set('crystal') +T.finish('native numeric session schemas and view iteration') From b86ad73f9a54bbe9119910e9499df450a0f77bc9 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Mon, 26 Oct 2026 00:35:25 +0100 Subject: [PATCH 4/4] Document numeric state views and remaining session schemas --- docs/native-session-runtime.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/native-session-runtime.md b/docs/native-session-runtime.md index bcf7632eb..38487895a 100644 --- a/docs/native-session-runtime.md +++ b/docs/native-session-runtime.md @@ -63,8 +63,10 @@ clock replacement remain native commands. Temporary presentation extensions and preview replacements never replace the underlying native save/stack. Persistence continues through the existing native services and `mod.save`. -This is not the complete source session schema. Party, bag, storage, dex, numeric -script stores, complete session enumeration/deep-copy, preview isolation, custom +Numeric flags and variables now use typed native command-backed views, with +session/map iteration and detached recursive-copy support; see +`native-script-state.md`. Party, bag, storage, dex, complete session schemas, +preview isolation, custom boot UI and further field/actor/battle/audio services still need their typed adapters. A differently shaped native collection is not exposed under a source name. The data facade supplies the prior owned map views; other data schemas need