Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/native-script-coroutine.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions docs/native-money-command-layouts.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions docs/native-script-space.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions src/compat/gen1/CoroutineScopes.lua
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions src/compat/gen1/ScriptBoundary.lua
Original file line number Diff line number Diff line change
@@ -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
174 changes: 174 additions & 0 deletions src/compat/gen1/ScriptBundle.lua
Original file line number Diff line number Diff line change
@@ -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
Loading