From 03e1b001df5fe7db307d5e45c81ca5119a00410a Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 22:27:21 +0100 Subject: [PATCH 1/3] Expose owned object and connected-map observations over native NPCs --- docs/native-object-views.md | 53 ++++++ src/compat/gen1/ObjectRuntime.lua | 199 +++++++++++++++++++++++ src/core/game3/ghosts.lua | 16 ++ src/core/game3/objects.lua | 120 ++++++++++++++ src/mods/Loader.lua | 9 + tests/engine/native_object_lifecycle.lua | 1 + tests/engine/native_object_views.lua | 1 + tools/object-runtime/check.lua | 125 ++++++++++++++ tools/object-runtime/lifecycle-check.lua | 66 ++++++++ 9 files changed, 590 insertions(+) create mode 100644 docs/native-object-views.md create mode 100644 src/compat/gen1/ObjectRuntime.lua create mode 100644 src/core/game3/ghosts.lua create mode 100644 src/core/game3/objects.lua create mode 100644 tests/engine/native_object_lifecycle.lua create mode 100644 tests/engine/native_object_views.lua create mode 100644 tools/object-runtime/check.lua create mode 100644 tools/object-runtime/lifecycle-check.lua diff --git a/docs/native-object-views.md b/docs/native-object-views.md new file mode 100644 index 000000000..7cdc7329d --- /dev/null +++ b/docs/native-object-views.md @@ -0,0 +1,53 @@ +# Native object and connected-map views + +`src.core.game3.objects` observes the live native GBA NPC pool. Importing it +does not create a Game, a world, an NPC, a texture or a second update loop. +`src.core.game3.ghosts` observes the native connected-map pools in map-local +coordinates; the consumer applies each connection placement exactly once. + +The imported object `index` is its preserved cartridge local ID. Canonical +map names resolve through the checked group/number binding, including when +native storage uses different map keys. The current and connected maps may +contain the same local ID without sharing an actor view. Invalid map names +cannot fall back to current-map objects. Conflicting live local IDs decline +the pool rather than publishing an ambiguous identity. + +Views have stable identities within a native cohort and expose live position, +movement, elevation, facing, step phase, visibility and actual resolved sprite +row. Their template and nested script data are owned copies. Extension fields +stay on the view; source code never receives a native actor or sprite resource. +The native pose rules supply jump lift on an observational copy. Queries do not +advance animation, NPC AI, script moves, playtime or the game RNG. + +Writes are limited to validated native visibility, facing, freeze state, +passability, horizontal presentation offset and in-range fixed-frame commands. +Movement-type changes and facing the player use the native NPC methods. +Position, movement state, elevation and raw graphics identity writes decline: +those changes need the actual native map/movement/script command owners. +View clearing retires observations only; it never deletes the native cast. + +An observed removal or a map, player, world, save, dataset, map-registry, +loader or edition replacement irreversibly retires an old handle. Reload/exit +retires the facade through the existing compatibility teardown, after wrapper +unwind. New observations can adopt the still-live native cast. + +## Validation + +`tools/object-runtime/check.lua` uses the real native NPC class and updater +with synthetic field metadata across FireRed, LeafGreen, Emerald, Ruby and +Sapphire. It checks live movement observations, native pose rules, connected +map identities/offsets, typed commands, detached records and cohort retirement. +It does not import cartridges or use player profiles. + +## Remaining actor contracts + +This is not complete actor compatibility or gameplay acceptance. Runtime +spawning/removal, script movement streams/tracks, virtual/network actors, +subpriority/fade/figure-eight commands, foreign carry/snapshots, template +visibility persistence and prefetch/adoption protocols remain to be connected +to their native owners. Source collision wrappers are not yet called by native +NPC AI or trainer sight. Sprite pixel/frame/anchor resources and special berry, +disguise and multipart poses still require the separate sprite interface. +`Objects.loadMap` can observe only the already active native map, and cannot +load or instantiate another map. No unchanged renderer or GPU acceptance is +claimed by this read-side interface. diff --git a/src/compat/gen1/ObjectRuntime.lua b/src/compat/gen1/ObjectRuntime.lua new file mode 100644 index 000000000..d8dea66dd --- /dev/null +++ b/src/compat/gen1/ObjectRuntime.lua @@ -0,0 +1,199 @@ +-- Owned, live observations of the native object pool. No second actor loop. +local Session = require('src.compat.gen1.NativeSession') +local Identity = require('src.compat.gen1.MapIdentity') +local Version = require('src.core.GameVersion') +local R = {}; R.__index = R +local directions = {up=true,down=true,left=true,right=true} +local function finite(v) return type(v)=='number' and v==v and math.abs(v)=lo and v<=hi end +local function copy(v,seen) + if type(v)~='table' then if type(v)=='function' then return nil end; return v end + seen=seen or {}; if seen[v] then return seen[v] end + local out={}; seen[v]=out + for k,value in pairs(v) do if type(k)=='string' or type(k)=='number' then out[k]=copy(value,seen) end end + return out +end +local fields={cellX='cellX',cellY='cellY',px='px',py='py',targetX='targetX',targetY='targetY', + moving='moving',progress='progress',facing='facing',stepFlip='stepFlip',animClock='animClock', + invisible='invisible',hidden='hidden',passable='passable',runtime='runtime', + customFrame='fixedFrame',raiseX='shiftPx',homeX='roamOriginX',homeY='roamOriginY'} +local boolWrite={hidden='hidden',invisible='invisible',passable='passable'} +function R.new() return setmetatable({views=setmetatable({},{__mode='kv'}),records=setmetatable({},{__mode='kv'})},R) end +function R:release() + if self.context then self.context.retired=true end + self.context=nil +end +function R:current(game) + local g,w,p=Session.resolve(game) + local c=self.context + if not g then self:release(); return nil,w end + if c and (c.retired or c.game~=g or c.world~=w or c.player~=p or c.map~=w.map or c.def~=w.map.def or c.data~=g.data + or c.maps~=g.data.maps or c.save~=g.save or c.loader~=g.mods or c.edition~=Version.get() + or c.binding:legacyId(w.map.id)~=c.id) then + self:release(); c=nil + end + if not c then + c={game=g,world=w,player=p,map=w.map,def=w.map.def,data=g.data,maps=g.data.maps, + save=g.save,loader=g.mods,edition=Version.get()} + c.binding=Identity.bind(c.maps,c.edition) + local why; c.id,why=c.binding:legacyId(w.map.id) + if not c.id then return nil,why end + self.context=c + end + return c +end +local function live(c) + if c.retired then return false end + local g,w,p=Session.resolve(c.game) + if g~=c.game or w~=c.world or p~=c.player or w.map~=c.map or w.map.def~=c.def + or g.data~=c.data or g.data.maps~=c.maps or g.save~=c.save or g.mods~=c.loader + or Version.get()~=c.edition or c.binding:legacyId(c.map.id)~=c.id then c.retired=true; return false end + return true +end +local function member(c,e,mapId) + if not live(c) then return false end + if mapId==c.map.id then + for _,a in ipairs(c.world.npcs or c.world.entities or {}) do if a==e then return true end end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==mapId and c.maps[mapId]==nb.map.def then + for _,a in ipairs(nb.peers or {}) do if a==e then return true end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + local map=ghost.nb and ghost.nb.map + if map and map.id==mapId and c.maps[mapId]==map.def and ghost.npc==e then return true end + end + end + return false +end +function R:view(c,e,mapId) + local class=package.loaded['src.world.NPC'] + if type(class)~='table' or getmetatable(e)~=class or type(e.def)~='table' + or not integer(e.def.index,1,65535) then return nil,'unsupported native object identity' end + local id,why=c.binding:legacyId(mapId); if not id then return nil,why end + local record=self.views[e] + if record and record.context==c and record.mapId==mapId and not record.retired then return record.view end + record={context=c,actor=e,mapId=mapId,extras={},definition=copy(e.def)} + record.definition.localId=e.def.index + local function valid() + if record.retired then return false end + if not member(c,e,mapId) or c.binding:legacyId(mapId)~=id then record.retired=true; return false end + return true + end + local function read(_,key) + if not valid() then return nil end + if key=='passable' then return e.passable==true or e.def.passable==true end + if key=='runtime' then return e.def.runtime==true end + if fields[key] then return e[fields[key]] end + if key=='def' then return record.definition end + if key=='localId' or key=='originLocalId' then return e.def.index end + if key=='id' then return e.id end + if key=='originMapId' then return id end + if key=='originMapGroup' then return c.maps[mapId].group end + if key=='originMapNum' then return c.maps[mapId].number end + if key=='graphicsId' then + -- The extractor preserves the cartridge row on the actual sprite. + -- Variable sprites resolve to a different sheet without changing def. + local sprite=e.sprite and e.sprite.def + local key=type(sprite)=='table' and sprite.id + return type(key)=='string' and tonumber(key:match('^SPRITE_G3_(%d+)$')) or e.def.graphicsId + end + if key=='visible' then return not(e.hidden or e.visible==false or e.berryVisualEmpty) end + if key=='currentElevation' or key=='elevation' then return e.elevation or e.gen3Elevation or e.def.elevation or 0 end + if key=='stepFrames' then return e.scriptStepLen or e.stepFrames or 16 end + if key=='raiseY' then + local observed={}; for k,v in pairs(e) do observed[k]=v end + setmetatable(observed,class) + local _,_,py=class.pose(observed) + return e.py-py + end + if key=='movementType' then return e.gen3MovementType or e.def.movementType end + if key=='frozen' then return e.frozen==true or e.gen3ScriptFrozen==true end + if key=='scriptBusy' then + for _,mv in ipairs(c.world.scriptMoves or {}) do if mv.entity==e then return true end end + return false + end + return record.extras[key] + end + local function write(_,key,value) + assert(valid(),'native object view retired') + if boolWrite[key] then + assert(type(value)=='boolean','invalid object boolean'); e[boolWrite[key]]=value + elseif key=='visible' then + assert(type(value)=='boolean','invalid object visibility'); e.hidden=not value; e.visible=value + elseif key=='facing' then + assert(directions[value],'invalid object facing'); e.facing=value + elseif key=='raiseX' then + assert(finite(value) and math.abs(value)<=16777216,'invalid object offset'); e.shiftPx=value + elseif key=='customFrame' then + local frames=e.sprite and e.sprite.def and math.max(e.sprite.def.frames or 1,e.sprite.def.poseFrames or 1) or 1 + assert(value==nil or integer(value,0,frames-1),'invalid object frame'); e.fixedFrame=value + elseif key=='frozen' then + assert(type(value)=='boolean','invalid object freeze'); e.gen3ScriptFrozen=value or nil + elseif fields[key] or key=='def' or key=='localId' or key=='graphicsId' or key=='id' + or key=='currentElevation' or key=='elevation' or key=='stepFrames' or key=='movementType' + or key=='scriptBusy' or key=='raiseY' or type(key)=='string' and key:match('^origin') then + error('object '..key..' requires a native command owner',2) + else record.extras[key]=value end + end + record.view=setmetatable({},{__index=read,__newindex=write}) + self.views[e]=record + self.records[record.view]=record + return record.view +end +function R:actor(view) + local r=view and self.records[view] + if r then + if not r.retired and r.view.localId~=nil then return r.actor,r.context,r.mapId end + r.retired=true; return nil,'native object view retired' + end + return nil,'foreign object view' +end +function R:pool(mapId,game) + local c,why=self:current(game); if not c then return nil,why end + local nativeId=c.map.id + if mapId then + nativeId=c.binding:nativeId(mapId) + if not nativeId then + -- Registries can acquire authored maps without replacing data.maps. + -- Re-index on a miss; never fall back to the current map's objects. + c.binding=Identity.bind(c.maps,c.edition) + nativeId=c.binding:nativeId(mapId) + end + if not nativeId then return nil,'unknown object map' end + end + local list,seen,ids={},{},{} + local function add(e) + if seen[e] then return true end + local view,err=self:view(c,e,nativeId); if not view then return nil,err end + if ids[view.localId] then return nil,'ambiguous native object local ID' end + seen[e]=true; ids[view.localId]=true; list[#list+1]=view + return true + end + if nativeId==c.map.id then + for _,e in ipairs(c.world.npcs or c.world.entities or {}) do + if e~=c.player then local ok,err=add(e); if not ok then return nil,err end end + end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==nativeId then + for _,e in ipairs(nb.peers or {}) do local ok,err=add(e); if not ok then return nil,err end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + if ghost.nb and ghost.nb.map and ghost.nb.map.id==nativeId then + local ok,err=add(ghost.npc); if not ok then return nil,err end + end + end + end + return list,c +end +function R:walkPhase(view) + local e=self:actor(view) + return e and not e.sliding and e:walkPhase() or 0 +end +R.integer=integer +R.copy=copy +R.directions=directions +return R diff --git a/src/core/game3/ghosts.lua b/src/core/game3/ghosts.lua new file mode 100644 index 000000000..98fec975b --- /dev/null +++ b/src/core/game3/ghosts.lua @@ -0,0 +1,16 @@ +-- Native connected-map pools, in each map's own coordinates. WorldAdapter +-- applies the connection offset once; never advance a second NPC clock here. +local Objects=require('src.core.game3.objects') +local Ghosts={} +function Ghosts.forDraw(mapId) + local out={} + if not mapId or mapId==Objects._mapId then return out end + for _,v in ipairs(Objects._pool(mapId) or {}) do + if v.visible and not(v.hidden or v.invisible) then out[#out+1]=v end + end + return out +end +function Ghosts.visibleIds(mapId) + local ids={}; for _,v in ipairs(Ghosts.forDraw(mapId)) do ids[v.localId]=true end; return ids +end +return Ghosts diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua new file mode 100644 index 000000000..e47d9d923 --- /dev/null +++ b/src/core/game3/objects.lua @@ -0,0 +1,120 @@ +-- Compatibility facade over native NPCs; all motion stays on the native queue. +local Runtime=require('src.compat.gen1.ObjectRuntime') +local Identity=require('src.compat.gen1.MapIdentity') +local Player=require('src.core.game3.player') +local runtime=Runtime.new() +local Objects={PLAYER_LOCAL_ID=0xFF} +function Objects.isPlayer(id) id=tonumber(id); return id==0xFF or id==0x800F end +function Objects.find(id) + if Objects.isPlayer(id) then return runtime:current() and Player or nil end + for _,view in ipairs(runtime:pool() or {}) do if view.localId==tonumber(id) then return view end end +end +function Objects.findObjectByLocalIdAndMap(id,group,number) + if group==nil and number==nil or Objects.isPlayer(id) then return Objects.find(id) end + local c=runtime:current(); if not c then return nil end + local mapId=Identity.lookup(c.edition,tonumber(group),tonumber(number)) + if not mapId then return nil end + for _,view in ipairs(runtime:pool(mapId) or {}) do if view.localId==tonumber(id) then return view end end +end +function Objects.layoutBounds(def) + local l=def and def.midLayout + if l and (l.width or 0)>0 and (l.height or 0)>0 then return {w=l.width,h=l.height} end +end +function Objects.offMap(bounds,eo) + return bounds~=nil and (eo.cellX<0 or eo.cellY<0 or eo.cellX>=bounds.w or eo.cellY>=bounds.h) or false +end +function Objects.forDraw() + local out={}; local list,c=runtime:pool(); if not list then return out end + for _,v in ipairs(list) do + if v.visible and not(v.hidden or v.invisible) and (v.moving or v.scriptBusy or c.map:inBounds(v.cellX,v.cellY)) then out[#out+1]=v end + end + return out +end +function Objects.listActive(_mod,game,mapId) + local ids={} + for _,v in ipairs(runtime:pool(mapId,game) or {}) do if v.visible and not v.hidden then ids[#ids+1]=v.localId end end + return ids +end +function Objects.at(x,y) + if not(Runtime.integer(x,-16777216,16777216) and Runtime.integer(y,-16777216,16777216)) then return nil end + for _,v in ipairs(runtime:pool() or {}) do + if v.visible and not v.hidden and (v.moving and v.targetX or v.cellX)==x + and (v.moving and v.targetY or v.cellY)==y then return v end + end +end +function Objects.elevationsCompatible(a,b) a,b=tonumber(a) or 0,tonumber(b) or 0; return a==0 or b==0 or a==b end +function Objects.blocks(x,y,except,elevation) + for _,v in ipairs(runtime:pool() or {}) do + if v.localId~=tonumber(except) and v.visible and not(v.hidden or v.passable) + and Objects.elevationsCompatible(elevation,v.currentElevation) then + if v.cellX==x and v.cellY==y or v.moving and v.targetX==x and v.targetY==y then return true end + end + end + return false +end +function Objects.playerBlocks(x,y,elevation) + local c=runtime:current(); local p=c and c.player + return p~=nil and Objects.elevationsCompatible(elevation,p.elevation) + and (p.cellX==x and p.cellY==y or p.moving==true and p.targetX==x and p.targetY==y) or false +end +function Objects.walkPhase(view) return view==Player and Player.walkPhase() or runtime:walkPhase(view) end +function Objects.scriptFace(view,dir) + if not Runtime.directions[dir] then return false end + if view==Player then if not runtime:current() then return false end; Player.facing=dir; return true end + local e=runtime:actor(view); if not e or e.facingLocked then return false end + e.facing=dir; return true +end +function Objects.turnObject(id,dir) + dir=({[1]='down',[2]='up',[3]='left',[4]='right'})[dir] or dir + return Objects.scriptFace(Objects.find(id),dir) +end +function Objects.hideObjectAt(id,g,n) + local v=Objects.findObjectByLocalIdAndMap(id,g,n); if not v then return false end + v.visible=false; return true +end +function Objects.showObjectAt(id,g,n) + local v=Objects.findObjectByLocalIdAndMap(id,g,n); if not v then return false end + v.visible=true; return true +end +function Objects.hideObject(id) return Objects.hideObjectAt(id) end +function Objects.showObject(id) return Objects.showObjectAt(id) end +function Objects.freeze(id) + local v=Objects.find(id); if not v or v==Player then return false end; v.frozen=true; return true +end +function Objects.unfreeze(id) + local v=Objects.find(id); if not v or v==Player then return false end; v.frozen=false; return true +end +function Objects.setMovementType(id,mt) + local v=Objects.find(id); local e,c=runtime:actor(v) + if not e or not Runtime.integer(mt,0,255) or not(c.data.constants.gen3MovementTypes or {})[mt] then return false end + return e:setMovementType(c.data,mt) +end +function Objects.facePlayer(id) + local e,c=runtime:actor(Objects.find(id)); if not e then return false end + e:facePlayer(c.player); return true +end +function Objects.updateElevation(view) + local e,c,mapId=runtime:actor(view); if not e then return false end + local map=mapId==c.map.id and c.map + if not map then for _,nb in ipairs(c.world.neighbors or {}) do if nb.map.id==mapId then map=nb.map; break end end end + if not map then return false end; e:updateElevation(map); return true +end +function Objects.loadMap(game,mapId) + local c,why=runtime:current(game); if not c then return nil,why end + if mapId~=c.id then return nil,'object loading belongs to the native map transition' end + return runtime:pool(mapId,game) +end +function Objects.clear() runtime:release() end +Objects.reset=Objects.clear +Objects.release=Objects.clear +-- Internal shared observer for the separate, map-local neighbour facade. +function Objects._pool(mapId) return runtime:pool(mapId) end +setmetatable(Objects,{__index=function(_,key) + if key=='_mapId' then local c=runtime:current(); return c and c.id end + if key=='_byId' or key=='_order' then + local byId,order={},{} + for _,v in ipairs(runtime:pool() or {}) do byId[v.localId]=v; order[#order+1]=v.localId end + return key=='_byId' and byId or order + end +end}) +return Objects diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ea332752f 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1309,12 +1309,20 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Retire owned object views after source wrappers have been unwound. +-- Querying teardown must not import a compatibility world on native boots. +local function retireCompatibilityObjects() + local objects = package.loaded["src.core.game3.objects"] + if type(objects) == "table" and type(objects.release) == "function" then objects.release() end +end + function Loader:load(data) -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() + retireCompatibilityObjects() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1654,6 +1662,7 @@ end -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() local undone = select(1, ModSeams.unwindAll()) + retireCompatibilityObjects() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/tests/engine/native_object_lifecycle.lua b/tests/engine/native_object_lifecycle.lua new file mode 100644 index 000000000..74ca7172f --- /dev/null +++ b/tests/engine/native_object_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/object-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_object_views.lua b/tests/engine/native_object_views.lua new file mode 100644 index 000000000..ad890913b --- /dev/null +++ b/tests/engine/native_object_views.lua @@ -0,0 +1 @@ +dofile('tools/object-runtime/check.lua') diff --git a/tools/object-runtime/check.lua b/tools/object-runtime/check.lua new file mode 100644 index 000000000..c2ae63727 --- /dev/null +++ b/tools/object-runtime/check.lua @@ -0,0 +1,125 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local Objects=require('src.core.game3.objects') +local Ghosts=require('src.core.game3.ghosts') +local R=require('src.compat.gen1.ObjectRuntime') +T.eq(package.loaded['src.core.Game'],nil,'queries do not bootstrap Game') +T.eq(Objects.find(1),nil); T.eq(#Objects.forDraw(),0); T.eq(Objects.blocks(1,1),false) +T.eq(Objects.playerBlocks(1,1),false); T.eq(#Ghosts.forDraw('missing'),0) +local Native=require('src.world.NPC') +local Identity=require('src.compat.gen1.MapIdentity') +local Version=require('src.core.GameVersion') +local F=require('tools.session-runtime.fixture') +local function npc(id,x,y) + return setmetatable({id='native_obj_'..id,def={index=id,localId=id,graphicsId=42, + elevation=3,x=x,y=y,passable=false,script={'native'}}, + sprite={def={id='SPRITE_G3_042',frames=9}},cellX=x,cellY=y,px=x*16,py=y*16, + facing='down',moving=false,progress=0,animClock=0,stepFlip=false},Native) +end +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'}) do + Objects.release() + local f=F.new(edition); local g,w=f.game,f.world + local a,b=npc(1,5,5),npc(2,6,6) + w.npcs={a,b}; w.entities={f.player,a,b} + g.data.constants.gen3MovementTypes={[1]={facing='left',wanders=false}} + local v=assert(Objects.find(1)); T.check(v~=a,'never lend the native actor') + T.eq(Objects.find(1),v,'stable live object view'); T.eq(v.localId,1); T.eq(v.id,a.id) + T.eq(v.originMapId,f.canonical); T.eq(Objects._mapId,f.canonical) + T.eq(v.graphicsId,42); T.eq(v.currentElevation,3); T.eq(v.visible,true) + T.eq(v.def.passable,false,'definition copy retains false values') + v.def.script[1]='detached'; T.eq(a.def.script[1],'native','nested definitions are owned') + v.customExtension={probe=true}; T.eq(a.customExtension,nil) + T.eq(Objects._byId[1],v); T.same(Objects._order,{1,2}) + local ids=Objects._order; ids[1]=99; T.same(Objects._order,{1,2},'external lists cannot replace native actors') + T.same(Objects.listActive(),{1,2}); T.eq(#Objects.forDraw(),2) + T.eq(Objects.at(5,5),v); T.eq(Objects.at(0/0,5),nil) + T.eq(Objects.blocks(5,5),true); T.eq(Objects.blocks(5,5,1),false) + T.eq(Objects.blocks(5,5,nil,4),false); T.eq(Objects.blocks(5,5,nil,0),true) + T.eq(Objects.playerBlocks(4,4,3),true); T.eq(Objects.playerBlocks(4,4,4),false) + T.eq(Objects.find(0xFF),require('src.core.game3.player')); T.eq(Objects.find(0x800F),Objects.find(0xFF)) + T.eq(Objects.findObjectByLocalIdAndMap(1,f.map.def.group,f.map.def.number),v) + T.eq(Objects.findObjectByLocalIdAndMap(1,255,255),nil,'unknown map cannot fall through to current pool') + T.eq(Objects.hideObject(1),true); T.eq(a.hidden,true); T.eq(#Objects.forDraw(),1) + T.eq(Objects.blocks(5,5),false); T.eq(Objects.showObject(1),true) + v.invisible=true; T.eq(#Objects.forDraw(),1); T.eq(Objects.blocks(5,5),true,'invisible objects remain solid') + v.invisible=false; v.passable=true; T.eq(Objects.blocks(5,5),false); v.passable=false + v.facing='right'; T.eq(a.facing,'right'); T.eq(Objects.turnObject(1,2),true); T.eq(a.facing,'up') + T.eq(Objects.turnObject(1,'bad'),false); T.eq(a.facing,'up') + T.raises(function() v.cellX=9 end,'native command owner','unpaired teleport is refused') + T.raises(function() v.graphicsId=99 end,'native command owner','raw sprite identity writes are refused') + T.raises(function() v.hidden=1 end,'invalid object boolean','typed visibility') + T.raises(function() v.raiseX=0/0 end,'invalid object offset','finite offsets') + v.raiseX=7; T.eq(a.shiftPx,7); T.eq(v.raiseX,7) + v.customFrame=8; T.eq(a.fixedFrame,8) + T.raises(function() v.customFrame=9 end,'invalid object frame','frame is in actual sheet') + v.customFrame=nil; T.eq(a.fixedFrame,nil) + T.eq(Objects.freeze(1),true); T.eq(a.gen3ScriptFrozen,true); T.eq(v.frozen,true) + T.eq(Objects.unfreeze(1),true); T.eq(a.gen3ScriptFrozen,nil) + T.eq(Objects.setMovementType(1,1),true); T.eq(a.facing,'left'); T.eq(v.movementType,1) + T.eq(Objects.setMovementType(1,250),false); T.eq(a.gen3MovementType,1) + T.eq(Objects.facePlayer(1),true); T.eq(a.facing,'up') + a.sprite={def={id='SPRITE_G3_077',frames=9}}; T.eq(v.graphicsId,77,'live variable-sheet identity') + a.moving=true; a.targetX=6; a.targetY=5; a.progress=4 + T.eq(Objects.at(5,5),nil); T.eq(Objects.at(6,5),v) + T.eq(Objects.blocks(5,5),true); T.eq(Objects.blocks(6,5),true) + T.eq(Objects.walkPhase(v),a:walkPhase()) + a.sliding=true; T.eq(Objects.walkPhase(v),0,'native sliding pose holds standing frame'); a.sliding=nil + a.sprite.def.holdStep=true; T.eq(Objects.walkPhase(v),1,'native held walk animation'); a.sprite.def.holdStep=nil + b.def.passable=true; T.eq(Objects.blocks(6,6),false,'authored passable bodies stay passable'); b.def.passable=false + b.berryVisualEmpty=true; T.eq(#Objects.forDraw(),1,'empty berry stage is not rendered'); b.berryVisualEmpty=nil + local before=R.copy(a); local clock=g.save.playTime + for _=1,12 do Objects.forDraw(); local observed=Objects._byId end + T.eq(a.progress,before.progress); T.eq(a.animClock,before.animClock); T.eq(g.save.playTime,clock) + -- The native walker alone moves the actor. Observations see each pixel. + a.frozen=true + for _=1,12 do + a:update(f.map,w.entities) + T.eq(v.px,a.px); T.eq(v.cellX,a.cellX); T.eq(Objects.walkPhase(v),a:walkPhase()) + end + T.eq(a.cellX,6); T.eq(a.moving,false); T.eq(v.stepFlip,true) + a.hopFrames=16; a.hopTotal=32 + local _,_,poseY=a:pose(); T.eq(v.raiseY,a.py-poseY) + for _=1,5 do T.eq(v.raiseY,a.py-poseY) end + T.eq(a.hopFrames,16,'render observations never advance jump timers') + local others=require('src.compat.gen1.MapNames')[edition] + local row + for _,candidate in ipairs(others) do if candidate[3]~=f.canonical then row=candidate; break end end + local def={id='AWAY',group=row[1],number=row[2]}; g.data.maps.AWAY=def + local awayMap={id='AWAY',def=def}; local other=npc(1,2,3) + local nb={map=awayMap,peers={other},cx=20,cy=-10} + w.neighbors={nb}; w.ghosts={{npc=other,nb=nb,ox=320,oy=-160}} + local ghosts=Ghosts.forDraw(row[3]); T.eq(#ghosts,1,'peer/ghost duplicate has one live view') + local gv=ghosts[1]; T.check(gv~=v); T.eq(gv.px,32); T.eq(gv.py,48,'offsets are applied by renderer once') + T.eq(gv.originMapId,row[3]); T.eq(Objects.findObjectByLocalIdAndMap(1,row[1],row[2]),gv) + T.eq(Objects.find(1),v,'same local id on another map does not alias') + collectgarbage('collect'); T.eq(Objects.find(1),v,'held views retain their identity across collection') + T.eq(Ghosts.visibleIds(row[3])[1],true); T.eq(#Ghosts.forDraw(f.canonical),0) + T.eq(#Ghosts.forDraw('DOES_NOT_EXIST'),0,'unknown map never borrows the home pool') + -- Native removal irrevocably retires observed handles. + w.npcs={b}; w.entities={f.player,b}; T.eq(v.px,nil) + w.npcs={a,b}; w.entities={f.player,a,b}; T.eq(v.px,nil) + local replacement=Objects.find(1); T.check(replacement~=v,'reinserted actor gets fresh handle') + T.raises(function() v.hidden=true end,'retired','retired handle cannot affect reinserted actor') + w.neighbors={}; w.ghosts={}; T.eq(gv.px,nil) + w.neighbors={nb}; w.ghosts={{npc=other,nb=nb,ox=320,oy=-160}}; T.eq(gv.px,nil) + -- Save, loader, dataset, player, map and generation swaps all retire views. + for _,replace in ipairs({ + function() g.save={player={}} end, + function() g.mods={} end, + function() local maps={}; for k,value in pairs(g.data.maps) do maps[k]=value end; g.data.maps=maps end, + function() local data={}; for k,value in pairs(g.data) do data[k]=value end; g.data=data end, + function() w.player=R.copy(f.player) end, + function() w.map={id=f.map.id,def=f.map.def,inBounds=f.map.inBounds} end, + }) do + local old=assert(Objects.find(1)); replace(); T.eq(old.px,nil) + T.raises(function() old.facing='left' end,'retired','cohort switch rejects stale writes') + T.check(Objects.find(1)~=old) + end + local old=Objects.find(1); Objects.clear(); T.eq(old.px,nil); T.eq(#w.npcs,2,'clear does not clear native NPCs') + T.check(Objects.find(1)~=old) + local live=Objects.find(1); Version.set('crystal'); T.eq(live.px,nil); Version.set(edition) + T.check(Objects.find(1)~=live,'a retired cohort cannot revive after edition restoration') + Version.set('crystal'); T.eq(Objects.find(1),nil) +end +T.finish('native object views') diff --git a/tools/object-runtime/lifecycle-check.lua b/tools/object-runtime/lifecycle-check.lua new file mode 100644 index 000000000..d90954e24 --- /dev/null +++ b/tools/object-runtime/lifecycle-check.lua @@ -0,0 +1,66 @@ +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').new('emerald') +local Native=require('src.world.NPC') +local actor=setmetatable({id='TEST_obj_1',def={index=1,graphicsId=42,elevation=3}, + cellX=5,cellY=5,px=80,py=80,facing='down',moving=false,progress=0},Native) +f.world.npcs={actor}; f.world.entities={f.player,actor} +local api=tonumber(os.getenv('SESSION_RUNTIME_API')) or require('src.core.Version').modApi +assert(api==2 or api==3) +T.eq(package.loaded['src.core.game3.objects'],nil,'first source import is exercised inside loader 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_objects',200,[[ + require('src.core.game3.objects').forDraw=function()error('failed wrapper survived')end + error('intentional object entry failure') +]]) +mod('source_objects',100,[[ + local mod=... + require('src.core.Game3') + local O=require('src.core.game3.objects') + local base=O.forDraw; mod.exports.base=base; mod.exports.calls=0 + O.forDraw=function(...) + mod.exports.calls=mod.exports.calls+1 + return base(...) + end + mod.exports.observe=function()return O.find(1),O.forDraw()end + mod.events:on('game.ready',function(p)mod.exports.game=p.game end) +]]) +mod('native_objects_peer',0,[[ + local mod=... + mod.events:on('game.ready',function(p)mod.exports.game=p.game end) +]]) +local run=T.sdk.loadMods({'mods/bad_objects','mods/source_objects','mods/native_objects_peer'}, + {data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) +T.eq(#run.errors,1,'only the intentional failed entry') +local g=f.game; g.mods=run.loader; run.loader.game=g +local source,peer=run.loader.exports.source_objects,run.loader.exports.native_objects_peer +run.loader.events:emit('game.ready',setmetatable({game=g},{__index=g})) +T.eq(source.game,require('src.core.Game3').forNative(g)); T.eq(peer.game,g) +local view,list=source.observe() +T.check(view~=actor); T.eq(view.localId,1); T.eq(list[1],view); T.eq(source.calls,1) +T.eq(g.overworld.entities[2],actor,'native peer keeps its actual actor') +view.hidden=true; T.eq(actor.hidden,true); T.eq(#select(2,source.observe()),0) +view.hidden=false +-- Reload uses the same native owner; view retirement must come from loader +-- teardown, not from a coincidental save/world replacement in the fixture. +local nextRun=T.sdk.loadMods({'mods/bad_objects','mods/source_objects','mods/native_objects_peer'}, + {data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) +T.eq(view.px,nil,'reload retires old object handles after wrapper unwind') +T.eq(#f.world.npcs,1,'reload leaves the native cast intact') +g.mods=nextRun.loader; nextRun.loader.game=g +source=nextRun.loader.exports.source_objects +local nextView,nextList=source.observe(); T.check(nextView~=view); T.eq(nextList[1],nextView) +local O=require('src.core.game3.objects') +require('src.mods.Loader').teardown() +T.eq(nextView.px,nil,'exit retires source handles with the native game still live') +T.eq(O.forDraw,source.base,'exit unwinds the successful wrapper') +T.eq(#f.world.npcs,1); T.eq(f.world.npcs[1],actor) +T.eq(O.find(1).px,80,'a new observation can adopt the unchanged native cast') +nextRun.release(); run.release() +T.finish('object views through SDK lifecycle API '..api) From 5a233e75fdd4f20ff5102433402df288fc774955 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 22:32:38 +0100 Subject: [PATCH 2/3] Keep native contexts private at shared object boundaries --- src/compat/gen1/ObjectRuntime.lua | 5 ++++- src/core/game3/objects.lua | 10 ++++++++-- tools/object-runtime/check.lua | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/compat/gen1/ObjectRuntime.lua b/src/compat/gen1/ObjectRuntime.lua index d8dea66dd..89c1948fb 100644 --- a/src/compat/gen1/ObjectRuntime.lua +++ b/src/compat/gen1/ObjectRuntime.lua @@ -7,7 +7,10 @@ local directions = {up=true,down=true,left=true,right=true} local function finite(v) return type(v)=='number' and v==v and math.abs(v)=lo and v<=hi end local function copy(v,seen) - if type(v)~='table' then if type(v)=='function' then return nil end; return v end + if type(v)~='table' then + if type(v)=='string' or type(v)=='number' or type(v)=='boolean' then return v end + return nil + end seen=seen or {}; if seen[v] then return seen[v] end local out={}; seen[v]=out for k,value in pairs(v) do if type(k)=='string' or type(k)=='number' then out[k]=copy(value,seen) end end diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index e47d9d923..688fae650 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -102,13 +102,19 @@ end function Objects.loadMap(game,mapId) local c,why=runtime:current(game); if not c then return nil,why end if mapId~=c.id then return nil,'object loading belongs to the native map transition' end - return runtime:pool(mapId,game) + local list,why=runtime:pool(mapId,game) + if not list then return nil,why end + return list end function Objects.clear() runtime:release() end Objects.reset=Objects.clear Objects.release=Objects.clear -- Internal shared observer for the separate, map-local neighbour facade. -function Objects._pool(mapId) return runtime:pool(mapId) end +function Objects._pool(mapId) + local list,why=runtime:pool(mapId) + if not list then return nil,why end + return list +end setmetatable(Objects,{__index=function(_,key) if key=='_mapId' then local c=runtime:current(); return c and c.id end if key=='_byId' or key=='_order' then diff --git a/tools/object-runtime/check.lua b/tools/object-runtime/check.lua index c2ae63727..ae995c63d 100644 --- a/tools/object-runtime/check.lua +++ b/tools/object-runtime/check.lua @@ -27,6 +27,8 @@ for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'}) do T.eq(Objects.find(1),v,'stable live object view'); T.eq(v.localId,1); T.eq(v.id,a.id) T.eq(v.originMapId,f.canonical); T.eq(Objects._mapId,f.canonical) T.eq(v.graphicsId,42); T.eq(v.currentElevation,3); T.eq(v.visible,true) + T.eq(select(2,Objects.loadMap(g,f.canonical)),nil,'adopting a pool never lends its private native context') + T.eq(select(2,Objects._pool(f.canonical)),nil,'shared neighbour entry returns only owned views') T.eq(v.def.passable,false,'definition copy retains false values') v.def.script[1]='detached'; T.eq(a.def.script[1],'native','nested definitions are owned') v.customExtension={probe=true}; T.eq(a.customExtension,nil) From 454b9e95d9510080e04229ef7b953dc7a3b72d8d Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 21:25:35 +0100 Subject: [PATCH 3/3] Keep source followers out of the ordinary NPC draw pool --- src/core/game3/objects.lua | 7 ++++++- tools/object-runtime/check.lua | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index 688fae650..55596ee44 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -26,7 +26,12 @@ end function Objects.forDraw() local out={}; local list,c=runtime:pool(); if not list then return out end for _,v in ipairs(list) do - if v.visible and not(v.hidden or v.invisible) and (v.moving or v.scriptBusy or c.map:inBounds(v.cellX,v.cellY)) then out[#out+1]=v end + -- The source follower has its own actor/presentation contract. Keep its + -- native NPC available to current/find and the native update lists, but + -- do not emit it again as ordinary map artwork (or as a player fallback). + local actor=runtime:actor(v) + if actor and actor.optionalFollower~=true and v.visible and not(v.hidden or v.invisible) + and (v.moving or v.scriptBusy or c.map:inBounds(v.cellX,v.cellY)) then out[#out+1]=v end end return out end diff --git a/tools/object-runtime/check.lua b/tools/object-runtime/check.lua index ae995c63d..9ceefd294 100644 --- a/tools/object-runtime/check.lua +++ b/tools/object-runtime/check.lua @@ -35,6 +35,13 @@ for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'}) do T.eq(Objects._byId[1],v); T.same(Objects._order,{1,2}) local ids=Objects._order; ids[1]=99; T.same(Objects._order,{1,2},'external lists cannot replace native actors') T.same(Objects.listActive(),{1,2}); T.eq(#Objects.forDraw(),2) + -- Source-only flags cannot disguise native actors; a real optional + -- follower belongs to its separate presentation path, retaining lookup. + v.optionalFollower=true;T.eq(a.optionalFollower,nil);T.eq(#Objects.forDraw(),2) + b.optionalFollower=true + local ordinary=Objects.forDraw();T.eq(#ordinary,1);T.eq(ordinary[1],v) + T.eq(Objects.find(2).localId,2);T.eq(#w.entities,3,'draw filtering preserves the native cast') + b.optionalFollower=nil;v.optionalFollower=nil;T.eq(#Objects.forDraw(),2) T.eq(Objects.at(5,5),v); T.eq(Objects.at(0/0,5),nil) T.eq(Objects.blocks(5,5),true); T.eq(Objects.blocks(5,5,1),false) T.eq(Objects.blocks(5,5,nil,4),false); T.eq(Objects.blocks(5,5,nil,0),true)