diff --git a/.github/workflows/native-script-coroutine.yml b/.github/workflows/native-script-coroutine.yml new file mode 100644 index 000000000..79da76425 --- /dev/null +++ b/.github/workflows/native-script-coroutine.yml @@ -0,0 +1,14 @@ +name: Native script coroutine ownership +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - run: luajit tests/engine/native_coroutine_phase_scopes.lua diff --git a/docs/native-money-command-layouts.md b/docs/native-money-command-layouts.md new file mode 100644 index 000000000..c51f7021e --- /dev/null +++ b/docs/native-money-command-layouts.md @@ -0,0 +1,18 @@ +# Native money-window script layouts + +`updatemoneybox` ($95) consumes three bytes (x, y, ignore) in all five GBA +editions. Treating it as operand-free decodes those bytes as commands. +`hidemoneybox` ($94) consumes x/y in Ruby/Sapphire, whereas the later games +removed both reads. Resolve this distinction per extractor, without mutating +the shared command table or duplicating manifests. Discovery uses the corrected common +update width. Only GBA import markers change; save schemas and files do not. + +Primary references: + +- https://github.com/pret/pokefirered/blob/master/src/scrcmd.c +- https://github.com/pret/pokeemerald/blob/master/src/scrcmd.c +- https://github.com/pret/pokeruby/blob/master/src/scrcmd.c + +`tools/script-command-widths/check.lua` decodes sentinel streams for every +edition and switches RS/later tables in both directions. It checks both +following assignments and the final terminator, not only the declared widths. diff --git a/docs/native-script-space.md b/docs/native-script-space.md new file mode 100644 index 000000000..ac1adf19b --- /dev/null +++ b/docs/native-script-space.md @@ -0,0 +1,54 @@ +# Field script observations over native execution + +The `src.core.game3.scripting.space` module binds an unchanged consumer to the +actual GBA field runner, numeric command-backed flags/vars and current script +context. It does not load Game, create another world, write a save file or tick +a second field loop. Native map-entry and idle-frame callbacks open scopes; +source wrappers can consume the underlying callback once, or defer it. Native +runner update alone opens the VM tick scope. Extra render/query calls cannot +consume waits, and a wrapper cannot hide a failed native body and retry it. + +Map entry temporarily exposes the actual entering world before StateStack has +published it. The scope restores on errors and nested visits. Map/runner/save/ +dataset/loader/edition changes retire held VM views. Dataset projections survive +ordinary map changes. Exact owned cancellation uses native lock/fade cleanup +without inventing a trainer result, and declines foreign owners or active UI/ +movement that has not finished. Preview replacements suspend live writes; loader +rollback releases only replacements belonging to the failed mod. + +Connected-map object observations run the existing compiler and a bounded, +restricted native command pass on detached flags/vars/home templates. They do +not dispatch script hooks or script lifecycle events, borrow live entities, or +invoke UI/audio/IO commands. A loop exhausts the budget rather than blocking +the game. No preview result is committed to the live save. + +The import retains opcode/address, raw operand payload/specification and the +original special number alongside normalized native IR. GBA cache markers +automatically require regeneration; player saves are unchanged. Script/event +projections are owned copies. Edited/authored/replaced script lists currently +fail explicitly instead of silently executing an unchanged native list. + +## Verification + +- `tools/script-space/check.lua`: actual native compiler/runner and loader + rollback, all five edition identities; phase ordering, duplicate calls, + waits, retired views, preview isolation, bounded neighbour evaluation, + command ownership and operand projections. +- `tools/script-space/oracle-host`: actual five-cartridge map/script extraction, + unchanged normalized IR against the corrected decoder before this metadata, + raw-byte checks, independent original disassembly and Lua cache round-trip. + The test reads caller-provided cartridges; none are distributed. +- The oracle visibly corrects reference-table discrepancies for money-window + commands against the actual read counts in the primary sources linked in + `native-money-command-layouts.md`. It does not pad missing operands with zeros. + +## Remaining integration + +This is not complete script API or game acceptance. Rich text/control-code and +canonical movement IR, symbolic label/flag catalogues, an overlay compiler for +authored/edited scripts, arbitrary source VM construction and full immediate/ +return-to-field scheduling remain open. The native compiler still has unsupported +command lowerings. Full field services, complete session collections, renderer, +shaders, UI, battle, audio/RTC and all-edition gameplay/coexistence acceptance +remain required. The immutable full Origami package must be probed again after +each concrete bridge; module presence is not a claim that the game runs. diff --git a/src/compat/gen1/CoroutineScopes.lua b/src/compat/gen1/CoroutineScopes.lua new file mode 100644 index 000000000..d4c337d55 --- /dev/null +++ b/src/compat/gen1/CoroutineScopes.lua @@ -0,0 +1,96 @@ +-- Dynamic native scopes may be borrowed by an actively resumed child. A +-- suspended child cannot lend its scope to an unrelated native operation. +local M={} +local MAIN={} +-- Retirement crosses phase/attribution registries. A source coroutine that +-- outlives a borrowed native phase cannot acquire another caller's authority. +local revoked=setmetatable({},{__mode='k'}) +local function thread() + local co,main=coroutine.running() + return main and MAIN or co or MAIN +end +function M.retired()return revoked[thread()]==true end +local function visible(frame,co) + if frame.closed then return false end + local creator=frame.creator + return creator==co or creator==MAIN or coroutine.status(creator)=='normal' +end +local function previous(frame,key) + repeat frame=frame and frame[key] until not frame or not frame.closed + return frame +end +local function within(frame,ancestor) + while frame do + if frame==ancestor then return true end + frame=frame.parent + end + return false +end +local function selectFrame(head,key,co) + local frame=head + -- A resumed coroutine's own phase wins over an unrelated caller's newer + -- phase. Creation order alone does not describe coroutine ancestry. + while frame do + if not frame.closed and frame.creator==co then return frame end + frame=frame[key] + end + local selected + frame=head + while frame do + if visible(frame,co)then + if not selected or within(frame,selected)then selected=frame + elseif not within(selected,frame)then + -- Lua exposes running/suspended status, not a resumer ancestry API. + -- Independent native roots cannot safely lend a phase to this child. + return nil,'ambiguous native coroutine scope' + end + end + frame=frame[key] + end + return selected +end +function M.new() + -- Weak heads do not keep an abandoned suspended coroutine (and the native + -- body on its stack) alive. Live dispatch calls retain their own frame. + local names=setmetatable({},{__mode='v'}) + local latest=setmetatable({},{__mode='v'}) + local locals=setmetatable({},{__mode='kv'}) + local stack={} + function stack:find(name) + local co=thread() + if revoked[co] or locals[co]and locals[co].closed then return nil,'native coroutine scope retired'end + return selectFrame(names[name],'previousName',co) + end + function stack:push(name,value,parent) + local co=thread() + assert(not revoked[co]and not(locals[co]and locals[co].closed),'native coroutine scope retired') + parent=parent or selectFrame(latest[1],'previousAll',co) + assert(not parent or not parent.closed,'native coroutine scope retired') + local frame={name=name,value=value,creator=co,parent=parent, + previousName=names[name],previousAll=latest[1],previousLocal=locals[co]} + if parent then + parent.children=parent.children or setmetatable({},{__mode='k'}) + parent.children[frame]=true + end + names[name],latest[1],locals[co]=frame,frame,frame + return frame + end + local function retire(frame,forced) + if frame.closed then return end + frame.closed=true + if forced and frame.creator~=MAIN and frame.creator~=thread()then revoked[frame.creator]=true end + -- A parent returning retires nested dispatches that yielded in a child. + -- Their later finally blocks cannot restore an expired native phase. + if frame.children then for child in pairs(frame.children)do retire(child,true)end end + if frame.parent then frame.parent.children[frame]=nil end + if names[frame.name]==frame then names[frame.name]=previous(frame,'previousName')end + if latest[1]==frame then latest[1]=previous(frame,'previousAll')end + frame.value,frame.body,frame.result=nil,nil,nil + end + function stack:close(frame) + retire(frame,false) + if locals[frame.creator]==frame then locals[frame.creator]=frame.previousLocal end + end + return stack +end +return M diff --git a/src/compat/gen1/ScriptBoundary.lua b/src/compat/gen1/ScriptBoundary.lua new file mode 100644 index 000000000..66093de5f --- /dev/null +++ b/src/compat/gen1/ScriptBoundary.lua @@ -0,0 +1,38 @@ +-- Native owners open a scope; source base calls can consume its body once. +-- Queries made between native phases cannot advance another script clock. +local M={} +local scopes=require('src.compat.gen1.CoroutineScopes').new() +local BODY={} +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.invoke(name) + local s=scopes:find(name) + if not s then return nil,'script operation belongs to its native phase'end + if s.failed then error(s.failure,0)end + if not s.called then + s.called=true + local claim=scopes:push(BODY,nil,s) + local r=pack(pcall(s.body)) + local retired=s.closed or claim.closed + scopes:close(claim) + s.body=nil + if not r[1]then s.failed=true;s.failure=r[2];error(r[2],0)end + if retired then error('native script phase retired while suspended',0)end + s.result={n=r.n-1};for i=2,r.n do s.result[i-1]=r[i]end + end + if not s.result then return nil,'native script phase is already executing' end + return unpack(s.result,1,s.result.n) +end +function M.dispatch(name,body,source,...) + local s=scopes:push(name);s.body=body + local result=pack(pcall(source,...)) + local retired,failed,failure=s.closed,s.failed,s.failure + scopes:close(s) + -- A wrapper cannot swallow a failing native command and retry it. + if failed then error(failure,0)end + if retired then error('native script phase retired while suspended',0)end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M.active(name)return scopes:find(name)~=nil end +return M diff --git a/src/compat/gen1/ScriptBundle.lua b/src/compat/gen1/ScriptBundle.lua new file mode 100644 index 000000000..3b0fa178e --- /dev/null +++ b/src/compat/gen1/ScriptBundle.lua @@ -0,0 +1,174 @@ +-- Owned script/event observations; the native compiler remains the executor. +local Identity=require('src.compat.gen1.MapIdentity') +local Header=require('src.import.Gen3ScriptInterface') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local records=setmetatable({},{__mode='k'}) +function M.copy(value,seen) + if type(value)~='table'then return value end + seen=seen or{};if seen[value]then return seen[value]end + local out={};seen[value]=out + for k,v in pairs(value)do out[M.copy(k,seen)]=M.copy(v,seen)end + return out +end +function M.sourceKey(key) + if type(key)=='number'then + if key>=0x08000000 and key<0x0a000000 then return ('g3:%08x'):format(key)end + return key + end + if type(key)~='string'then return key end + local hex=key:match('^[SM](%x+)$') + if hex then return ('g3:%08x'):format(0x08000000+tonumber(hex,16))end + return key +end +function M.nativeKey(key,prefix) + if type(key)=='number'then + if key<0x08000000 or key>=0x0a000000 then return nil end + return (prefix or'S')..('%07X'):format(key-0x08000000) + end + if type(key)~='string'then return nil end + local hex=key:match('^g3:(%x+)$') + return hex and M.nativeKey(tonumber(hex,16),prefix)or key +end +local aliases={loadword={'dest','value'},setvar={'var','value'},compare_var_to_value={'var','value'}, + setflag={'flag'},clearflag={'flag'},checkflag={'flag'},setworldmapflag={'flag'},callstd={'std'},gotostd={'std'}, + ['goto']={'target'},call={'target'},goto_if={'cond','target'},call_if={'cond','target'}, + applymovement={'localId','movement'},applymovementat={'localId','movement'},waitmovement={'localId'}, + removeobject={'localId'},addobject={'localId'},message={'ptr'},special={'id'},specialvar={'var','id'}, + textcolor={'color'},setobjectxyperm={'localId'},setobjectmovementtype={'localId'}, + callnative={'fn'},gotonative={'fn'}} +local targets={['goto']=1,call=1,goto_if=2,call_if=2,vgoto=1,vcall=1,vgoto_if=2,vcall_if=2} +local trainerPointers={ + [0]={'introText','defeatText'},[1]={'introText','defeatText','eventScript'}, + [2]={'introText','defeatText','eventScript'},[3]={'defeatText'}, + [4]={'introText','defeatText','notEnoughText'},[5]={'introText','defeatText'}, + [6]={'introText','defeatText','notEnoughText','eventScript'}, + [7]={'introText','defeatText','notEnoughText'},[8]={'introText','defeatText','notEnoughText','eventScript'}, + [9]={'introText','defeatText'},[10]={'introText','defeatText'},[11]={'introText','defeatText'},[12]={'introText','defeatText'}, +} +function M.row(ir,edition) + local row={op=ir[1],opcode=ir.sourceOpcode} + if ir.sourceSpec=='*'and type(ir.sourceBytes)=='string'then + local bytes=ir.sourceBytes + local function number(at,n) + local value=0;for i=0,n-1 do value=value+assert(bytes:byte(at+i),'truncated trainer operands')*256^i end;return value + end + row.type=number(1,1);row.trainer=number(2,2);row.localId=number(4,2) + row[1],row[2]=row.trainer,row.localId + local fields=trainerPointers[row.type] + if row.type==9 and(edition=='firered'or edition=='leafgreen')then fields={'defeatText','victoryText'};row.flags=row.localId end + assert(fields,'original trainer battle type unavailable') + assert(#bytes==5+#fields*4,'trainer operand size differs') + for i,name in ipairs(fields)do row[name]=M.sourceKey(number(6+(i-1)*4,4))end + elseif ir.sourceSpec and type(ir.sourceBytes)=='string'then + local at=1 + for i=1,#ir.sourceSpec do + local width=({b=1,w=2,d=4})[ir.sourceSpec:sub(i,i)] + assert(width,'invalid original operand specification') + local value=0 + for n=0,width-1 do value=value+assert(ir.sourceBytes:byte(at+n),'truncated original operands')*256^n end + row[i]=value;at=at+width + end + assert(at==#ir.sourceBytes+1,'original operand size differs') + else + assert(ir[1]~='trainerbattle','original trainer-battle projection is not yet available') + for i=2,#ir do row[i-1]=M.copy(ir[i])end + end + if ir[1]=='special'or ir[1]=='specialvar'then + assert(type(ir.sourceSpecial)=='number','original script operands unavailable; cartridge cache needs regeneration') + row[ir[1]=='special'and 1 or 2]=ir.sourceSpecial + end + local target=targets[row.op] + if target then row[target]=M.sourceKey(row[target])end + if row.op=='message'or row.op=='vmessage'or row.op=='braillemessage'or row.op=='getbraillestringwidth' + or row.op=='messageautoscroll'or row.op=='messageinstant'or row.op=='pokenavcall'then + row[1]=M.sourceKey(row[1]);row.ptr=row[1] + end + if row.op=='loadword'then row[2]=M.sourceKey(row[2])end + if row.op=='bufferstring'then row[2]=M.sourceKey(row[2]);row.src=row[2]end + if row.op=='applymovement'or row.op=='applymovementat'then row[2]=M.sourceKey(row[2])end + for i,name in ipairs(aliases[row.op]or{})do row[name]=row[i]end + return row +end +local phaseNames={[1]='onLoad',[2]='onFrame',[3]='onTransition',[4]='onWarpIntoMap', + [5]='onResume',[6]='onDiveWarp',[7]='onReturnToField'} +function M.new(data,edition,valid) + local pool=data and data.map_scripts + if not(pool and pool.source=='RomExtractorGen3'and type(pool.scripts)=='table')then + return nil,'native script bundle unavailable' + end + local binding=Identity.bind(data.maps,edition) + local bundle={scripts={},events={},labels={},fromNative=true} + local cache,expected={},{} + records[bundle]={cache=cache,expected=expected,scripts=bundle.scripts} + -- Materialize a list only when a consumer asks for it. Mutating this owned + -- list never mutates the compiler cache or another mod's native definitions. + local function script(key) + assert(valid(),'native script bundle retired') + local native=M.nativeKey(key) + native=pool.labels and(pool.labels[native]or native)or native + local rows=pool.scripts[native];if not rows then return nil end + local canonical=M.sourceKey(native);local cached=cache[canonical] + if cached then return cached end + local out={};for i,ir in ipairs(rows)do out[i]=M.row(ir,edition)end + cache[canonical]=out;expected[canonical]=M.copy(out);return out + end + setmetatable(bundle.scripts,{__index=function(_,key)return script(key)end}) + Iteration.register(bundle.scripts,function() + assert(valid(),'native script bundle retired') + local out={};for key in pairs(pool.scripts)do local source=M.sourceKey(key);out[source]=script(source)end;return out + end) + for key,label in pairs(pool.labels or{})do bundle.labels[key]=M.sourceKey(label)end + bundle.nativeMovementsAvailable=type(pool.movements)=='table' + -- Native text definitions retain their native keys. The text-IR service + -- owns address/name projection and richer control-code semantics. + bundle.nativeTextAvailable=type(data.text)=='table' + for nativeId,entry in pairs(pool.maps or{})do + local id=binding:legacyId(nativeId) + local def=data.maps[nativeId] + if id and def then + local ev={objects=M.copy(def.objects or{}),bgEvents=M.copy(def.signs or{}),coordEvents=M.copy(def.coordEvents or{}), + mapScripts={onFrame={},onWarpIntoMap={}},music=def.music} + for i,obj in ipairs(ev.objects)do + obj.localId=obj.localId or obj.index;obj.graphics=obj.graphicsId;obj.flag=obj.flagId or obj.flag + obj.scriptKey=M.sourceKey((entry.objects or{})[i]) + end + for i,sign in ipairs(ev.bgEvents)do sign.scriptKey=M.sourceKey((entry.signs or{})[i])end + for _,cb in ipairs(entry.callbacks or{})do + local name=phaseNames[cb.type];if name then ev.mapScripts[name]=M.sourceKey(cb.script)end + end + for _,tbl in ipairs(entry.tables or{})do + local name=phaseNames[tbl.type] + if name then + local rows={};for i,r in ipairs(tbl.rows or{})do rows[i]={var=r.var,value=r.value,script=M.sourceKey(r.script)}end + ev.mapScripts[name]=rows + end + end + for i,coord in ipairs(entry.coords or{})do + ev.coordEvents[i]=M.copy(coord);ev.coordEvents[i].scriptKey=M.sourceKey(coord.script) + end + bundle.events[id]=ev + end + end + local header=pool.sourceInterface + bundle.originalOperandsAvailable=header and header.schema==Header.SCHEMA and header.edition==edition or false + return bundle +end +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +function M.validate(bundle) + local record=records[bundle] + if not record then return false,'replacement script bundles require the native overlay compiler'end + if bundle.scripts~=record.scripts then return false,'replacement script lists require the native overlay compiler'end + if Iteration.rawNext(bundle.scripts)~=nil then return false,'authored script lists require the native overlay compiler'end + for k,v in pairs(record.cache)do + if not same(v,record.expected[k])then return false,'edited script rows require the native overlay compiler'end + end + return true +end +return M diff --git a/src/compat/gen1/ScriptPreview.lua b/src/compat/gen1/ScriptPreview.lua new file mode 100644 index 000000000..633a001ca --- /dev/null +++ b/src/compat/gen1/ScriptPreview.lua @@ -0,0 +1,37 @@ +-- A bounded native command pass over detached state. It never publishes a +-- field, borrows a live runner, dispatches mod hooks or starts UI/audio/IO. +local Bundle=require('src.compat.gen1.ScriptBundle') +local M={} +local admission={maxInstructions=4000,commands={ + label=true,jump=true,g3_call=true,g3_return=true,g3_jump_if=true,g3_jump_unless=true,g3_call_if=true, + g3_setvar=true,g3_addvar=true,g3_subvar=true,g3_copyvar=true,g3_setorcopyvar=true, + g3_compare_value=true,g3_compare_var=true,g3_check_flag=true, + g3_place_perm=true,g3_movement_type=true, +}} +function M.run(c,nativeId) + local snapshot=c.backend:snapshot() + local save={flags={},gen3Vars=Bundle.copy(snapshot.vars)} + for id,value in pairs(snapshot.flags)do save.flags[('FLAG_G3_%04X'):format(id)]=value end + for id=0x4010,0x401f do save.gen3Vars[id]=nil end + local entry=c.pool and c.pool.maps and c.pool.maps[nativeId] + local key + for _,cb in ipairs(entry and entry.callbacks or{})do if cb.type==3 then key=cb.script;break end end + if key then + local rows=require('src.script.Gen3ScriptVM').compile(c.data,key) + if not rows then return nil,'native neighbour transition did not compile'end + local runner=require('src.script.ScriptRunner').new({data=c.data,save=save}) + local ctx=runner:makeContext({mapId=nativeId,source={strict=true}}) + local ok,why=pcall(runner.exec,runner,rows,ctx,admission) + if not ok then return nil,why end + end + local flags,vars={},{} + for name,value in pairs(save.flags)do local hex=name:match('^FLAG_G3_(%x%x%x%x)$');if hex then flags[tonumber(hex,16)]=value end end + for id,value in pairs(save.gen3Vars)do if not c.backend:isSpecial(id)then vars[id]=value end end + local result={store={flags=flags,vars=vars},perm={},movementType={}} + for id,home in pairs(save.gen3ObjectHomes and save.gen3ObjectHomes[nativeId]or{})do + if home.x~=nil and home.y~=nil then result.perm[id]={x=home.x,y=home.y}end + result.movementType[id]=home.movementType + end + return result +end +return M diff --git a/src/compat/gen1/ScriptRuntime.lua b/src/compat/gen1/ScriptRuntime.lua new file mode 100644 index 000000000..463da7a51 --- /dev/null +++ b/src/compat/gen1/ScriptRuntime.lua @@ -0,0 +1,277 @@ +-- Source scripting observations and commands over the existing native runner. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Identity=require('src.compat.gen1.MapIdentity') +local Store=require('src.compat.gen1.ScriptStore') +local Maps=require('src.compat.gen1.ScriptMap') +local Bundle=require('src.compat.gen1.ScriptBundle') +local Iteration=require('src.compat.gen1.TableIteration') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local g=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or g and g._callbackOwner and g._callbackOwner() +end +function R.new()return setmetatable({},R)end +function R:owner()return owner()end +function R:cancelOwner(who) + local c=self.context + if c then + for _,key in ipairs({'vm','store'})do + if c.preview[key..'Owner']==who then + c.preview[key],c.preview[key..'Set'],c.preview[key..'Owner']=nil,nil,nil + end + end + end + if self.dataset and self.dataset.overrideOwner==who then self.dataset.override,self.dataset.overrideOwner=nil,nil end +end +function R:retireContext() + local c=self.context + if c then c.life.retired=true;c.backend:release()end + self.context=nil +end +function R:release() + self:retireContext() + if self.dataset then self.dataset.life.retired=true end + self.dataset=nil +end +function R:current(game,world) + local g,why=Native.game(game);if not g then self:release();return nil,why end + if not world then local _,w=Native.resolve(g);world=w end + if world and not(type(world)=='table'and world.isOverworld and world.player and world.map + and g.data and g.data.maps and g.data.maps[world.map.id]==world.map.def)then world=nil end + if not(g.data and type(g.data.maps)=='table'and type(g.save)=='table')then + self:release();return nil,'native script dataset unavailable' + end + local c=self.context;local map=world and world.map + if c and(c.game~=g or c.world~=world or c.map~=map or c.data~=g.data or c.maps~=g.data.maps + or c.save~=g.save or c.loader~=g.mods or c.pool~=g.data.map_scripts or c.edition~=Version.get() + or c.life.runner~=(world and world.runner))then self:retireContext();c=nil end + local dataset=self.dataset + if dataset and(dataset.game~=g or dataset.data~=g.data or dataset.maps~=g.data.maps or dataset.save~=g.save + or dataset.loader~=g.mods or dataset.pool~=g.data.map_scripts or dataset.edition~=Version.get())then + dataset.life.retired=true;self.dataset=nil;dataset=nil + end + if not dataset then + dataset={game=g,data=g.data,maps=g.data.maps,save=g.save,loader=g.mods,pool=g.data.map_scripts,edition=Version.get()} + local held={};for k,v in pairs(dataset)do held[k]=v end;dataset.life=held + dataset.bundle,dataset.error=Bundle.new(g.data,dataset.edition,function() + return not held.retired and held.data==g.data and held.maps==g.data.maps and held.save==g.save + and held.loader==g.mods and held.pool==g.data.map_scripts and held.edition==Version.get() + end) + self.dataset=dataset + end + if not c then + c={game=g,world=world,map=map,data=g.data,maps=g.data.maps,save=g.save,loader=g.mods,pool=g.data.map_scripts,edition=Version.get()} + c.binding=Identity.bind(c.maps,c.edition);c.id=map and c.binding:legacyId(map.id) + c.backend=assert(Store.new(g)) + local life={game=g,data=c.data,maps=c.maps,save=c.save,loader=c.loader,pool=c.pool,edition=c.edition, + world=world,map=map,runner=world and world.runner} + c.life=life + local function valid() + local _,observed=Native.resolve(g) + return not life.retired and g.data==life.data and g.save==life.save + and g.mods==life.loader and g.data.maps==life.maps and g.data.map_scripts==life.pool and Version.get()==life.edition + and(not life.world or observed==life.world and life.world.map==life.map and life.world.runner==life.runner) + end + c.valid=valid;c.preview={};self.context=c + local backend,preview=c.backend,c.preview + local guarded=setmetatable({},{__index=function(_,key) + local fn=backend[key] + if type(fn)~='function'then return fn end + return function(_,...) + assert(valid(),'native script view retired') + if key=='setFlag'or key=='setVar'or key=='clearVar'then + assert(not(preview.vmSet or preview.storeSet or preview.disabled),'native commands suspended by source preview lease') + end + return fn(backend,...) + end + end}) + local flags,vars=Maps.new(guarded,'flags'),Maps.new(guarded,'vars') + c.store=setmetatable({},{__index=function(_,key) + assert(valid(),'native script view retired') + if key=='flags'then return flags elseif key=='vars'then return vars end + end,__newindex=function(_,key,value) + assert(key=='flags'or key=='vars','invalid script store field') + Maps.replace(guarded,key,value) + end}) + Iteration.register(c.store,function(s) + assert(s.valid(),'native script view retired');return s.backend:snapshot() + end,{valid=valid,backend=backend}) + c.dataset=dataset;c.bundle,c.bundleError=dataset.bundle,dataset.error + if world and world.runner and c.bundle then c.vm=self:vm(c)end + end + return c +end +function R:bundle() + local c,why=self:current();if not c then return nil,why end + return c.dataset.override or c.bundle,c.bundleError +end +function R:key(c,key) + local native=Bundle.nativeKey(key) + local labels=c.dataset.override and c.dataset.override.labels or c.bundle and c.bundle.labels + if labels and labels[key]then native=Bundle.nativeKey(labels[key])end + if c.pool and c.pool.scripts and c.pool.scripts[native]then return native end +end +function R:start(key,localId,facing) + local c,why=self:current();if not(c and c.world and c.vm)then return false,why or'no native field runner'end + if c.disabled or c.preview.vmSet or c.preview.storeSet then return false,'native commands are suspended by a source preview lease'end + local runner=c.world.runner + if runner:isRunning()then return false,'native field runner is busy'end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + if not intact then return false,reason end + local native=self:key(c,key);if not native then return false,'native script label unavailable'end + local rows=require('src.script.Gen3ScriptVM').compile(c.data,native) + if not rows then return false,'native script did not compile'end + for _,row in ipairs(rows)do + if row[1]=='g3_unimplemented'or row[1]=='g3_unimplemented_result'then + return false,'native script command unavailable: '..tostring(row[2]) + end + end + local npc + if localId then + assert(type(localId)=='number'and localId%1==0 and localId>=0 and localId<=255,'invalid script local id') + for _,n in ipairs(c.world.npcs or{})do if n.def and n.def.index==localId then npc=n;break end end + end + if facing then + facing=({down=1,up=2,left=3,right=4})[facing]or facing + assert(type(facing)=='number'and facing%1==0 and facing>=1 and facing<=4,'invalid script facing') + end + if localId then c.backend:setVar(0x800f,localId)end + if facing then c.backend:setVar(0x800c,facing)end + local who=owner();c.started={runner=runner,key=native,owner=who} + runner:run(rows,{npc=npc,mapId=c.map.id,source={modId=who,mapId=c.map.id,hook='source script',strict=true}}) + c.started.context=runner.ctx + return true +end +function R:halt() + local c=self:current();local run=c and c.started + if not run or c.world.runner~=run.runner or run.runner.ctx~=run.context or run.owner~=owner()then return false,'native script belongs to another owner'end + local runner=run.runner + if not runner:isRunning()then return true end + local states=c.game.stack and c.game.stack.states + if states and states[#states]~=c.world then return false,'native script UI must finish before cancellation'end + if #(c.world.scriptMoves or{})>0 then return false,'native script movements must finish before cancellation'end + -- Reuse the native cancellation cleanup, including locks/fades and the + -- script-ended notification; do not replace another runner's coroutine. + return runner:cancelOwned(run.context,runner.co) +end +function R:vm(c) + local runtime=self;local vm={} + local special=setmetatable({},{ + __index=function(_,id) + assert(c.valid(),'native script view retired');id=tonumber(id) + if not id or id%1~=0 or not c.backend:isSpecial(id)then return nil end + return c.backend:varValue(id) + end, + __newindex=function(_,id,value) + assert(c.valid(),'native script view retired');id=tonumber(id) + assert(not(c.preview.vmSet or c.preview.storeSet or c.disabled),'native commands suspended by source preview lease') + assert(id and id%1==0 and c.backend:isSpecial(id),'invalid native special variable') + if value==nil then c.backend:clearVar(id)else c.backend:setVar(id,value)end + end}) + Iteration.register(special,function(s) + assert(s.valid(),'native script view retired');local out={} + local high=(s.edition=='firered'or s.edition=='leafgreen')and 0x8014 or 0x8015 + for id=0x8000,high do out[id]=s.backend:varValue(id)end + return out + end,{valid=c.valid,backend=c.backend,edition=c.edition}) + local ctx=setmetatable({specialVars=special},{__index=function(_,key) + assert(c.valid(),'native script view retired');local native=c.world.runner.ctx + if key=='status'then return c.world.runner:isRunning()and'running'or'stopped'end + local value=native and native[key] + if type(value)~='table'and type(value)~='function'and type(value)~='userdata'then return value end + end,__newindex=function()error('script context writes require their native command',2)end}) + function vm:isRunning()return c.valid()and c.world.runner:isRunning()or false end + function vm:start(key)assert(c.valid(),'native script view retired');return runtime:start(key)end + function vm:startTalk(key,id,facing)assert(c.valid(),'native script view retired');return runtime:start(key,id,facing)end + function vm:tick()assert(c.valid(),'native script view retired');return Boundary.invoke('tick')end + function vm:halt()assert(c.valid(),'native script view retired');return runtime:halt()end + function vm:getText(key) + assert(c.valid(),'native script view retired');local b=runtime:bundle() + if not(b and b.text)then return nil,'source text control-code projection is not available'end + return b.text[key] + end + return setmetatable(vm,{__index=function(_,key) + assert(c.valid(),'native script view retired') + if key=='ctx'then return ctx end + if key=='store'then return c.preview.storeSet and c.preview.store or c.store end + if key=='scripts'or key=='text'or key=='movements'then local b=runtime:bundle();return b and b[key]end + end}) +end +function R:phase(name,game,world,mapId,body,source) + return Native.withWorld(game,world,function() + local c=self:current(game,world) + if not c or c.id==nil or not c.vm then return body()end + if c.disabled or c.preview.vmSet or c.preview.storeSet then return body()end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + assert(intact,reason) + local facade=require('src.core.Game3');local observed=assert(facade.forNative(game)) + return Boundary.dispatch(name,function() + if name=='runEnterScripts'then + c.entered=true;c.requests=c.requests or{} + local r=package.loaded['src.core.game3.runtime'];local serial=r and r.tickVblank()or 0 + local entry=c.pool.maps and c.pool.maps[c.map.id] + for _,cb in ipairs(entry and entry.callbacks or{})do c.requests[cb.type]=serial end + end + return body() + end,source,nil,c.id,observed,world) + end) +end +function R:tick(runner,body) + local c=self:current() + if not(c and c.vm and c.world.runner==runner)then return body(runner)end + -- A detached preview does not take ownership of the live game's clock. + if c.preview.vmSet or c.preview.storeSet then return body(runner)end + return Boundary.dispatch('tick',function()return body(runner)end,function()return c.vm:tick()end) +end +function R:request(kind,mapId) + local c,why=self:current() + if not(c and c.world and c.bundle)then return false,why or'no native field script space'end + if mapId and mapId~=c.id then return false,'native map is not the requested source map'end + if c.preview.vmSet or c.preview.storeSet then return false,'source preview lease is active'end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + if not intact then return false,reason end + if kind==1 or kind==3 then return c.entered==true,'native map-entry phase owns this callback'end + local rows={} + local entry=c.pool and c.pool.maps and c.pool.maps[c.map.id] + for _,cb in ipairs(entry and entry.callbacks or{})do if cb.type==kind then rows[#rows+1]=cb.script end end + if kind==4 then + for _,tbl in ipairs(entry and entry.tables or{})do if tbl.type==kind then + for _,row in ipairs(tbl.rows or{})do + if c.backend:getVar(row.var)==c.backend:getVar(row.value or 0)then rows[#rows+1]=row.script;break end + end + end end + end + local r=package.loaded['src.core.game3.runtime'];local serial=r and r.tickVblank()or 0 + c.requests=c.requests or{} + if c.requests[kind]==serial then return true,'native callback already requested'end + local compiler=require('src.script.Gen3ScriptVM');local compiled={} + for _,key in ipairs(rows)do + local code=compiler.compile(c.data,key) + if code then + for _,row in ipairs(code)do + if row[1]=='g3_unimplemented'or row[1]=='g3_unimplemented_result'then return false,'native callback command unavailable: '..tostring(row[2])end + end + compiled[#compiled+1]=code + end + end + if #compiled==0 then return false,'native callback is absent'end + assert(type(c.world.queueScript)=='function','native script queue unavailable') + c.requests[kind]=serial + for _,code in ipairs(compiled)do c.world:queueScript(code,{mapId=c.map.id})end + return true +end +function R:neighbor(mapId) + local c,why=self:current();if not(c and c.world and c.bundle)then return nil,why end + local id=c.binding:nativeId(mapId) + if not id or id==c.map.id then return nil,'not a native neighbour map'end + local found=false + for _,row in ipairs(c.world.neighbors or{})do if row.map and row.map.id==id then found=true;break end end + if not found then return nil,'requested map is not in the native connection world'end + if c.preview.vmSet or c.preview.storeSet then return nil,'source preview lease is active'end + -- Recompute from current native variables: transitions may have changed a + -- gating var without changing maps or the identity of its store. + return require('src.compat.gen1.ScriptPreview').run(c,id) +end +return R diff --git a/src/core/game3/scripting/space.lua b/src/core/game3/scripting/space.lua new file mode 100644 index 000000000..ecb37e615 --- /dev/null +++ b/src/core/game3/scripting/space.lua @@ -0,0 +1,200 @@ +-- Source-facing field scripts observe the existing native runner. Native map +-- callbacks open execution scopes; querying or rendering never ticks scripts. +local Runtime=require('src.compat.gen1.ScriptRuntime') +local Bundle=require('src.compat.gen1.ScriptBundle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local runtime=Runtime.new() +local Space={TEMP_FIELD_EVENT_FLAGS={0x807,0x842}} +local function current(game,world)return runtime:current(game,world)end +local function methodOwner(name) + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==Space and change.key==name and change.after==Space[name]then return change.owner end + end +end +local function attributed(name,fn,...) + local game=package.loaded['src.core.Game3'] + if game and game._withCallbackOwner then return game._withCallbackOwner(methodOwner(name),fn,...)end + return fn(...) +end +function Space.isActive() + local c=current();return c~=nil and c.world~=nil and c.vm~=nil and not c.disabled +end +function Space.getVm()return Space.vm end +function Space.getStore()return Space.store end +function Space.persistSession() + local c,why=current();if not c then return false,why end + -- Writes already go through native commands into the native in-memory save. + -- The normal save owner alone decides when to write a file. + return not(c.preview.vmSet or c.preview.storeSet),'native save owner persists script state' +end +function Space.ensureBundle()return runtime:bundle()end +function Space.installLabels(bundle) + bundle=bundle or Space.bundle + return bundle and bundle.labels or nil +end +function Space.scriptKey(name) + local c=current();if not c then return nil end + local key=runtime:key(c,name);return key and Bundle.sourceKey(key) +end +function Space.attachEventsToMaps(maps,bundle) + assert(type(maps)=='table','invalid source map views') + local c=current();if not c then return 0 end + assert(maps~=c.maps,'native map definitions cannot be replaced by source event views') + bundle=bundle or Space.bundle;local n=0 + for id,ev in pairs(bundle and bundle.events or{})do + local def=maps[id] + if def then + assert(def~=c.maps[c.binding:nativeId(id)],'native map definitions cannot be replaced by source event views') + for _,key in ipairs({'objects','bgEvents','coordEvents','mapScripts'})do + if ev[key]~=nil then def[key]=Bundle.copy(ev[key])end + end + if ev.music~=nil then def.music=ev.music end + n=n+1 + end + end + return n +end +function Space.activate(_mod,mapId,game,world) + local c,why=current(game,world) + if not(c and c.vm)then return nil,why or'no native field runner'end + if mapId and c.id~=mapId then return nil,'activation belongs to the native map transition'end + c.disabled=nil;c.preview.disabled=nil;return Space.vm +end +Space.retarget=Space.activate +function Space.deactivate() + local c=current() + if c then + c.disabled=true;c.preview.disabled=true + c.preview.vm,c.preview.vmSet,c.preview.store,c.preview.storeSet=nil,nil,nil,nil + end +end +function Space.onMapEnter(mod,mapId,game,world,opts) + local vm,why=Space.activate(mod,mapId,game,world);if not vm then return nil,why end + Space.runEnterScripts(mod,mapId,game,world,opts);return vm +end +function Space.runEnterScripts()return Boundary.invoke('runEnterScripts')end +function Space.runOnFrame()return Boundary.invoke('runOnFrame')end +function Space.runImmediately(key)return runtime:start(Space.scriptKey(key)or key)end +function Space.runOnResume(id)return runtime:request(5,id)end +function Space.runOnWarpIntoMap(id)return runtime:request(4,id)end +function Space.runOnReturnToField(id)return runtime:request(7,id)end +function Space.runOnDiveWarp(id)return runtime:request(6,id)end +function Space.runOnLoad(id)return runtime:request(1,id)end +function Space.returnToField(id) + local a=Space.runOnResume(id);local b=Space.runOnReturnToField(id);return a or b +end +function Space.scheduleOnFrame() + local c=current();if not(c and c.vm)then return false end + c.pendingOnFrame=true;return true +end +local tempFlags={firered={0x807,0x842},leafgreen={0x807,0x842}, + emerald={0x8ad,0x8ae,0x889,0x8c1,0x880},ruby={0x84d,0x84e,0x829,0x861},sapphire={0x84d,0x84e,0x829,0x861}} +function Space.tempFieldEventFlags(session) + local edition=session and session.edition or Version.get() + return Bundle.copy(assert(tempFlags[edition],'no native GBA temporary field flags')) +end +function Space.clearTempFieldEventFlags() + local store=Space.store;if not store then return false end + for _,id in ipairs(Space.tempFieldEventFlags())do store.flags[id]=false end + return true +end +function Space.objectVisible(obj) + local id=tonumber(obj and(obj.flag or obj.flagId)) + if not id or id==0 or id==65535 then return true end + local store=Space.store + return store~=nil and store.flags[id]~=true +end +function Space.resolveObjectGraphicsId(obj,neighbor) + if not obj then return nil end + local c=current();if not c then return nil end + local graphics=tonumber(obj.graphics or obj.graphicsId) + local store=neighbor and neighbor.store or Space.store + if not store then return nil end + if obj.graphicsVar then + local value=store.vars[obj.graphicsVar] + if type(value)=='number'and value~=0 then graphics=value end + end + local slot=tonumber(obj.varSprite) + if not slot and graphics and graphics>=240 and graphics<=255 then slot=graphics-240 end + if slot then + local value=store.vars[0x4010+slot] + if value==nil then return nil end + graphics=value%256 + end + local meta=c.data.gen3_object_sheets + if graphics and meta and graphics>=meta.count then + return nil,'object graphics id outside the native cartridge catalogue' + end + return graphics +end +function Space.resolveObjectSprite(obj) + if obj and type(obj.sprite)=='string'and obj.sprite~=''then return obj.sprite end + local id=Space.resolveObjectGraphicsId(obj) + return id and('SPRITE_G3_%03d'):format(id) +end +function Space.neighborObjectState(mapId)return runtime:neighbor(mapId)end +function Space.refreshObjectGraphics() + local c=current();if not(c and c.world)then return false end + if c.preview.vmSet or c.preview.storeSet then return false,'source preview lease is active'end + if c.world.syncObjectVisibility then c.world:syncObjectVisibility()end + local seen={} + local function refresh(npc) + if npc and not seen[npc]and npc.refreshSprite then seen[npc]=true;npc:refreshSprite(c.data)end + end + for _,npc in pairs(c.world.npcPool or{})do refresh(npc)end + for _,npc in ipairs(c.world.npcs or{})do refresh(npc)end + return true +end +function Space.startScript(key,localId,facing) + local c=current();if not(c and c.world)then return false,'no native field runner'end + facing=facing or c.world.player.facing + return runtime:start(key,localId or 0,facing) +end +function Space.install(mod) + local bundle,why=Space.ensureBundle();if not bundle then return nil,why end + Space._mod=mod;return bundle +end +function Space.release()runtime:release()end +function Space.cancelOwner(who)runtime:cancelOwner(who)end +function Space._nativePhase(name,game,world,mapId,body) + return runtime:phase(name,game,world,mapId,body,function(...) + local c=current(game,world) + if name=='runOnFrame'and c then c.pendingOnFrame=nil end + return attributed(name,Space[name],...) + end) +end +function Space._nativeTick(runner,body)return runtime:tick(runner,body)end +setmetatable(Space,{ + __index=function(_,key) + local c=current();if not c then return key=='active'and false or nil end + if key=='active'then return c.world~=nil and c.vm~=nil and not c.disabled end + if key=='mapId'then return c.id end + if key=='vm'then if c.disabled then return nil end;if c.preview.vmSet then return c.preview.vm end;return c.vm end + if key=='store'then if c.preview.storeSet then return c.preview.store end;return c.store end + if key=='bundle'then return c.dataset.override or c.bundle end + if key=='_pendingOnFrame'then return c.pendingOnFrame==true end + end, + __newindex=function(t,key,value) + if key=='vm'or key=='store'or key=='bundle'then + local c,why=current();assert(c,why) + assert(value==nil or type(value)=='table','invalid source script replacement') + if key=='bundle'then + assert(c.dataset.overrideOwner==nil or c.dataset.overrideOwner==runtime:owner(),'script bundle replacement belongs to another mod') + c.dataset.override=value~=c.bundle and value or nil + c.dataset.overrideOwner=c.dataset.override and runtime:owner()or nil + else + local who=runtime:owner() + assert(c.preview[key..'Owner']==nil or c.preview[key..'Owner']==who,'script preview lease belongs to another mod') + local original=c[key];c.preview[key]=value;c.preview[key..'Set']=value~=original + c.preview[key..'Owner']=c.preview[key..'Set']and who or nil + end + elseif key=='_pendingOnFrame'then local c=current();if c then c.pendingOnFrame=value==true end + elseif key=='active'or key=='mapId'then error('script field belongs to its native owner: '..key,2) + else rawset(t,key,value)end + end, +}) +return Space diff --git a/src/import/Gen3ScriptInterface.lua b/src/import/Gen3ScriptInterface.lua new file mode 100644 index 000000000..92d58afb5 --- /dev/null +++ b/src/import/Gen3ScriptInterface.lua @@ -0,0 +1,15 @@ +-- Additive information needed to observe original script operands after the +-- native importer has normalized cross-edition command ids. +local M={SCHEMA=1} +function M.row(line,row) + row.sourceOpcode=line.op + row.sourceAddress=line.address and line.address+0x08000000 + row.sourceSpecial=line.sourceSpecial + row.sourceBytes=line.sourceBytes + row.sourceSpec=line.sourceSpec + return row +end +function M.header(edition,sha1) + return {schema=M.SCHEMA,edition=edition,romSha1=sha1} +end +return M diff --git a/src/import/Gen3ScriptOps.lua b/src/import/Gen3ScriptOps.lua index 37f73f4ca..cedda25e1 100644 --- a/src/import/Gen3ScriptOps.lua +++ b/src/import/Gen3ScriptOps.lua @@ -70,7 +70,15 @@ -- (usually a pointer) -- * variable length; see TRAINER_BATTLE_LENGTH -local Gen3ScriptOps = {} +local Gen3ScriptOps = { CACHE_REVISION = 1 } + +-- The RS handler reads x/y; the later games removed those reads. Keep +-- edition semantics here instead of mutating the shared command table. +function Gen3ScriptOps.operandSpec(command, version) + local edition = type(version) == 'table' and version.id or version + if command[1] == 'hidemoneybox' and (edition == 'ruby' or edition == 'sapphire') then return 'bb' end + return command[2] +end Gen3ScriptOps.COMMANDS = { { "nop", "" }, { "nop1", "" }, { "end", "" }, { "return", "" }, { "call", "d" }, @@ -119,7 +127,7 @@ Gen3ScriptOps.COMMANDS = { { "choosecontestmon", "" }, { "startcontest", "" }, { "showcontestresults", "" }, { "contestlinktransfer", "" }, { "random", "w" }, { "addmoney", "db" }, { "removemoney", "db" }, { "checkmoney", "db" }, { "showmoneybox", "bbb" }, - { "hidemoneybox", "" }, { "updatemoneybox", "" }, { "getpokenewsactive", "w" }, + { "hidemoneybox", "" }, { "updatemoneybox", "bbb" }, { "getpokenewsactive", "w" }, { "fadescreen", "b" }, { "fadescreenspeed", "bb" }, { "setflashlevel", "w" }, { "animateflash", "b" }, { "messageautoscroll", "d" }, { "dofieldeffect", "w" }, { "setfieldeffectargument", "bw" }, { "waitfieldeffect", "w" }, { "setrespawn", "w" }, diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..908e4063c 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -13550,7 +13550,7 @@ function RomExtractorGen3:decodeScriptAt(start, queue) if not command then return lines, ("unknown opcode $%02X at %07X"):format(op, o) end - local name, spec = command[1], command[2] + local name, spec = command[1], Gen3ScriptOps.operandSpec(command, self.version) local at, args = o + 1, {} if spec == "*" then local kind = rom:u8(at) @@ -13655,6 +13655,10 @@ function RomExtractorGen3:decodeScriptAt(start, queue) -- `specialRemap`, 0-based, -1 for a FireRed-only one, which becomes -- 0x1000 + its own index so it can never land on an Emerald handler). local remap = self.manifest.specialRemap + local sourceSpecial + if name == "special" or name == "specialvar" then + sourceSpecial = args[name == "special" and 1 or 2] + end if remap and (name == "special" or name == "specialvar") then local slot = name == "special" and 1 or 2 local raw = args[slot] @@ -13663,7 +13667,8 @@ function RomExtractorGen3:decodeScriptAt(start, queue) args[slot] = (to >= 0) and to or (0x1000 + raw) end end - lines[#lines + 1] = { address = o, op = op, name = name, args = args } + lines[#lines + 1] = { address = o, op = op, name = name, args = args, sourceSpecial = sourceSpecial, + sourceBytes = rom:sub(o + 1, at - o - 1), sourceSpec = spec } o = at if Gen3ScriptOps.TERMINATORS[op] then return lines, nil end if op == 0x27 and previous and Gen3ScriptOps.WARP_YIELD[previous] then @@ -18016,7 +18021,7 @@ function RomExtractorGen3:extractMapScripts() for i, line in ipairs(lines) do local row = { line.name } for k = 1, #line.args do row[k + 1] = line.args[k] end - rows[i] = row + rows[i] = require("src.import.Gen3ScriptInterface").row(line, row) end scripts[("S%07X"):format(start)] = rows if err then failures[#failures + 1] = err end @@ -18175,6 +18180,8 @@ function RomExtractorGen3:extractMapScripts() source = "RomExtractorGen3", scripts = scripts, movements = movements, -- filled by extractMovementScripts + sourceInterface = require("src.import.Gen3ScriptInterface").header( + type(self.version) == "table" and self.version.id or self.version, self.manifest.romSha1), -- local ids the map's own scripts spawn; see the note above spawned = spawned, -- ...and the ones an arrival callback puts BACK, which start on the map diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..cde8fbabf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1313,6 +1313,12 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + if GameVersion.generation(version) == 3 then + revision = revision .. "gba-script-operands-v" .. require("src.import.Gen3ScriptOps").CACHE_REVISION .. ":" + end + if GameVersion.generation(version) == 3 then + revision = revision .. "original-script-interface-v" .. require("src.import.Gen3ScriptInterface").SCHEMA .. ":" + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..76ba4ed5c 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -895,6 +895,14 @@ local ON_LOAD, ON_TRANSITION, ON_RESUME = 1, 3, 5 -- warp-into rows, which do not need to. local ON_FRAME_TABLE, ON_WARP_INTO_MAP_TABLE = 2, 4 +local function fieldPhase(name, game, overworld, mapId, body) + local space = package.loaded['src.core.game3.scripting.space'] + if space and type(space._nativePhase) == 'function' then + return space._nativePhase(name, game, overworld, mapId, body) + end + return body() +end + local function contributionFor(data, mapId, entry, mapDef) local contribution = {} @@ -945,6 +953,7 @@ local function contributionFor(data, mapId, entry, mapDef) if #onEnter > 0 or #onWarpIn > 0 or #onFrame > 0 then local Gen3Commands = require("src.script.Gen3Commands") contribution.onEnter = function(game, overworld) + return fieldPhase('runEnterScripts', game, overworld, mapId, function() -- a fresh visit re-arms the frame table: these closures outlive the -- map, and a save loaded into the same map has to be asked again for _, g in ipairs(onFrame) do g.armed = nil end @@ -957,6 +966,7 @@ local function contributionFor(data, mapId, entry, mapDef) break end end + end) end end @@ -977,6 +987,7 @@ local function contributionFor(data, mapId, entry, mapDef) if #onFrame > 0 then local Gen3Commands = require("src.script.Gen3Commands") contribution.onFrame = function(game, overworld) + return fieldPhase('runOnFrame', game, overworld, mapId, function() for _, g in ipairs(onFrame) do local now = Gen3Commands.getVar(game.save, g.var) if now ~= g.value then @@ -988,6 +999,7 @@ local function contributionFor(data, mapId, entry, mapDef) end end return false + end) end end diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..b0a39e577 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -125,6 +125,7 @@ function ScriptRunner:run(script, extra) end self.co = coroutine.create(function() self:exec(script, ctx) + if ctx.scriptCancelled then return end -- Commands can defer a game action until the script's own dialogue is -- done. start_battle uses this for win-path evolutions, which must not -- be covered by post-battle trainer text. @@ -174,11 +175,17 @@ end -- Execute a command list. Supports labels via jump commands: a script is -- an array of rows; control commands return a new program counter, as a -- row number or a label name. -function ScriptRunner:exec(script, ctx) +function ScriptRunner:exec(script, ctx, admission) local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 + local budget = admission and admission.maxInstructions while pc <= #script do + if budget then + if budget <= 0 then error("native script preview instruction budget exhausted", 0) end + budget = budget - 1 + end + if ctx.scriptCancelled then return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -197,7 +204,9 @@ function ScriptRunner:exec(script, ctx) fn, meta = Gen3Commands[name], nil end end - if not fn then + if admission and not admission.commands[name] then + pc = pc + 1 + elseif not fn then -- api 2 owned scripts fail loudly; everything else keeps the v1 -- skip so old content degrades instead of dying if ctx.source and ctx.source.strict then @@ -236,7 +245,7 @@ function ScriptRunner:exec(script, ctx) -- scripts still fail loudly, which is what their authors asked for. local jump local function invoke() - if Runtime.wantsHook("script.command") then + if not admission and Runtime.wantsHook("script.command") then local args = { select(2, unpack(row)) } return Runtime.call("script.command", function(hctx, _, hargs) return fn(hctx, unpack(hargs)) @@ -275,6 +284,19 @@ function ScriptRunner:exec(script, ctx) end end +function ScriptRunner:cancelOwned(ctx, co, silent) + if self.ctx ~= ctx or self.co ~= co or not co then return false end + ctx.scriptCancelled = true + self.co, self.waitingFrames, self.waitingCheck = nil, nil, nil + if silent then return true end + if ctx.g3Locked and Commands.g3_release then Commands.g3_release(ctx) end + if ctx.g4Locked and Commands.g4_release_all then Commands.g4_release_all(ctx) end + if ctx.g3FadedOut then require("src.script.Gen3Commands").restoreFade(ctx) end + if ctx.onDone then ctx.onDone() end + Runtime.emit("script.ended", { ctx = ctx, completed = false }) + return true +end + -- Called by blocking commands from inside the coroutine. function ScriptRunner:yield() return coroutine.yield() @@ -316,7 +338,7 @@ function ScriptRunner:resume(...) end end -function ScriptRunner:update() +local function updateBody(self) -- commands that wait on frames re-resume every step while running if self:isRunning() and self.waitingFrames then self.waitingFrames = self.waitingFrames - 1 @@ -336,4 +358,12 @@ function ScriptRunner:update() end end +function ScriptRunner:update() + local space = package.loaded['src.core.game3.scripting.space'] + if space and type(space._nativeTick) == 'function' then + return space._nativeTick(self, updateBody) + end + return updateBody(self) +end + return ScriptRunner diff --git a/tests/engine/gen3_money_command_widths.lua b/tests/engine/gen3_money_command_widths.lua new file mode 100644 index 000000000..c2bf2a74e --- /dev/null +++ b/tests/engine/gen3_money_command_widths.lua @@ -0,0 +1 @@ +dofile('tools/script-command-widths/check.lua') diff --git a/tests/engine/native_coroutine_phase_scopes.lua b/tests/engine/native_coroutine_phase_scopes.lua new file mode 100644 index 000000000..be7af3324 --- /dev/null +++ b/tests/engine/native_coroutine_phase_scopes.lua @@ -0,0 +1,117 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local B=require('src.compat.gen1.ScriptBoundary') +local function pack(...)return {n=select('#',...),...}end +local function resume(co,...) + local result=pack(coroutine.resume(co,...)) + T.eq(result[1],true,'owned coroutine resumes without a native error') + return unpack(result,2,result.n) +end +local calls=0 +local result=pack(B.dispatch('once',function()calls=calls+1;return nil,'native',nil end,function() + T.same(pack(B.invoke('once')),{n=3,[2]='native'},'native nil tuple survives first consumption') + return B.invoke('once') +end)) +T.same(result,{n=3,[2]='native'},'dispatch preserves source return arity') +T.eq(calls,1,'repeated base calls execute the native body once') +T.eq(B.active('once'),false,'return retires the native phase') + +local child,innerCalls= nil,0 +B.dispatch('parent',function()calls=calls+1;return 42 end,function() + child=coroutine.create(function() + T.eq(B.invoke('parent'),42,'a directly resumed child borrows its active parent body') + return B.dispatch('child',function()innerCalls=innerCalls+1 end,function() + coroutine.yield('held') + T.eq(B.invoke('replacement'),nil,'retired child cannot consume a newer native phase') + T.raises(function()B.dispatch('revived',function()end,function()error('must not run')end)end, + 'retired','retired child cannot open a new attributed callback') + B.invoke('child') + end) + end) + T.eq(resume(child),'held') + T.eq(B.active('child'),false,'suspended child scope is invisible on its parent thread') + T.eq(B.invoke('child'),nil,'parent cannot consume a suspended child body') + resume(coroutine.create(function() + T.eq(B.active('child'),false,'another child cannot borrow the suspended sibling') + T.eq(B.invoke('parent'),42,'another direct child shares the same consumed parent tuple') + end)) +end) +T.eq(innerCalls,0,'returning parent never executes a held nested body') +local ok,why +B.dispatch('replacement',function()return 'new native body'end,function() + ok,why=coroutine.resume(child) + T.eq(B.invoke('replacement'),'new native body','new native phase remains available to its actual caller') +end) +T.eq(ok,false,'resuming a retired nested dispatch fails closed') +T.check(tostring(why):find('retired',1,true)~=nil,'retirement is reported explicitly') +T.eq(innerCalls,0,'retired nested body cannot execute after resume') +T.eq(B.active('parent'),false);T.eq(B.active('child'),false) + +local borrowed,borrowedCalls +borrowedCalls=0 +B.dispatch('borrowed-body',function() + borrowedCalls=borrowedCalls+1 + coroutine.yield('body held') + return 'old result' +end,function() + borrowed=coroutine.create(function() + local consumed,errorValue=pcall(B.invoke,'borrowed-body') + T.eq(consumed,false,'a borrowed native body cannot publish after its parent retired') + T.check(tostring(errorValue):find('retired',1,true)~=nil) + T.eq(B.invoke('replacement-body'),nil,'catching retirement cannot consume a new native body') + end) + T.eq(resume(borrowed),'body held') +end) +B.dispatch('replacement-body',function()return 'fresh'end,function() + resume(borrowed) + T.eq(B.invoke('replacement-body'),'fresh','retired native body cannot consume replacement phase') +end) +T.eq(borrowedCalls,1,'retirement never retries a borrowed native body') + +local first=coroutine.create(function() + return B.dispatch('separate',function()return 'first' end,function() + coroutine.yield('old root') + T.eq(B.invoke('separate'),'first','resumed root consumes its own body, not a newer caller') + end) +end) +T.eq(resume(first),'old root') +T.eq(B.active('separate'),false,'an independently suspended root is invisible') +B.dispatch('separate',function()return 'second' end,function() + resume(first) + T.eq(B.invoke('separate'),'second','old root cleanup retains the newer root') +end) +T.eq(B.active('separate'),false,'neither root resurrects after completion') + +local ambiguous=coroutine.create(function() + return B.dispatch('ambiguous',function()error('old native body must not run')end,function() + coroutine.yield() + resume(coroutine.create(function() + T.eq(B.invoke('ambiguous'),nil,'independent resumer roots cannot lend an ambiguous phase') + end)) + end) +end) +resume(ambiguous) +B.dispatch('ambiguous',function()error('new native body must not run')end,function()resume(ambiguous)end) + +local failures=0 +ok,why=pcall(B.dispatch,'failed',function()failures=failures+1;error('owned native failure')end,function() + pcall(B.invoke,'failed');pcall(B.invoke,'failed') +end) +T.eq(ok,false);T.check(tostring(why):find('owned native failure',1,true)~=nil) +T.eq(failures,1,'swallowing an exception cannot replay the native body') +ok,why=pcall(B.dispatch,'false failure',function()error(false)end,function()pcall(B.invoke,'false failure')end) +T.eq(ok,false,'false-valued native errors also cannot be swallowed');T.eq(why,false) + +-- Weak scope heads must not keep an abandoned independent coroutine alive. +local weak=setmetatable({},{__mode='v'}) +do + local co=coroutine.create(function() + B.dispatch('abandoned',function()end,function()coroutine.yield()end) + end) + weak[1]=co;resume(co) +end +collectgarbage('collect');collectgarbage('collect') +T.eq(weak[1],nil,'abandoned native scope does not pin its coroutine') +T.eq(B.active('abandoned'),false) + +T.finish('Native coroutine phase borrowing, retirement and once-only native bodies') diff --git a/tests/engine/native_script_space.lua b/tests/engine/native_script_space.lua new file mode 100644 index 000000000..3d3ff9caf --- /dev/null +++ b/tests/engine/native_script_space.lua @@ -0,0 +1 @@ +dofile('tools/script-space/check.lua') diff --git a/tools/gen3_discover.py b/tools/gen3_discover.py index b3467fc64..b211c1c15 100644 --- a/tools/gen3_discover.py +++ b/tools/gen3_discover.py @@ -278,7 +278,7 @@ def dec(b): 0x92: ('checkmoney', 'db'), 0x93: ('showmoneybox', 'bbb'), 0x94: ('hidemoneybox', ''), - 0x95: ('updatemoneybox', ''), + 0x95: ('updatemoneybox', 'bbb'), 0x96: ('getpokenewsactive', 'w'), 0x97: ('fadescreen', 'b'), 0x98: ('fadescreenspeed', 'bb'), diff --git a/tools/script-command-widths/check.lua b/tools/script-command-widths/check.lua new file mode 100644 index 000000000..f588d6db2 --- /dev/null +++ b/tools/script-command-widths/check.lua @@ -0,0 +1,19 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local Extractor=require('src.import.RomExtractorGen3') +for _,edition in ipairs({'ruby','emerald','sapphire','firered','leafgreen','ruby','emerald'})do + local rs=edition=='ruby'or edition=='sapphire' + local raw=string.char(0x94)..(rs and string.char(1,2)or'') + ..string.char(0x16,0x75,0x40,42,0,0x95,3,4,1,0x16,0x76,0x40,91,0,0x02) + local ex=Extractor.new(raw,{id=edition},{}) + local lines,why=ex:decodeScriptAt(0) + T.eq(why,nil);T.eq(#lines,5,'money arguments cannot shift the next command') + T.eq(lines[1].name,'hidemoneybox');T.eq(#lines[1].args,rs and 2 or 0) + T.eq(lines[2].name,'setvar');T.eq(lines[2].args[2],42) + T.eq(lines[3].name,'updatemoneybox');T.eq(#lines[3].args,3) + T.eq(lines[3].args[1],3);T.eq(lines[3].args[2],4);T.eq(lines[3].args[3],1) + T.eq(lines[4].name,'setvar');T.eq(lines[4].args[2],91) + T.eq(lines[5].name,'end') +end +T.finish('native money command byte widths') diff --git a/tools/script-space/check.lua b/tools/script-space/check.lua new file mode 100644 index 000000000..28fc9a12a --- /dev/null +++ b/tools/script-space/check.lua @@ -0,0 +1,167 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Space=require('src.core.game3.scripting.space') +T.eq(package.loaded['src.core.Game'],nil,'requiring script space cannot bootstrap a game') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local calls=0 +local a,b,c=Boundary.dispatch('probe',function()calls=calls+1;return 'a',nil,'c'end,function() + local x,y,z=Boundary.invoke('probe');Boundary.invoke('probe');return x,y,z +end) +T.eq(calls,1);T.eq(a,'a');T.eq(b,nil);T.eq(c,'c') +T.eq(Boundary.invoke('probe'),nil,'no native scope cannot advance anything') +T.raises(function() + Boundary.dispatch('probe',function()calls=calls+1;error('native failure')end,function() + pcall(Boundary.invoke,'probe');pcall(Boundary.invoke,'probe') + end) +end,'native failure','native errors cannot be hidden by wrappers') +T.eq(calls,2);T.eq(Boundary.active('probe'),false) +local nested={} +Boundary.dispatch('probe',function()nested[#nested+1]='outer'end,function() + Boundary.dispatch('probe',function()nested[#nested+1]='inner'end,function()Boundary.invoke('probe')end) + Boundary.invoke('probe');Boundary.invoke('probe') +end) +T.eq(table.concat(nested,','),'inner,outer','nested visits restore the correct native scope') +love=require('tests.love_stub') +local Version=require('src.core.GameVersion') +local Compiler=require('src.script.Gen3ScriptVM') +local Runner=require('src.script.ScriptRunner') +local MapScripts=require('src.script.MapScripts') +local Native=require('src.compat.gen1.NativeSession') +local Bundle=require('src.compat.gen1.ScriptBundle') +local HostRuntime=require('src.mods.Runtime') +local nativeEnter,nativeFrame=Space.runEnterScripts,Space.runOnFrame +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + Space.release();MapScripts.resetBase() + local f=require('tools.session-runtime.fixture').new(edition) + local game,world=f.game,f.world + game.save.flags={};game.save.gen3Vars={} + world.runner=Runner.new(game,world) + local queued={};function world:queueScript(rows,extra)queued[#queued+1]={rows,extra}end + game.data.map_scripts={source='RomExtractorGen3',sourceInterface={schema=1,edition=edition},scripts={ + S0001000={{'setvar',0x4075,42},{'end'}}, + S0001100={{'delay',2},{'setvar',0x4076,91},{'end'}}, + S0001200={{'setvar',0x4077,77},{'end'}}, + S0001300={{'setvar',0x4078,99},{'end'}}, + S0001400={{'setvar',0x4010,19},{'setobjectxyperm',2,9,7},{'setobjectmovementtype',2,7},{'end'}}, + S0001500={{'goto','S0001500'}}, + S0001600={{'setflag',0x807},{'setvar',0x4079,13},{'end'}}, + },maps={TEST={callbacks={{type=3,script='S0001000'},{type=5,script='S0001200'}}, + tables={{type=2,rows={{var=0x4078,value=0,script='S0001300'}}}},objects={},signs={}}}} + local pool=game.data.map_scripts + local canonical=({firered='FR_VIRIDIAN_CITY',leafgreen='FR_VIRIDIAN_CITY',emerald='EM_OLDALE_TOWN',ruby='RU_OLDALE_TOWN',sapphire='SA_OLDALE_TOWN'})[edition] + local _,group,number=require('src.compat.gen1.MapIdentity').lookup(edition,canonical) + local neighbor={group=group,number=number,id='NEXT',objects={}} + game.data.maps.NEXT=neighbor + world.neighbors={{map={id='NEXT',def=neighbor}}} + T.eq(Space.isActive(),true);T.eq(Space.mapId,f.canonical) + local vm,store,bundle=Space.vm,Space.store,Space.bundle + T.check(vm~=nil);T.eq(vm:isRunning(),false) + T.eq(Space.scriptKey('g3:08001000'),'g3:08001000') + store.vars[0x4075]=3;T.eq(game.save.gen3Vars[0x4075],3) + T.eq(Space.runEnterScripts(),nil,'queries cannot start native entry scripts') + T.eq(Compiler.register(game.data,'scenes'),1) + local scripts=assert(MapScripts.get('TEST')) + local enterCalls,frameCalls=0,0 + Space.runEnterScripts=function(mod,id,view,observed) + enterCalls=enterCalls+1 + T.eq(id,f.canonical);T.eq(view.generation,3);T.eq(observed,world) + T.eq(select(2,Native.resolve()),world,'map entry sees native world before stack publication') + T.eq(Space.vm,vm) + nativeEnter(mod,id,view,observed);nativeEnter(mod,id,view,observed) + end + game.stack.states={} + scripts.onEnter(game,world) + game.stack.states={world} + T.eq(enterCalls,1);T.eq(#queued,2,'wrapped entry queues each native callback once') + T.eq(store.vars[0x4075],3,'callbacks remain on the native pending queue') + for _,item in ipairs(queued)do world.runner:run(item[1],item[2])end + T.eq(store.vars[0x4075],42);T.eq(store.vars[0x4077],77) + Space.runOnFrame=function() + frameCalls=frameCalls+1 + local result=nativeFrame();T.eq(nativeFrame(),result);return result + end + T.eq(scripts.onFrame(game,world),true);T.eq(frameCalls,1) + T.eq(store.vars[0x4078],99);T.eq(scripts.onFrame(game,world),false) + T.eq(Space.startScript('g3:08001100',2,'left'),true) + T.eq(store.vars[0x4076],nil);T.eq(vm:isRunning(),true) + T.eq(game.save.gen3Vars[0x800f],2);T.eq(vm.ctx.specialVars[0x800c],3,'source context observes native facing') + vm:tick();vm:tick();T.eq(world.runner.waitingFrames,2,'render/extra queries cannot consume waits') + local baseTick=vm.tick;local ticks=0 + vm.tick=function(self)ticks=ticks+1;baseTick(self);return baseTick(self)end + world.runner:update();T.eq(world.runner.waitingFrames,1);T.eq(ticks,1) + world.runner:update();T.eq(store.vars[0x4076],91);T.eq(vm:isRunning(),false);T.eq(ticks,2) + vm.tick=baseTick + -- Invalid arguments must not partly update the native talk context. + local last=vm.ctx.specialVars[0x800f] + T.raises(function()Space.startScript('g3:08001000',9,'bad')end,'invalid script facing') + T.eq(vm.ctx.specialVars[0x800f],last) + local oldVm,oldStore=Space.vm,Space.store + Space.vm=nil;Space.store={flags={},vars={}} + T.eq(Space.vm,nil);T.eq(Space.startScript('g3:08001000'),false) + T.raises(function()oldStore.vars[0x4075]=900 end,'preview lease') + T.raises(function()oldVm.ctx.specialVars[0x8000]=900 end,'preview lease') + Space.vm=oldVm;Space.store=oldStore + T.eq(Space.vm,oldVm);T.eq(Space.store,oldStore) + HostRuntime.currentMod='preview-owner';Space.vm=nil;Space.store={flags={},vars={}} + HostRuntime.currentMod='native-peer' + T.raises(function()Space.vm=oldVm end,'another mod') + Space.cancelOwner('native-peer');T.eq(Space.vm,nil,'retiring a peer preserves the active preview') + require('src.mods.Loader').new({}):_rollback('preview-owner') + HostRuntime.currentMod=nil + T.eq(Space.vm,oldVm);T.eq(Space.store,oldStore,'failed-entry rollback releases only the owning preview') + T.eq(Space.startScript('g3:08001000'),true) + T.eq(Space.persistSession(),true) + T.eq(Space.resolveObjectGraphicsId({graphics=240}),nil) + store.vars[0x4010]=39;T.eq(Space.resolveObjectGraphicsId({graphics=240}),39) + T.eq(Space.resolveObjectSprite({graphics=39}),'SPRITE_G3_039') + store.flags[0x807]=true;T.eq(Space.objectVisible({flag=0x807}),false) + T.eq(Space.objectVisible({flag=0}),true);T.eq(Space.objectVisible({flag=65535}),true) + pool.maps.NEXT={callbacks={{type=3,script='S0001400'}}} + local liveVars=game.save.gen3Vars + local observed=assert(Space.neighborObjectState(canonical)) + T.eq(observed.store.vars[0x4010],19);T.eq(observed.perm[2].x,9);T.eq(observed.perm[2].y,7) + T.eq(observed.movementType[2],7);T.eq(game.save.gen3Vars,liveVars);T.eq(store.vars[0x4010],39) + T.eq(game.save.gen3ObjectHomes,nil,'neighbour commands cannot write live object homes') + pool.maps.NEXT.callbacks[1].script='S0001500' + local state,why=Space.neighborObjectState(canonical) + T.eq(state,nil);T.check(why:find('budget exhausted',1,true)~=nil,'looping native previews are bounded') + pool.maps.NEXT.callbacks[1].script='S0001600' + local before=HostRuntime.call;local hookCalls=0 + local wants=HostRuntime.wantsHook;HostRuntime.wantsHook=function()return true end + HostRuntime.call=function(...)hookCalls=hookCalls+1;return before(...)end + local clean=assert(Space.neighborObjectState(canonical)) + HostRuntime.call=before;HostRuntime.wantsHook=wants + T.eq(clean.store.vars[0x4079],13);T.eq(clean.store.flags[0x807],true,'preview cannot execute an unsupported flag write') + T.eq(store.vars[0x4079],nil);T.eq(hookCalls,0) + local projection=bundle.scripts['g3:08001000'] + T.eq(projection[1].op,'setvar');projection[1][2]=888 + local ran,reason=Space.startScript('g3:08001000') + T.eq(ran,false);T.check(reason:find('overlay compiler',1,true)~=nil) + T.eq(pool.scripts.S0001000[1][3],42,'editing a projection cannot edit native rows') + projection[1][2]=42 + T.eq(Space.startScript('g3:08001100'),true) + local owned=world.runner.ctx + HostRuntime.currentMod='another-mod';T.eq(vm:halt(),false,'a peer cannot cancel another admission');HostRuntime.currentMod=nil + T.eq(vm:halt(),true);T.eq(owned.scriptCancelled,true) + T.eq(owned.trainerPreparationCancelled,nil);T.eq(owned.lastBattleResult,nil,'ordinary cancellation never fabricates a battle result') + T.eq(vm:isRunning(),false) + -- A retained source VM cannot drive a replacement runner/world/dataset. + local nextWorld={isOverworld=true,map=f.map,player=f.player,npcs={}} + nextWorld.runner=Runner.new(game,nextWorld);game.stack.states={nextWorld} + T.eq(vm:isRunning(),false) + T.raises(function()vm:start('g3:08001000')end,'retired') + T.check(Space.vm~=vm);T.eq(Space.bundle,bundle,'map changes reuse immutable script projection') + local hold=Space.vm;game.save={flags={},gen3Vars={}} + T.eq(hold:isRunning(),false);T.check(Space.vm~=hold);T.check(Space.bundle~=bundle) + Space.deactivate();T.eq(Space.isActive(),false);T.eq(Space.vm,nil) + T.check(Space.activate(nil,f.canonical)~=nil);T.eq(Space.isActive(),true) + Space.runEnterScripts,Space.runOnFrame=nativeEnter,nativeFrame + Space.release() +end +-- Raw operands must survive native normalization, including trainer layouts. +local row=Bundle.row({'specialvar',0x8000,999,sourceOpcode=0x26,sourceSpecial=117,sourceSpec='ww',sourceBytes=string.char(0,128,117,0)},'ruby') +T.eq(row.op,'specialvar');T.eq(row[2],117);T.eq(row.id,117) +local pointer=Bundle.row({'loadword',0,'TEXT_001234',sourceSpec='bd',sourceBytes=string.char(0,0x34,0x12,0,8)},'firered') +T.eq(pointer.value,'g3:08001234');T.eq(pointer[2],pointer.value) +Version.set('crystal');T.eq(Space.isActive(),false);T.eq(Space.vm,nil) +T.finish('native script-space ownership and field phases') diff --git a/tools/script-space/oracle-host/conf.lua b/tools/script-space/oracle-host/conf.lua new file mode 100644 index 000000000..33b315d62 --- /dev/null +++ b/tools/script-space/oracle-host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + assert(t.identity:match('^oasc%-gen2recomp%-qa%-script%-interface%-')) + t.version='12.0';t.window=false + t.modules.graphics=false;t.modules.audio=false;t.modules.joystick=false;t.modules.physics=false +end diff --git a/tools/script-space/oracle-host/main.lua b/tools/script-space/oracle-host/main.lua new file mode 100644 index 000000000..18b3558f0 --- /dev/null +++ b/tools/script-space/oracle-host/main.lua @@ -0,0 +1,111 @@ +io.stdout:setvbuf('no') +local engine=assert(os.getenv('SCRIPT_INTERFACE_ENGINE')) +local reference=assert(os.getenv('SCRIPT_INTERFACE_REFERENCE')) +package.path=engine..'/?.lua;'..reference..'/?.lua;'..package.path +function love.errorhandler(e)print(debug.traceback(tostring(e),2));return function()return 1 end end +local function read(p)local f=assert(io.open(p,'rb'));local s=f:read('*a');f:close();return s end +local function hash(s)return love.data.hash('sha256',s):gsub('.',function(c)return('%02x'):format(c:byte())end)end +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function canonical(v) + if type(v)~='table'then return type(v)..':'..tostring(v)end + local keys={};for k in pairs(v)do keys[#keys+1]=k end + table.sort(keys,function(a,b)if type(a)==type(b)then return a0xc5)and common[op]or nil)end, + trainerBattleType=function(_,id)local name=battles[id];return name and name:gsub('^TRAINER_BATTLE_','')end} + local count,scripts,specials,trainers=0,0,0,0 + local differences={};local nativeOnly={} + for key,rows in pairs(pool.scripts)do + scripts=scripts+1;assert(old.scripts[key]and #old.scripts[key]==#rows,'native list changed') + for i,ir in ipairs(rows)do + local previous=old.scripts[key][i] + for k=1,math.max(#ir,#previous)do assert(same(ir[k],previous[k]),'native operand changed')end + local bytes={ir.sourceOpcode};for n=1,#ir.sourceBytes do bytes[#bytes+1]=ir.sourceBytes:byte(n)end + assert(raw:sub(ir.sourceAddress-0x08000000+1,ir.sourceAddress-0x08000000+#bytes)==string.char(unpack(bytes)),'raw opcode payload differs') + local expected,at=Disasm.decodeOne(bytes,1,set) + local actual=Bundle.row(ir,edition) + if expected.op=='unknown'then nativeOnly[ir[1]]=true + else + count=count+1 + local function restore(v) + if type(v)=='string'and v:match('^g3:%x+$')then return tonumber(v:sub(4),16)end + return v + end + for k,v in pairs(expected)do + if not same(restore(actual[k]),v)then + if #differences<20 then differences[#differences+1]={script=key,row=i,field=tostring(k),op=expected.op,want=tostring(v),got=tostring(actual[k])}end + end + end + if at~=#bytes+1 and #differences<20 then differences[#differences+1]={script=key,row=i,op=expected.op,field='payload-length',want=at-1,got=#bytes}end + end + if ir[1]=='special'or ir[1]=='specialvar'then specials=specials+1 end + if ir[1]=='trainerbattle'then trainers=trainers+1 end + end + end + local module=require('src.import.LuaWriter').encodeSplit(pool) + local decoded=assert(loadstring(module,'@generated/map_scripts.lua'))() + assert(same(pool,decoded),'additive metadata did not round-trip') + report.editions[edition]={scripts=scripts,sourceRows=count,specials=specials,trainers=trainers, + nativeOperandsUnchanged=true,cacheRoundTrip=true,moduleBytes=#module,differences=differences,nativeOnly=nativeOnly, + referenceCorrections=family=='ruby'and{'showmoneybox: 3 byte reads','updatemoneybox: 3 byte reads'}or{'hidemoneybox: no byte reads'}, + originalFailureCount=old.info.failureCount,currentFailureCount=pool.info.failureCount} + local f=assert(io.open(out..'/oracle.json','w'));f:write(J.encode(report));f:close() + print(edition,scripts,count,specials,trainers,#differences) + assert(#differences==0,'original operand projection differs; inspect oracle.json') + end + collectgarbage('collect') + end + love.event.quit(0) +end