Struct layouts
Projected from db/os/struct.tsv and struct_field.tsv: 25 layouts, 160 fields, each offset read from the instructions that use it. Typed accessors are generated from these rows so that no hand-written code carries a magic offset.
tld — 0x60 bytes
allocated in kstate.installBootHeap
evidence
euser thread-local data block; [+0]=RAllocator* heap, [+4]=TTrapHandler* (set by exec9)
ttraphandler — 0x8 bytes
TCleanupTrapHandler: {+0 vptr, +4 iCleanup}
evidence
euser GetActiveCleanup @0x801a19ba = `svc8 ; ldr r0,[r0,#4]` -> iCleanup at +4
ctrapcleanup — 0xc bytes
{+0 vptr, +4 iHandler(=ttraphandler), +8 iCleanup}
evidence
CTrapCleanup::New @0x801a18ca embeds the TTrapHandler at +4 and stores iCleanup at +8
XIP module header, immediately followed by the code section: headerVA = code_address - 0x78. The superseded 128-byte model plus a compensating ROM_CODE_SHIFT=8 loaded the image at the same address but misread every VA out of the header.
evidence
Size MEASURED across the whole ROM, not assumed. (a) Each standalone samples/firmware/5320/sys/bin/<mod> file's first 96 bytes occur EXACTLY ONCE in samples/firmware/5320/5320.rom, and code_address - (0x80000000 + thatFileOffset) = 120 for all six of EUser.dll, libc.dll, libz.dll, libdl.dll, libpthread.dll and ekern.exe. (b) Sweeping the ROM for uid1 in {0x10000079,0x1000007a} with uid2 in {0x1000008d,0x1000008b} finds 1425 places where code_address == headerVA + 0x78 (1361 of them uid1 = DLL). (c) Behavioural: the export directory read at header+0x78 gives EUser ordinal 196 = 0x801a18d3, exactly the CTrapCleanup::New VA that web/src/kernel/kstate.js already calls successfully as a guest subroutine, and ordinal 585 = 0x801b2d35, which is provably the function issuing `svc #0x9f` (ROM 0x8019c318 = ef00009f) and provably the euser ordinal mgs_ngi.exe imports (its import slot at code offset 0x51fc holds 0x249 = 585). Ordinals 196/585/891/1360/1600/2060 all land on a Thumb push prologue with no fixup scan. Reading the directory at +128 instead shifts every ordinal by two export slots.
romdllreftable — 0x4 bytes
fixed 4-byte head; `count` TRomImageHeader* entries follow at +0x4. Dedupe by header VA when walking - a table can name the module itself.
evidence
TRomDllRefTable at TRomImageHeader.dll_ref_table_address, read live from 5320.rom. libc.dll's table @0x82e86320 reads flags=0x0000 count=4 then 0x82e460e0 0x82e61340 0x82e63f40 0x82e88600 - each entry is another module's HEADER VA (0x82e63f40 is libc's own header VA, i.e. the table contains a self-reference), not a code address. libz.dll @0x82e460c4, libpthread.dll @0x82e885ec and libdl.dll @0x82e99b88 carry the identical 4-entry table; backend.dll @0x82e55d2c has count=1 -> 0x82e61340; EUser.dll, LibCIpcClient.dll, libm.dll and ekern.exe have dll_ref_table_address = 0. Names resolved from the ROM directory entries whose iAddressLin equals the header VA: 0x82e460e0 backend.dll, 0x82e61340 LibCIpcClient.dll, 0x82e88600 libm.dll.
tdesc — 0x4 bytes
Variable total size by type; only the 4-byte head is fixed, and max_length below is present ONLY for types 2/3/4. A TLitC (what _LIT expands to) is layout-identical to type 0 EBufC, so the same accessors read it. WRITE SIDE (web/src/kernel/descriptor.js writeDes16): K::USetLength ekern 0x8006b9a4 is what Kern::KUDesPut 0x80098088 calls before copying the characters (0x800980c0, then 0x8006bec4): 0x8006b9b0 `ldrt r2,[r0],#4 ; ldrt r3,[r0],#-4` reads word0 and iMaxLength, 0x8006b9b8 `and r2,r2,#0xf0000000 ; cmp r2,#0x20000000 ; blo 0x80097f8c` panics on a non-modifiable type (0 EBufC, 1 EPtrC), 0x8006b9c4 `cmp r1,r3 ; bhi 0x80097f94` panics when the new length exceeds iMaxLength (no KErrOverflow is ever returned), 0x8006b9cc `cmp r2,#0x40000000 ; orr r2,r2,r1 ; strt r2,[r0],#8 ; bxne lr` stores type|newLength, and for type 4 EBufCPtr 0x8006b9dc `ldrt r2,[r0],#0 ; strt r1,[r2],#0` also stores the length into the pointed-to TBufC.
evidence
The fixed 4-byte head EVERY Symbian descriptor starts with, decoded by the kernel at ekern 0x8006b934 (the first act of Kern::KUDesGet 0x80098040, which is how every exec reads a TDesC from user memory). That routine is the specification and is only 16 instructions: 0x8006b93c `ldrteq r3,[r0],#4` takes word0; 0x8006b944 `bic ip,r3,#0xf0000000` = length; 0x8006b94c `mvn ip,#0` presets maxLength to -1; 0x8006b950 `cmp r3,#0x50000000 ; bhs 0x80097f84` PANICS for a type nibble >= 5, which is what fixes the type field at bits 31..28 and the count of valid types at exactly 5; then 0x8006b958 `eor r3,r3,r3,lsr #1 ; msr apsr_nzcvq,r3` converts the nibble (t3..t0) into N=t3, Z=t2^t3, C=t1^t2, V=t0^t1 and three conditional instructions read it back — 0x8006b960 `ldrths ip,[r0],#4` (C set = types 2,3,4: maxLength is word1), 0x8006b968 `ldrtle r0,[r1],#0` (LE = types 1,2,4: the data is behind a pointer) and 0x8006b970 `addeq r0,r0,#4` (Z set = type 4 only: skip the pointed-to TBufC's own length word). Evaluating that flag algebra yields exactly the five documented TDesType layouts — 0 EBufC data at +4; 1 EPtrC data at *(+4); 2 EPtr max at +4, data at *(+8); 3 EBuf max at +4, data at +8; 4 EBufCPtr max at +4, data at *(+8)+4 — so the reading is self-checking rather than assumed. Live confirmation from the boot: the TDesC8 mgs_ngi.exe hands to Exec::SessionCreate reads {0x3000000b, 0x50, '!FileServer'} = type 3 EBuf, length 0xb, maxLength 0x50, text inline at +8.
iPrimaryFile = the kernel, iSecondaryFile = the first user-side process the kernel starts. On this ROM that is efile.exe, the F32 file server.
evidence
NOT the whole TRomHeader — the RE'd extent, through iTraceMask[8] at +0x100..+0x11F; the real header is longer and its total size is not measured here. The header lives at THE ROM BASE and the kernel keeps its address in the global 0xc800004c (TheRomHeader, stored at kernel entry 0x80072a84 from r0 of _E32Startup 0x80068110 -> 0x8006e430 -> 0x80072a74; served to user code by fast exec 0x13, handler 0x80072b0c): its iRomBase (+0x8c) reads 0x80000000 and its iUncompressedSize (+0xf4) reads exactly the byte length of 5320.rom, so the base is self-describing. The three anchors are mutually confirming rather than assumed: (a) +0x94 = 0x800458D0 is already relied on by exec 0x4d, and walking it as TRomRootDirectoryList{TInt iNumRootDirs; {TUint iHardwareVariant; TLinAddr iAddressLin}[]} — 1 root dir — yields 2933 named entries whose iAddressLin values include \Sys\Bin\EUser.dll = 0x80199000 and \Sys\Bin\ekern.exe = 0x80067F88, i.e. exactly the TRomImageHeader VAs the loader independently derives; (b) +0x98 = 0xC8000000 is byte-identical to ekern.exe's own romimageheader.data_bss_linear_base already recorded in struct_field.tsv, which pins the field run; (c) +0xA0 and +0xA4 each point at a TRomEntry whose iName decodes to 'ekern.exe' and 'efile.exe' and whose iAddressLin (0x80067F88, 0x801CB600) is a real TRomImageHeader — a coincidence-free decode.
tromentry — 0xa bytes
TRomEntry{TInt iSize; TLinAddr iAddressLin; TUint8 iType; TUint8 iNameLength; TText16 iName[]}; iType bit 0x10 = KEntryAttDir (iAddressLin is then a TRomDir).
evidence
Fixed head of a ROM directory entry. Decoded live from 5320.rom at both TRomHeader.iPrimaryFile (0x800458E0) and iSecondaryFile (0x800462F0): iSize 0x37CB8/0x205D4, iAddressLin 0x80067F88/0x801CB600, iType 0x81/0x81, iNameLength 9/9, and the 9 UTF-16 units at +0xA read 'ekern.exe'/'efile.exe'. Stepping the whole directory tree with entry stride ((0xA + iNameLength*2 + 3) & ~3) walks all 2933 entries without desynchronising, which is what proves the head is 10 bytes and the name is 16-bit.
tchunkcreate — 0x14 bytes
TChunkCreate as passed to Exec::ChunkCreate (slow 0x6a).
evidence
The user-side chunk-creation struct exec 0x6a receives in r2. ekern 0x80083be4 copies exactly 0x14 = 20 bytes from it (0x80083c28 `bl 0x8006baac` with r0=sp+0x44, r1=the user pointer, r2=0x14) and then routes the five words: [+0x0] is masked &0x10 and &0xf and bit-tested with 0x20 to pick the kernel chunk type, while [+0x4], [+0x8], [+0xc] and [+0x10] are copied to the kernel create-info at sp+0x10, sp+0x24, sp+0x28 and sp+0x20. Live confirmation from the boot: mgs_ngi.exe's TChunkCreate at 0x7fff2c reads att=0 +4=0 +8=0 +0xc=0 +0x10=0x1000000, and 0x1000000 is exactly mgs_ngi.exe's E32 header heapMax.
nthread — 0x6c bytes
the nanokernel thread: state, priority, request semaphore, exec tables
evidence
NThreadBase, embedded in the DThread at +0x2c0: ekern 0x8006ad54 `sub sb,sb,#0x2c0` turns TScheduler.iCurrentThread into the current DThread, 0x80092aa0 `add r0,r4,#0x2c0` hands the sub-object to NKern::ThreadCreate 0x8006f320, and every fast-exec accessor (0x8006a674..0x8006a6b4) reaches DThread fields as negative offsets from it. Fields read out of the SVC vector 0x80068120/wait_for_any_request 0x80068708, NFastSemaphore::SignalN/Signal 0x80068750/0x8006880c, the ready-list ops 0x800687ac/0x80068828, NKern::ThreadResume 0x8006e5d4 and NThreadBase construction 0x8006f2d8..0x8006f3e8. The RE'd extent ends at iSavedSP +0x68 (reschedule 0x80068c8c `str sp,[r1,#0x68]`); the full size is not measured.
dthread — 0x334 bytes
kernel thread object; the NThreadBase lives at +0x2c0, the sync RMessageK at +0xEC
evidence
RE'd extent only: fields between +0x24 and +0x165 come from Exec::ThreadCreate 0x800839fc -> DProcess::NewThread 0x80091570 -> DThread::Create 0x80092ae8 -> DoCreate 0x800929ac, AllocateUserStack 0x8007a920, EpocThreadFunction 0x8006a868, HeapSwitch 0x80082140 and the fast-table accessors 0x8006a674..0x8006a6b4 (which address them as [NThread-0x2c0+off]). 0x80092a94/0x80092ab0 save and restore +0x32c/+0x330 around ThreadCreate, so the object extends at least to 0x334; the full size is not measured.
tscheduler — 0x134 bytes
the single scheduler: ready-list bitmap + 64 queues, reschedule flags, current thread
evidence
TScheduler, the object the SVC vector loads through the literal at 0x800683a0 (= 0xc800043c, inside ekern's writable data at TRomHeader.kern_data_address 0xC8000000). Fields from the vector 0x80068120..0x80068268, wait_for_any_request 0x80068708, the ready ops 0x800687ac/0x80068828..0x800688b8 and the reschedule 0x80068c40..0x80068cb4. RE'd extent ends at iCurrentThread +0x130; the full size is not measured.
nfastsemaphore — 0x8 bytes
NFastSemaphore; the thread's request semaphore is the one at NThread+0x20
evidence
{iCount, iOwningThread}: SignalN 0x80068750 reads the count at [r0] and the owner at [r0,#4]; Signal 0x8006880c `ldm r0,{r1,r2}` the same pair; NThreadBase construction 0x8006f308 `str r4,[r4,#0x24]` makes the NThread that embeds one at +0x20 its own owner.
sthreadcreateinfo — 0x40 bytes
SThreadCreateInfo head (0x30) + the SStdEpocThreadCreateInfo tail euser passes (allocator, heap sizes); iTotalSize says how much
evidence
The block Exec::ThreadCreate 0x800839fc copies from user memory: 0x80083a1c `mov r2,#0x30 ; bl 0x8006baac` takes the 0x30-byte head, then 0x80083a60..0x80083a78 copies iTotalSize-0x30 more once the total is validated — (total-0x30) < 0xd1 and total&7 == 0 at 0x80083a28..0x80083a38, iUserStackSize >= 0x200 at 0x80083a48..0x80083a54, else -6; DThread::Create 0x80092af0..0x80092b04 re-checks 0x30 <= total <= 0x100. The handler overwrites +0x4 with 3 (EThreadUser, 0x80083a7c), +0x20 with -5 (0x80083a84 `mvn r0,#4`), +0x10 and +0x14 with 0 (0x80083a98/0x80083a9c) before the block becomes NThreadCreateInfo.iParameterBlock (0x80092a4c) and is copied, by EpocThreadFunction 0x8006a8dc..0x8006a8f8, to the top of the new thread's user stack with user SP = top - iTotalSize. The 0x40 size is the SStdEpocThreadCreateInfo euser builds: UserHeap::SetupThreadHeap 0x801b2d04/0x801b2d0c and CreateThreadHeap 0x801b0672/0x801b067a read +0x30/+0x34/+0x38 past the kernel head.
dprocess — 0x11c bytes
kernel process object
evidence
RE'd extent only: iCodeSeg +0x108 (exec 0x9f 0x800899fc; 0x80091df8), iReentryPoint +0x118 (0x80091df8..0x80091e00 `ldr r0,[r4,#0x108] ; ldr r0,[r0,#0x24] ; str r0,[r4,#0x118]` stores the codeseg's entry point at process load; fast exec 0x16's handler 0x8006a6ac rewrites it from user code; EpocThreadFunction 0x8006a90c reads it as a non-initial thread's first user PC), iPriority +0x20 (0x800928d8/0x800928e0 in the thread-priority mapping). The owning process of a thread is DThread+0x30. Full size not measured.
tentry — 0x228 bytes
⚠ 0x1c + 0x204 = 0x220 = 544, but sizeof is 552: EIGHT BYTES AT 0x220..0x227 ARE UNACCOUNTED FOR and are deliberately NOT declared as fields. They are not tail padding — 544 is already 8-aligned and the struct's alignment is 8 (it holds a 64-bit iModified). No read or write of a TEntry at +0x220/+0x224 was found in efile.exe, EFSrv.dll, or across the whole-ROM sweep of RFs::Entry call sites (a later sweep of all 68,759,552 ROM bytes for a literal 0x220/0x224 or an ldr/str at rN+0x220 in 0x801E0000-0x80200000 also found none). CROSS-REFERENCE ONLY, never a committed value here: Symbian's published TEntry reference documents FileSize()/SetFileSize(TInt64) as storing the low word in iSize and the high word in a private iSizeHigh, and f32file.h ends the class with TUint32 iSizeHigh; TUint32 iReserved; - which fits +0x220/+0x224 exactly. Not declared as fields: that reference is Symbian^3, this ROM is 9.3, and nothing in this image reads either word. Also unresolved: the producer side. The efile handler delegates the fill to the mounted filesystem through vtable slot 0x40 (0x801CC8DC), and that plugin is not among samples/firmware/5320/sys/bin, so every field below is evidenced from CONSUMERS. Attribute BIT NAMES are deliberately absent: EFSrv exports five one-line accessors testing 0x1/0x2/0x4/0x10/0x20 and the stripped ROM names none of them.
evidence
sizeof = 0x228, established three independent ways: EFSrv.dll's RFs::Entry wrapper 0x801ECCC7 builds the TPckg with `movs r3,#0x45 ; lsls r3,r3,#3` = 0x228; efile.exe's fn-22 handler 0x801D3530 reserves a 0x238 frame with the TEntry at sp+8 and a TPtrC16 immediately after it at sp+0x230 = sp+8+0x228; and a third ROM module at 0x80BA9A80 heap-allocates it with `movs r0,#0x45 ; lsls r0,r0,#3` before calling the ctor 0x801ED69D and RFs::Entry. The ctor fixes the layout: `adds r0,#0x10` (TUidType ctor, 12 bytes zeroed by EUser 0x801A53B5) then `adds r0,#0xc` (TBufC16 ctor 0x801AF70B, which stores a zero length word), so iType is at +0x10 and the name head at +0x1c. EFSrv's EntrySize 0x801F28E1 is `ldr r0,[r0,#0x1c] ; lsls r0,r0,#4 ; lsrs r0,r0,#3 ; adds r0,#0x20` = 0x20 + 2*length, independently fixing the head at 0x1c and the characters at 0x20; EFSrv 0x801F140E passes 256 as the TBufC capacity bound to TBufCBase16::Copy (EUser 0x8019DE44), so iName is TBufC16<256> = 4 + 512 bytes at 0x1c, ending at 0x220.
tldrinfo — 0x20 bytes
the block the loader load/create requests carry in slot 0. ⚠ +0x14 and +0x1C are NEVER written on the library path (only the process path writes +0x1C), so their meaning is undetermined and they are deliberately not declared as fields. Names here are labels: this stripped ROM carries no symbols.
evidence
size 0x20: euser 0x801A5B64 builds it by zeroing exactly 0x20 bytes through 0x8019E65C (Mem::Fill with the byte forced to 0) and then `movs r0,#1 ; mvns r0,r0 ; str r0,[r4,#0x18]`. The caller at 0x801A78FE wraps it in a TPtr8 of length 0x20 (0x801A7914 `bl 0x801b8af8`) and sends that as TIpcArgs slot 0 of !Loader fn 2. efile reads it back with RMessagePtr2::Read(0, TDes8&, 0) into the first 0x20 bytes of its request (0x801D19E2) and writes it back with RMessagePtr2::Write(0, TPtrC8(&req,0x20), 0) at 0x801D13A4.
rarraybase — 0x20 bytes
RArrayBase as this ROM lays it out — NOT the order Symbian's published e32cmn.h documents, which is the reason it is measured here rather than taken from a header. ⚠ +0x18 and +0x1C are zeroed by all five constructors and read by NOTHING: a sweep of every Thumb `ldr`/`str` with those two offsets across EUser's whole code section [0x80199078,+0x32588) finds 245 hits, of which the 10 inside the array region 0x801AC800..0x801AD600 are all STORES, all in the constructors. They are deliberately not declared as fields.
evidence
RE'd extent: every constructor writes through +0x1C, so the object is at least 0x20 bytes; the full size is not measured. Six of the eight words are fixed by four independent bodies rather than one. (a) The 1-argument constructor, EUser ordinal 55 = 0x801ACC8B, is the whole shape in one function: it zeroes +0x00, +0x04, +0x0C, +0x10, +0x18 and +0x1C, stores 8 at +0x14, checks aEntrySize against the literal 0x27F at 0x801ACD6C (`subs r0,r5,#1 ; cmp r0,r1 ; bls`, else USER 129 through the panic helper 0x801AB7C4) and stores it at +0x08 ROUNDED UP TO 4 (`adds r5,#3 ; lsrs r0,r5,#2 ; lsls r0,r0,#2`). (b) Append, ordinal 48 = 0x8019CC10 and ARM, addresses four of them in six instructions: `ldm r0,{r3,ip}` = iCount and iEntries, `ldr r2,[r0,#0x10]` = iAllocated, `ldr r2,[r0,#8]` = iEntrySize and `mla r0,r2,r3,ip` = the slot to copy into. (c) Remove, ordinal 50 = 0x801ACECD, recomputes the same address, shifts the tail with 0x8019E7BC and decrements +0x00. (d) The free path, ordinals 46 and 47 (two byte-identical bodies at 0x801ACE24 and 0x801ACF28), zeroes +0x00, hands +0x04 to User::Free 0x801A46BE and then zeroes +0x04 and +0x10. Ordinal 1510 = 0x801ACE39 is `ldr r0,[r0] ; bx lr`, i.e. Count() is word 0 outright.
cactive — 0x18 bytes
CActive: the active object. vtable slot 3 (+0xC) = DoCancel, slot 4 (+0x10) = RunL, slot 5 (+0x14) = RunError — fixed by Cancel's `ldr r1,[r0,#0xc] ; blx r1` and RunIfReady's `ldr r1,[r0,#0x10] ; blx r1` / `ldr r2,[r0,#0x14] ; blx r2`.
evidence
RE'd extent, and it is closed by construction: the constructor (ordinal 1091 = 0x8019F189) writes +0, +8, +0xC and +0x14, and +0xC is the start of an embedded TPriQueLink whose third word is that +0x14, so the object ends at 0x18. The decisive single instruction is in the scheduler's own ARM run loop: 0x8019BA34 `ldmdane lr,{r2,ip,lr}` with lr = &iLink loads [lr-8], [lr-4] and [lr] into r2, ip and lr, and the next three instructions use r2 as the request status (`cmp r2,#0x80000001`), ip as the flags (`ands r3,ip,#1`, then `bic ip,ip,#3 ; str ip,[r0,#8]`) and lr as the next link — so +4, +8 and +0xC are fixed in one go, with 0x8019BA30 `sub r0,lr,#0xc` giving the link offset a second time. Corroborated from the C++ side by SetActive (0x8019F197), Cancel (0x8019F0D9), Deque (0x8019F0FB), the destructor (0x8019F09B) and IsAdded (0x801B7C58 = `ldr r1,[r0,#0xc]`).
cactivescheduler — 0x14 bytes
CActiveScheduler. Install/Current are nothing but fast execs 6 and 5 (ordinal 428 = 0x8019F3E9 `blx 0x8019BD88`, ordinal 427 -> 0x801B747E `blx 0x8019BD80`), so the installed scheduler is a per-thread kernel slot, not a euser global.
evidence
RE'd extent: the constructor (ordinal 430 = 0x8019F38B) writes the vtable at +0 and then builds a TDblQueBase at +8 with link offset 0xC through the generic ctor 0x801A97CA, which writes +0, +4 and +8 of ITS object — so the scheduler ends at 0x14. The destructor 0x8019F3A1 reads exactly the same two words back, `ldr r0,[r4,#8]` (head->iNext) minus `ldr r1,[r4,#0x10]` (iOffset), to recover each CActive* and Deque it, then uninstalls itself with fast exec 6 when it is the current one. +4 is fixed by the loop driver 0x8019F49C (`ldr r0,[r4,#4] ; str r0,[sp,#8] ; str r7,[r4,#4]` pushes a TLoop node, and the epilogue 0x8019F508 pops it) and by Stop (ordinal 425 = 0x8019F527), which walks that list.
tdblquebase — 0xc bytes
TDblQueBase {TDblQueLink iHead; TInt iOffset}. iOffset is how far the link sits inside the queued object — 0xC for CActive.
evidence
The generic doubly-linked-queue head, constructed by EUser 0x801A97CA: `movs r0,#0 ; str r0,[r4]`, then `lsls r0,r1,#0x1e ; str r1,[r4,#8] ; beq` stores the link offset at +8 and panics USER 78 when it is not 4-aligned, then `str r4,[r4,#4] ; str r4,[r4]` self-links both words. IsEmpty 0x801A9796 and the destructor loop 0x8019F3B8 read +0 back and compare it against the head address.
tpriquelink — 0xc bytes
TPriQueLink {TDblQueLink iNext, iPrev; TInt iPriority}. Higher priority runs first: the walk stops at the first queued link whose priority is BELOW the new one.
evidence
The link a CActive embeds at +0xC, fixed by the priority insert EUser 0x801A97FA: `ldr r2,[r0,#8]` takes the queue's iOffset, `adds r2,r1,r2` makes &aLink, `ldr r1,[r2,#8]` reads the NEW link's priority, and the walk `ldr r4,[r3,#8] ; cmp r4,r1 ; bge ; ldr r3,[r3]` compares each queued link's +8 and follows +0 — so +0 is iNext and +8 is iPriority. `ldr r1,[r3,#4]` then takes iPrev for the splice at 0x801A96BC. The CActive constructor 0x8019F189 `str r1,[r0,#0x14]` writes aPriority into exactly this +8 (0xC+8).
tschedulerloop — 0x8 bytes
the TLoop node. Two words, on the stack of whoever called Start — there is no allocation and no fixed count of nesting levels.
evidence
The stack-allocated node CActiveScheduler::Start pushes onto the scheduler's +4. Built by the loop driver 0x8019F49C at sp+8: `ldr r0,[r4,#4] ; str r0,[sp,#8]` puts the previous head in word 0 and `str r5,[sp,#0xc]` the token in word 1 (r5 = 1 for a plain Start, ordinal 426 = `movs r0,#1 ; b 0x8019F49C`), then `str r7,[r4,#4]` makes it the head; the epilogue 0x8019F508 `ldr r0,[sp,#8] ; str r0,[r4,#4]` pops it. Stop (ordinal 425) walks the same list by `ldr r0,[r0]` looking for word 1 == 1 and stores 0 there; the ARM run loop's caller re-reads word 1 every iteration (0x8019BA10 `ldr r1,[r5]` with `cmp r1,#0 ; bxne`), which is what makes Stop end the loop.
libcstat — 0x60 bytes
struct stat as this libc writes it. ⚠ The FIELD NAMES here say what the binary puts there, not what a POSIX header calls it: this ROM is stripped and backend.dll's producer is the only witness. As a cross-reference only (never a committed value), the offsets line up with the openc/newlib struct stat where +0 is st_dev, +8 st_mode, +0xA st_nlink, +0x14 st_rdev, +0x18 st_atime, +0x20 st_mtime, +0x30 st_size (64-bit) and +0x40 st_blksize; the gaps this producer never touches would then be st_ino, st_uid, st_gid, the spare words and st_ctime at +0x28.
evidence
The struct the C library's stat() fills, established end to end from the ROM: libc ordinal 344 = 0x82E70371 converts the path and calls the veneer 0x82E827A4 (`ldr pc,[pc,#-4]` with the target word 0x82E501DF), which is backend.dll 0x82E501DE -> 0x82E46871 -> the worker 0x82E47851. The worker constructs a TEntry at sp+8 (0x82E478_90 `blx 0x82E527F8`, a veneer to 0x801ED69D = the same EFSrv TEntry constructor struct tentry already rests on), then 0x82E478CA `movs r1,#0x60 ; movs r0,r4 ; blx 0x82E52EA8` ZEROES EXACTLY 0x60 BYTES of the caller's buffer — that is the size measurement. Only six places are written afterwards, and every one is quoted in the field rows; everything else stays zero, which is what makes a JS implementation checkable against the real one.
rpointerarraybase — 0x18 bytes
RPointerArrayBase. Its granularity sits at +0xC, exactly where rarraybase keeps iKeyOffset — so one shared set of accessors over both classes would silently write the wrong field. ⚠ +0x10 and +0x14 are zeroed by the constructor and read by nothing, the same gap rarraybase has at +0x18/+0x1C, and they are deliberately not declared as fields. Independently reached by the HLE track from the same address (db/hle/import-impl.tsv euser 481), which is the only reason to state offsets that a second reader also has: they agreed.
evidence
RE'd extent: the constructor writes through +0x14, so at least 0x18 bytes; the full size is not measured. A SEPARATE CLASS from rarraybase with a different layout, and the difference is not cosmetic — the two share iCount at +0 and iEntries at +4 and then diverge. Three bodies fix it. (a) The constructor, EUser ordinal 481 = 0x801AC97B, is nine instructions with no push and never writes r0, so it returns this unchanged: `movs r1,#0 ; str r1,[r0] ; str r1,[r0,#4] ; str r1,[r0,#8] ; str r1,[r0,#0x10] ; movs r2,#8 ; str r1,[r0,#0x14] ; str r2,[r0,#0xc] ; bx lr`. It takes no argument at all. (b) The free path, ordinal 471 = 0x801ACAE3, zeroes +0, frees +4 through User::Free 0x801A46BE and then zeroes +4 and +8 — +8, NOT the +0x10 that rarraybase's equivalent clears, which is the instruction that proves iAllocated moved. (c) Remove, ordinal 474 = 0x801ACA89, bounds-checks with the same USER 130 and then shifts the tail with `lsls r1,r5,#2` and `lsls r2,r2,#2` — a hardcoded stride of four rather than a stored entry size, which is the second proof that this class has no iEntrySize. Count, ordinal 1593 = 0x801ACA07, is `ldr r0,[r0] ; bx lr` like its rarraybase twin.
The 31 walls, in order
Projected from db/frontier.json, the recorded honest halt of the deep ROM run. The single recorded honest halt of the deep ROM run (web/test/run-rom.mjs asserts the run stops exactly here). GREEN = the run halts at `current` and every audit passes; PROGRESS = `current` advances AND the change is backed by new evidence (mapped real code, or new evidence rows). Moving this file by hand WITHOUT the run actually reaching the new halt is the failure the whole db/ tooling exists to prevent. Run `node tools/next.mjs` for the next action.
The wall standing now: unimplemented-import, at 68477 instructions
mgs_ngi.exe's Arena initialisation calls ArenaEnv ordinal 1 at 0x249f8 once the file server has answered for Arena/config.xml
arenaenv@1 — the N-Gage Arena environment's first entry point. The ROM-backed profile has now reached the SAME wall the container-only profile measured from the other side (db/frontier-hle.json): the game's own startup wants the N-Gage platform, whose runtime.dll resolves features through a Central Repository .cre the platform provisions and whose implementations are separately installed Arena DLLs. samples/launcher/installed/sys/bin holds ArenaEnv.dll, and it is a 340-byte stub with 2 exports and 0 imports — so the question to settle first is what that stub actually is, and whether the real implementation is ngiNAF.dll, which carries the same uid3 0x2000106d.
Read ArenaEnv.dll and ngiNAF.dll from samples/launcher/installed/sys/bin with tools/e32info.py before implementing anything: two images share uid3 0x2000106d and only one of them can be the one the game links against. The container-only profile reached this same platform boundary at 19,801 instructions and answered runtime@9/@10 honestly (KErrNone for the environment, the real -1000004 for an unresolvable feature); the ROM profile arrives here with the whole file server and loader behind it, so the two profiles now differ only in what they answer, not in where they stop. Whichever is implemented next, the evidence must come from those DLLs, not from the game's expectations.
Wall 1: unimplemented !FileServer op 22 (RFs::Entry) at 68,015 instrs — the game's Arena runtime asking for the metadata of C:\private10925\Arena\config.xml
By answering it from the VFS the container installed, with the two fields this filesystem cannot source DECLARED rather than invented. Everything the device does was read first (db/os/fs-op.tsv (!FileServer,22)): efile's handler 0x801d3530 delegates to the mounted filesystem through CMountCB vtable slot 0x40 and passes the mount's leave reason through unchanged; the mount is ELocal.fsy, which IS in the ROM (TRomEntry 0x80046328, image 0x801F3BD0, EntryL 0x801F7B21), and its not-found code is KErrNotFound -1, read at 0x801f7b06 rather than chosen. EntryL writes exactly four fields — iAtt, iSize, iModified, iName — and never iType, which independently corroborates struct tentry. TWO of those four have no source in a VFS synthesised from a SIS: iModified is left ZERO (a SIS install carries no per-file timestamp, and the host clock would be both the wrong clock and non-deterministic, which is what the gate rests on) and iAtt reports only bit 0, set when the VFS entry is read-only — the one bit with behavioural support anywhere in this ROM (efile 0x801CC996 -> -21). A name that is a directory in the VFS HALTs rather than guess the attribute ELocal would report for it, and a name with neither a drive nor a leading separator HALTs because efile's TParse defaults for that case are not disassembled. MEASURED: 68,015 -> 68,477, the reply is 'C:\private10925\Arena\config.xml -> 3376 B' (asserted in run-rom.mjs), no message is left outstanding, and the run walks on into the game's own Arena initialisation — where it meets, from the ROM side, the same wall the container-only profile measured: arenaenv@1.
evidence
db/os/fs-op.tsv (!FileServer,22); web/src/fs/fileserver.js entry/absolutePath/writeEntryHeader/writeEntryName; web/test/run-rom.mjs !FileServer sequence + the Entry reply assertion
Wall 2: null-branch from euser 0x8019fb36 (1014 instrs) — the honest frontier
evidence
STILL OPEN after the carry fix (boot path doesn't hit it). Upstream divergence causes a mid-function entry at fb14. Next: deeper back-trace + more oracle coverage.
Wall 3: Thumb ADDS/SUBS dropped carry/overflow (reg+imm3+imm8 forms set only N/Z)
cpu.js addF/subF — found by the expanded differential oracle (64-bit ADC). NOT on the boot path before 1014, but a real systemic bug.
evidence
web/test/cpu-oracle.mjs 64-bit add; tools/lib/elf-link.mjs
Wall 4: libc@2 import (600 instrs)
Mapped the REAL libc/pthread/zlib/dl from firmware (run pristine code, not HLE stubs) — resolved 95 imports honestly.
evidence
run-rom FIRMWARE_LIBS; samples/firmware/5320/sys/bin
Wall 5: exec 0x4c (676 instrs)
WAS FABRICATION-INFLATED: only reached because libc@2 silently returned 0 at ~600 instrs. Making import stubs HALT exposed the real frontier.
evidence
registry _stub now throws
Wall 6: cleanup-stack panic 66/63 (hand-modeled CCleanup)
let real euser CTrapCleanup::New build it; artefact of the cond bug
evidence
web/src/kernel/kstate.js installBootHeap
Wall 7: Thumb cond() HI/LS/VS/VC missing -> every bhi/bls always taken
cpu.js cond() explicit cases 6..9 (real CPU bug)
evidence
web/test/thumb-cond.mjs
Wall 8: boot diverged → mid-function entry at euser fb14 → null pop (~1014 instrs)
ROOT CAUSE: the ROM places XIP DLL code at codeAddress+8, but mapRomImage loaded at codeAddress (8 low). DLLs were self-consistent internally but ROM veneers (absolute VAs) landed 8 bytes into target functions, skipping the prologue. Fixed: ROM_CODE_SHIFT=8 (rom.js) + bootstrap VA +8 (kstate.js). Boot now reaches exec 0x4d cleanly at 890 instrs.
evidence
rom.js ROM_CODE_SHIFT; the standalone euser code section appears in the 5320 ROM at codeAddress+8
Wall 9: exec 0x4d UserSvr::RomRootDirectoryAddress (890 instrs)
returns TRomHeader.rom_root_dir_list (read live from ROM header @+0x94). Evidence: eka2l1 svc.cpp + rom.h. Boot advanced to 1536 instrs.
evidence
db/os/exec-impl.tsv slow 0x4d; web/src/kernel/exec-impl.js rom_root_dir_address
Wall 10: exec 0x69 handle_close / RHandleBase::Close (1536 instrs)
ekern 0x80083b98: h&0x8000->KErrGeneral; else close+return TObjectType (EChunk=2). Impl drops the handle and returns the type; euser user-side cleanup ran clean. The earlier 'lifecycle gap' was a false alarm — exec 0x6a DID create handle 0x40000001. Boot advanced to 1587.
evidence
db/os/exec-impl.tsv slow 0x69; exec-impl.js handle_close
Wall 11: exec 0xa0 static_call_list + the static-init loop (1587-1852 instrs)
RE'd the euser runner (0x801a6174): a loop StaticCallList->run ctors->StaticCallDone that stops when either returns -25. Our model has no codesegs in that loop -> StaticCallList returns -25 (done) on call 1, exiting cleanly (+256). Skips static_call_done (0xa1) entirely. MUST revisit when game DLLs are loaded.
evidence
db/os/exec-impl.tsv slow 0xa0; euser runner adds r0,#0x19;beq
Wall 12: fast exec 0x2 User::Critical (1852 instrs)
ekern 0x8006a67c returns thread's TCritical field; impl returns tracked KState.thread.critical (default ENotCritical=0). Boot advanced to 1885.
evidence
db/os/exec-impl.tsv fast 0x02 user_critical
Wall 13: slow exec 0x6f RSemaphore::CreateLocal (1885 instrs)
ekern 0x80084184 + eka2l1 semaphore_create; euser caller panics on count<0 (the semaphore guard). Alloc a 'semaphore' handle (TObjectType 4); single-threaded so it never blocks. Creating it (the heap lock) unblocked +2963 instrs to 4848.
evidence
db/os/exec-impl.tsv slow 0x6f sema_create
Wall 14: slow exec 0x67 RThread::Create (4848 instrs)
ekern 0x800839fc copies a 0x30B SThreadCreateInfo (eka2l1 thread_create); alloc a suspended 'thread' object (TObjectType 0) capturing entry/arg/stack/size; return handle. Boot advanced to 4874. Resume/scheduler is the next subsystem step.
evidence
db/os/exec-impl.tsv slow 0x67 thread_create
Wall 15: exec 0x28 RThread::Resume + word LDREX/STREX (4874-5963 instrs)
thread_resume marks the thread runnable (eka2l1 thread_resume). Added ARMv6 word LDREX/STREX to arm.js (single-CPU: monitor always succeeds; v6K byte/half/dword stay unimplemented per the decode audit). +1089 to 5963, then a null fn-pointer call in the thread-entry region.
evidence
db/os/exec-impl.tsv slow 0x28; web/src/cpu/arm.js LDREX/STREX
Wall 16: exec 0xa0 mis-identified as the C++ constructor path (the 2025 revision of this ledger)
CORRECTED BY RE, not by a code change. ekern 0x80089a84 walks [DThread+0x138], an SDblQue that the DThread constructor self-links empty (0x800927f8 `add r1,r0,#0x138`) and that ONLY DLibrary::Close feeds (0x80088cec-0x80088d08); its euser runner 0x801a617c passes r0=3. It is the pending-library-DETACH queue, so -25 was always the faithful answer for a process that has closed no RLibrary. Constructors come from exec 0x9f (ekern 0x80089994) off DProcess::iCodeSeg at +0x108. The 2025 note claiming 0xa0 walks a codeseg init-list, and the REFUTED experiment built on it (publishing entry_point+8 and calling with r0=3), were both wrong for the same reason: entry_point+8 is the `bx ip` of a self-contained veneer whose `ldr ip,[pc,#4]` sits at +4, and r0=3 selects the destructor branch, whose guard word in this ROM is 0.
evidence
db/os/exec-impl.tsv slow 0xa0 and slow 0x9f evidence columns
Wall 17: null function-pointer call at backend.dll 0x82e462a8, lr=0x82e531a7 (5963 instrs) — the member at [0x3ffc0094] never constructed
The codeseg / static-initialiser subsystem, plus THREE pre-existing fabrications the old import resolution had been hiding. (1) TRomImageHeader is 120 (0x78) bytes, not 128: `HDR=128` + a compensating `ROM_CODE_SHIFT=8` loaded each XIP image at the right address but read the export directory two slots too far, so every one of the 95 firmware imports resolved to ordinal+1's VA + 8 bytes. euser@1360 was landing on 0x801b05c5 (which happened to return 0) instead of the real UserHeap::SetupThreadHeap 0x801b2d01. Removing it dropped the run 5963 -> 340 — de-fabrication, exactly like the 0x4c entry of 2025, and every later gain is real work. (2) `msr cpsr_f,Rm` was a silent no-op in arm.js, so euser's descriptor decoder at 0x8019ddf4 kept the flags from the preceding `cmp` and every TLitC decoded to garbage; SetupThreadHeap rejected its chunk name with KErrBadName. 340 -> 932. (3) exec 0x3 was recorded as `mov r0,#0xf ; bx lr`, which is the handler of exec 0x4 at 0x800822b0 — the +8 misread of the same header bug, via tools/disasm.py. The real handler at 0x800822a8 is `ldr r0,[r0,#0x28]` = RChunk::MaxSize, and exec 0x6b selector 0 (RChunk::Adjust) was returning the chunk base where euser's `beq` demands KErrNone. With those fixed the real heap builds and the game reaches EUser ordinal 585, which issues svc #0x9f. Exec 0x9f now walks DProcess::iCodeSeg deps-first/self-last (ekern TraverseDeps 0x800874d8) and publishes 8 TRomImageHeader.entry_point veneers; euser's runner calls each with r0=2 and skips the last (the EXE). Also fixed: XIP writable data lives at TRomImageHeader+0x60 (data_bss_linear_base), not at data_address — the old loader was zeroing bss over libc's own TRomDllRefTable in ROM. VERIFIED, not assumed: exec 0x9f dispatched exactly once with count 8; all 7 DLL veneers executed exactly once each with r0==2; euser and libc _E32Dll both reached their .init_array walkers; LibCIpcClient's ctor (0x82e61cd6) and libc's (0x82e78cec) ran; the game's own walker 0x83ac -> ctor 0x22adc still ran afterwards; and [0x3ffc0094] — the exact word this frontier recorded as null — reads 0x1000. Boot advanced 5963 -> 5581 at a NAMED unimplemented exec (0x7e) instead of a null branch.
evidence
db/os/struct.tsv romimageheader + romdllreftable + tchunkcreate; db/os/exec-impl.tsv slow 0x9f, 0x3, 0x6b, 0x6a, 0x6d and the rewritten 0xa0; web/src/kernel/codeseg.js; web/src/cpu/rom.js HDR=0x78; web/src/cpu/arm.js MSR; web/test/run-rom.mjs codeseg assertions
Wall 18: slow exec 0x7e Exec::SessionCreate (5581 instrs) — fully RE'd but deliberately left unimplemented, because the only answer our kernel could justify was KErrNotFound over an EMPTY EServer container
By BUILDING the container the previous rung said to build, and by letting the ROM — not the author — say what is in it. The blocker was never the exec's algorithm (that was already RE'd); it was the claim 'a real 5320 has a server named !FileServer at this moment', which now rests on the firmware itself. TRomHeader carries two boot-file pointers, and both decode without ambiguity: iPrimaryFile (+0xA0) -> TRomEntry{iSize 0x37CB8, iAddressLin 0x80067F88, iNameLength 9, iName 'ekern.exe'} and iSecondaryFile (+0xA4) -> TRomEntry{iSize 0x205D4, iAddressLin 0x801CB600, iNameLength 9, iName 'efile.exe'} — each iAddressLin is a real TRomImageHeader VA, so the decode is coincidence-free. iSecondaryFile is the first USER-side process the kernel starts, i.e. the F32 file server runs before any application does; our emulator simply skips device boot. The NAME is read out of efile.exe's own TLitC16 at 0x801E7EC0 (length 11, '!FileServer'), never typed in, and web/src/kernel/servers.js verifies every structural expectation around it (iSecondaryFile really names efile.exe, its uid1 really is 0x1000007a EXE, the literal really lies inside that image, the text really is 11 characters and '!'-prefixed) and HALTs otherwise. Cross-checks that close the loop: searching all of 5320.rom for the UTF-16 text finds it exactly TWICE — in efile.exe and in \Sys\Bin\EFSrv.dll (header 0x801EBC50) at 0x801F2FC0, the client library whose RFs::Connect is the very code issuing this exec; efile.exe treats the string as a SERVER name in its own code (0x801D2D74 loads it into a TFindServer, 0x801D2D80 calls TFindServer::Next); and on the kernel side a sweep of ALL TWELVE call sites of ekern's container-add helper 0x8008C22C finds r1 = 7 (EServer) at exactly one, 0x8008A8E0, inside the handler of slow exec 0x7d = Exec::ServerCreate — which also does `ldrb r0,[r0] ; cmp r0,#0x21` at 0x8008a850 (the '!'-prefixed protected-server convention) and writes aMode to DServer+0x3c at 0x8008a8a8, the exact byte DSession attach reads back at 0x8008a37c. The exec itself is implemented in the handler's own order and every case the firmware does not fix HALTs rather than defaulting: a non-null aPolicy (the six in-range arms of the validator 0x8009a66c are unread), aMsgSlots>0 (the dedicated message pool at 0x8008a2fc is unmodelled), aMode>0 (would require efile's TIpcSessionType, which aMode=0 does not), a wildcard name (only the '*' comparison of TDesC::Match 0x80099048 has been read), and a KState with no ROM behind its memory. Also added on the way: web/src/kernel/descriptor.js, which reproduces the kernel's own descriptor-head decoder ekern 0x8006b934 instruction for instruction — its `eor r3,r3,r3,lsr #1 ; msr apsr_nzcvq,r3` flag trick reproduces all five TDesType layouts, and the boot's live TBuf8 {0x3000000b,0x50,'!FileServer'} lands on type 3 EBuf exactly as predicted. VERIFIED, not assumed: exec 0x7e dispatched exactly once and returned handle 0x40000029; the EServer container holds exactly one object, '!FileServer', sourced from efile.exe; and the run's NEXT exec is 0x4c with r0 = 0x40000029 (that same handle) and r1 = -1 = KConnectMessage — so the boot went THROUGH RFs::Connect, not around it. All four are asserted permanently in web/test/run-rom.mjs. Boot 5581 -> 5624.
evidence
db/os/exec-impl.tsv slow 0x7e; db/os/struct.tsv + struct_field.tsv tdesc / tromheader / tromentry; web/src/kernel/servers.js; web/src/kernel/descriptor.js; web/src/kernel/exec-impl.js session_create; web/test/run-rom.mjs EServer/session/KConnectMessage assertions
Wall 19: slow exec 0x4c Exec::SessionSendSync (5624 instrs) — the synchronous send carrying RFs::Connect's KConnectMessage, with the open worry that implementing it would mean fabricating the server's reply
By implementing what the handler ACTUALLY does and nothing more — and the firmware turned out to make that easy, because the exec's return value has nothing to do with the server's answer. ekern 0x8006ac80 was disassembled end to end and reproduced in its own order: `ldr r4,[r0,#0x20]` proves the handler is entered with a DSession* (that field is the one DSession attach writes at 0x8008a3ac `str r5,[r4,#0x20]`, r5 being the DServer whose +8 iAccessCount attach had just tested the same way at 0x8008a33c — two routines cross-confirming one layout); `cmn r1,#2` -> -6 KErrArgument; `cmp r4,#0 / ldrne r6,[r4,#8] / cmpne r6,#0 / beq` -> -15 KErrServerTerminated. The message is the RMessageK embedded in the DThread at +0xEC — `sub ip,sb,#0x1d4` measured against the `sub sb,sb,#0x2c0` that the same routine uses to get the current DThread at 0x8006ad54 — and its first word is the queue link the delivery routine writes at 0x8006ae60, so `ldr r7,[ip] ; cmp r7,#0 ; bne 0x80089e50` is a busy test whose failure leg is `mov r0,#0x1e ; b 0x8008d468`, i.e. a KERN-EXEC PANIC (30), not an error return. 0x8008d468 was disassembled to confirm it is the panic helper (`mov r2,r0` code / `sub r0,r0,#0x2c0` current thread / `mov r1,#2` category / `b 0x80092638`), which also reclassifies the connect branch's two guards: 0x8006adc8 requires DSession+0x4c and +0x2c to be null and panics with 9 / 0x39 otherwise. The tail bumps iMsgCount (+0x34), links the message onto DSession+0x3c/+0x40, copies the caller's TIpcArgs as four words plus a HALFWORD of type flags (`ldmne r2,{r1,r3,r6,r7,r8}` / `stm r2!,{r1,r3,r6,r7}` / `strh r8,[r2]` — corroborated by euser's wrapper preloading the identical five words at 0x8019c590), delivers via 0x8006ae44 and returns `mov r0,#0`. Delivery itself is four instructions of SDblQue append onto DServer+0x34/+0x38 whenever DServer+0x28 (a thread blocked in Receive) is null; the non-null hand-off at 0x8006dfac MMU-switches to the receiver and was left HALTing because it is not RE'd. THE KEY FINDING, which removes the worry entirely: nothing in this handler ever dereferences aStatus — 0x8006acb4 only RECORDS it at msg+0x30. euser proves the division of labour: its send wrapper 0x801a6c58 pre-sets the TRequestStatus to KRequestPending (literal 0x801a6f0c = 0x80000001, iFlags 2), returns the exec's error directly when it is non-zero, and ONLY on zero calls User::WaitForRequest 0x801a4594 — whose loop spins on `blx 0x8019bd58` while [status] == KRequestPending (literal 0x801a48c0, the same constant) — before `ldr r0,[sp]` returns the STATUS as RSessionBase::CreateSession's result. So KErrNone here is the firmware's own answer, not a stand-in for the server's, and the status was deliberately left pending. VERIFIED, not assumed, and asserted permanently in web/test/run-rom.mjs: exec 0x4c dispatched exactly once; !FileServer's message queue holds exactly one message, fn == -1, whose session is the one exec 0x7e minted, which is simultaneously held in DSession+0x4c and in the thread's own sync slot with iMsgCount 1; the TIpcArgs type-flags halfword read 0; and — the anti-fabrication check — the guest word at *aStatus still reads 0x80000001 at the halt, so no reply was invented. Boot 5624 -> 5637, halting inside User::WaitForRequest on fast exec 0, which is the scheduler, not another exec.
evidence
db/os/exec-impl.tsv slow 0x4c session_send_sync (full disasm trace of ekern 0x8006ac80/0x8006ad50/0x8006adc8/0x8006ae44 + the panic helper 0x8008d468 + euser 0x801a6c58/0x801a4594); web/src/kernel/exec-impl.js session_send_sync and the DSession fields added to session_create; web/src/kernel/servers.js DServer msgQ/receiver; web/src/kernel/kstate.js thread.syncMsg; web/test/run-rom.mjs exec-0x4c assertions incl. the KRequestPending check
Wall 20: fast exec 0x0 Exec::WaitForAnyRequest (5637 instrs) — the wait loop inside User::WaitForRequest after exec 0x4c queued RFs::Connect's KConnectMessage, with the handler VA for fast 0 flagged as suspect
By reading the SVC vector instead of the table, and by building the scheduler the wait needs. ekern's __ArmVectorSwi at 0x80068120 (found by its opcode `ldr ip,[lr,#-4]`) decodes the immediate with `lsls ip,ip,#9` — C = bit 23, Z = the number is zero — then `blo` slow, `ldr r1,[fp,#0x130]` (TScheduler.iCurrentThread) and `beq 0x80068708`: fast exec 0 is special-cased BEFORE the fast table is indexed, and the table itself is indexed count-first (`ldr r3,[r2],ip,lsr #7 ; ldr r2,[r2] ; cmp r3,ip,lsr #9 ; bxhi r2` over the table NKern::ThreadCreate installs at 0x80069e1c, whose word 0 is the count 0x1c). So docs/data/ekern-exec-table.tsv's whole fast column is shifted by one slot: its 'fast 0x0 = 0x8006a674' is exec 1 (Heap: DThread+0x48), its 0x8006a684/0x8006a68c are 5/6 (ActiveScheduler get/set), 0x8006a694/0x8006a69c are 8/9 (TrapHandler get/set), and its 'fast 0x1b = 0xf1' is the SLOW table's count word. wait_for_any_request 0x80068708 is eleven instructions: `ldr r2,[r1,#0x20] ; subs r2,r2,#1 ; str r2,[r1,#0x20] ; bxge lr` decrements NThread::iRequestSemaphore.iCount and returns at once while it stays >= 0; otherwise iKernCSLocked/iRescheduleNeededFlag (TScheduler+0x10c/+0x108), iWaitObj = &the semaphore (+0x28), iNState = 2 EWaitFastSemaphore (+9), unready (0x800687ac) and the reschedule 0x80068c40, which saves SP at NThread+0x68 and runs the head of the highest-priority ready queue (the two-word bitmap at TScheduler+0, queues at +8+4p, new entries linked at the TAIL by 0x800688a8..0x800688b4). The only wake-ups are NFastSemaphore::SignalN 0x80068750 / Signal 0x8006880c — reached from NKern::ThreadRequestSignal 0x8006ea50, which is slow exec 0x3b (0x80082ba8 `mov r1,r0 ; mov r0,#0 ; b`) and the tail of every request completion. web/src/kernel/threads.js models exactly that: Thread contexts, the request semaphore, nstate/suspendCount, a FIFO ready queue, and a reschedule that HALTs as scheduler-idle when nothing is ready — naming the blocked thread, its semaphore count and the queued message only !FileServer's thread could complete. RThread::Create (0x67) and Resume (0x28) were re-derived on the way: Exec::ThreadCreate 0x800839fc validates iTotalSize/iUserStackSize (-6), forces type 3 / priority -5 / no supervisor stack, and the new thread's first user context is fixed by EpocThreadFunction 0x8006a868 (stack filled with 0x29, the info copy at the stack top with SP = top - iTotalSize, r4 = 1, PC = DProcess::iReentryPoint = the codeseg entry stored by 0x80091e00), ESuspended with iSuspendCount -1 until NKern::ThreadResume 0x8006e5d4 brings it to 0 — all unit-tested through the dispatcher in web/test/kernel.mjs, including a full block/switch/signal/resume round trip. THREE FABRICATIONS ON THE LIVE BOOT PATH fell out of the same RE and are gone: fast exec 2 is Exec::HeapSwitch (0x80082140 stores r0 into DThread+0x48/+0x4c and returns the old allocator — the old 'user_critical' returned 0 and every allocation kept going through the host bootstrap allocator); slow exec 1 is RChunk::Base (0x80082298 = DChunk+0x2c; euser constructs the RHeap at that address + offset, and with the old 'committed size' answer the heap had been built at 0x40000 in low memory — the next exec in the log, HAL::Get, wrote the page size to 0x40048); slow 0x4d is Exec::DllTls (0x80083128 tail-branches into DThread::Tls), not RomRootDirectoryAddress. Slow 0x14/0x61/0x5a rows, all from the defective euser-svc-map, were removed (their handlers are not what the rows claimed) and DllSetTls (0x75), DllFreeTls (0x76), ThreadRequestSignal (0x3b) and MutexCreate (0x6e) were added from their handlers. With the guest's own RHeap now running, the boot executes 630 more real instructions to the same wait: 5637 -> 6267, halting as scheduler-idle with the request semaphore at -1 and *aStatus still KRequestPending (both asserted in run-rom.mjs). The file server itself now boots in a second lane — see `secondary`.
evidence
db/os/exec-impl.tsv fast 0x00 wait_for_any_request, fast 0x02 heap_switch, fast 0x01, slow 0x01 rchunk_base, slow 0x3b, 0x28, 0x67, 0x4d, 0x75, 0x76, 0x6e (each carrying its disasm trail); db/os/struct.tsv + struct_field.tsv nthread / dthread / tscheduler / nfastsemaphore / sthreadcreateinfo / dprocess and romimageheader heap_min/heap_max/stack_size; web/src/kernel/threads.js; web/src/kernel/kstate.js (Scheduler, attachRomProcess, iReentryPoint); web/test/kernel.mjs scheduler round trip; web/test/run-rom.mjs scheduler + heap assertions
Wall 21: efile.exe secondary lane (9,392 instrs, halted at Exec::ObjectNext slow 0x0) — the dropped kernel-3 direction
RETIRED. It existed to run efile.exe as a guest so its thread could complete the game's connect message; the direction change of 2026-09-02 evening (game first, HLE at the server boundary) makes that unnecessary — the messages are completed at the boundary instead, and the game lane now has its own honest, deeper frontier (37,330 instrs). The RE it produced is preserved: DllSetTls (0x75), MutexCreate (0x6e) and UserSvr::RomHeaderAddress (fast 0x13) are all live execs used by the game lane too, and efile's CServer2 construction (0x801d2a6e / StartL 0x801a29a1 / RServer2::CreateGlobal) is the evidence for hle-server.tsv's server names and session types. The one-KState two-process merge (per-process data windows, the 0x8006dfac direct hand-off, the 0x800928ec priority table) is NOT needed for the game and is dropped, as §8 directed.
evidence
db/os/hle-server.tsv evidence column (efile's server construction); db/os/exec-impl.tsv slow 0x75 / 0x6e, fast 0x13 (RE'd in the efile lane, used by the game lane)
Wall 22: scheduler idle at 6,267 instrs — the main thread blocked in User::WaitForRequest on RFs::Connect's KConnectMessage, with no file-server process to complete it (the halt this session resolved)
By implementing !FileServer as an HLE SERVICE at the session boundary (emulation/CONSOLIDATION-2026-09-02.md §8), NOT by running efile.exe as a guest. Two declared tables drive it: db/os/hle-server.tsv (server -> the TLitC16 VA in efile.exe that names it + its TIpcSessionType, RE'd from efile's own CServer2 construction: 0x801d2a6e allocs the file server and StartL 0x801a29a1 -> RServer2::CreateGlobal 0x801a6e96 -> svc 0x7d with aMode = 2, so !FileServer is EIpcSession_GlobalSharable) and db/os/fs-op.tsv (function number -> client wrapper evidence + TIpcArgs flags). tools/gen-exec.mjs (extended, not a fourth generator) emits web/src/kernel/gen/hle-dispatch.gen.js; web/src/fs/fileserver.js answers the ops from the VFS; web/src/kernel/servers.js completes each message the device's way — the RMessageK bookkeeping of Exec::MessageComplete (ekern 0x8006ab9c: unlink from the session SDblQue, iMsgCount--, DThread+0x20--), then DThread::RequestComplete (0x8006e2ac) writes the reason into *aStatus and NKern::ThreadRequestSignal signals the request semaphore fast exec 0 blocked on — so the waiting thread readies and the run continues with no fabricated wake-up. Six execs the CRT needs alongside the send were RE'd and de-fabricated on the way (each with its handler disasm in db/os/exec-impl.tsv): 0x27 SessionShare (the CRT shares its RFs session right after Connect), 0x16 ProcessFileName, 0x17/0x51 ProcessCommandLine[Length], 0xad ProcessSecurityInfo (all reading the DProcess fields KState.attachProcess now records from the E32 header), 0xd0 ProcessGetDataParameter (-1 for an unset slot, matching EKA2L1's log), 0x11/0x12 MutexWait/Signal, 0x26 ThreadId; the old 0x51='allocator_get' and 0x26='rchunk_bottom' rows were fabrications (remembered API names, not handler reads) and are corrected. Kernel descriptor WRITE side added: web/src/kernel/descriptor.js writeDes16 reproduces K::USetLength (ekern 0x8006b9a4) so the HLE writes TDes16 replies (session path) the kernel's way. VERIFIED against C:\tools\eka2l1\traces\mgs-2026-09-02.log, an independent HLE of the same game: it sends the IDENTICAL sequence to !FileServer — Connect(-1), SetSessionToPrivate(85), then three Connect(-1)+SessionPath(14) pairs — and all eight complete KErrNone here, in that order (asserted in web/test/run-rom.mjs). Boot 6,267 -> 37,330, halting in the GAME's own code at the first call into metalgearslibdl.dll (the game lane's next slice), NOT at a file-server op.
evidence
db/os/hle-server.tsv (!FileServer, !Loader); db/os/fs-op.tsv (Connect, SetSessionToPrivate, SessionPath, PrivatePath implemented; DriveList/SetSessionPath/CreatePrivatePath/Entry named); web/src/fs/fileserver.js; web/src/kernel/servers.js (deliver/completeMessage/closeSession); web/src/kernel/descriptor.js writeDes16; db/os/exec-impl.tsv slow 0x27/0x16/0x17/0x51/0xad/0xd0/0x11/0x12/0x26 (each with handler disasm); tools/gen-exec.mjs HLE codegen + web/src/kernel/gen/hle-dispatch.gen.js; web/test/run-rom.mjs IPC assertions incl. the anti-fabrication *aStatus check and the mgs-2026-09-02.log order
Wall 23: unimplemented import metalgearslibdl@3 at 37,330 instrs — the game reached its own dlsym/dlopen shim and run-rom mapped only the firmware XIP libs, so every import into MetalGearSlibdl.dll resolved to the HALTing stub (the halt this session resolved)
By MAPPING THE GAME'S OWN RAM DLL and running its real code, not by answering ordinal 3. MetalGearSlibdl.dll lives in the container's <Game> SIS at !:\sysin\MetalGearSlibdl.dll (34,395 B, DEFLATE, uncompressedSize 57,692 -> 57,848 B flat); loader/e32.js flatImage already inflates it, and the result is byte-identical in the CODE section to samples/from-hexenwerkzeug/metalgearslibdl_decompressed.bin - the ONLY 4 differing bytes are the compressionType word at 0x1c, which the salvaged copy zeroed. So the container is the source and the salvaged fixture is not needed. Three pieces were added. (1) loader/e32.js getExports: the export directory is a FILE offset into the flat image - read at h.exportDirOffset all 1310 entries land inside [codeBase, codeBase+codeSize), read at codeOffset+exportDirOffset only 1272 do, which is what settles the interpretation. (2) cpu/bridge.js mapImage takes opts.loadBase: codeDelta = loadBase - h.codeBase is applied through the image's OWN 2,664-byte code relocation block (applyRelocs already took a delta, every caller had passed 0), and the mapped image now returns ordinalVA, the same contract cpu/rom.js gives a ROM module, so it can satisfy another image's imports. An ordinal outside the table HALTs rather than returning 0 into the caller's import slot. A relocated image carrying data/bss HALTs too - its writable-data run address is not modelled and would have to be invented. (3) loader/ramcode.js: for each of the EXE's import blocks not already satisfied by a ROM module, look for <drive>:\sysin\<key>.dll in the VFS (skipping z:, which is the XIP ROM) and map it into a RAM code slot at 0x70000000 + (n+1)*0x100000, refusing a module whose code does not fit the slot. MetalGearSlibdl.dll therefore loads at 0x70100000 with codeDelta 0x700f8000 and 1,310 exports. MEASURED: the run advanced 37,330 -> 41,695 instructions and 10 instructions retired INSIDE the DLL's mapped span (run-rom asserts ramSteps > 0, so a future regression that stops entering it fails the gate); 'no wild-branch' still passes once inSane knows the mapped RAM spans, and the !FileServer handshake is unchanged - its 8 messages still complete in the same order and its queue is empty at the halt. CROSS-REFERENCE ONLY (never the source of a committed value, CLAUDE.md §Evidence doctrine precedence): EKA2L1's own trace C: ools\eka2l1 races\mgs-2026-09-02.log creates a code chunk at 0x70100000 for metalgearslibdl.dll and 0x70000000 for mgs_ngi.exe - our independently-chosen slot allocator lands on the same address, which is what makes the two exec traces line up for tools/oracle-diff.mjs.
Wall 24: unimplemented !Loader op -1 (KConnectMessage) at 41,695 instrs — the CRT connects to the loader server once the game's own MetalGearSlibdl.dll has run, and !Loader was NAMED in db/os/hle-server.tsv but had no impl (the halt this session resolved)
By RE-ing CServerLoader::NewSessionL and answering the connect with the device's own arithmetic. The loader server's vtable is 0x801E96E8 (its constructor 0x801D0C30 does `movs r2,#0 ; blx 0x801e4bb4` = CServer2::CServer2(aPriority, aType=0) then `ldr r1,[pc,#0x194] ; str r1,[r0]`, literal at 0x801D0DD0). NewSessionL is SLOT 7 of that vtable = 0x801D2225 — slot 7 because the file server's vtable 0x801E9E8C holds the already-known CServerFs::NewSessionL 0x801D2E4B in the same slot, which pins the index instead of guessing it. Its body builds TVersion(iMajor 1, iMinor 0, iBuild [0x801D2604] = 0x625) and calls the same User::QueryVersionSupported stub 0x801e538c the !FileServer connect uses, leaving KErrNotSupported (`subs r0,r0,#5`) when unsupported. QueryVersionSupported (euser 0x801A5B27) was disassembled rather than assumed, because the answer turned on it: it reads ONLY offset 0 (iMajor) and offset 1 (iMinor) via ldrsb and returns 1 iff requested.iMajor < current.iMajor, or the majors are equal and requested.iMinor <= current.iMinor. iBuild is never read. The client asks for 0x063f0001 = 1.0.1599 against the server's 1.0.1573, so the higher client BUILD does not matter and the connect is supported: KErrNone is the device's result, not a chosen one. Structural change that came with it: a server's HLE impl no longer has to live in fileserver.js. hle-server.tsv gained an impl_file column, tools/gen-exec.mjs reads each server's own module and emits one import per module, and its 'what to do next' text now names that module — so web/src/fs/loader.js holds loaderServer and fileserver.js is not a dumping ground for every future server. User::QueryVersionSupported also stopped being duplicated: web/src/kernel/version.js owns the one predicate and both servers import it, so the rule exists once. MEASURED: 41,695 -> 41,865 instructions, the connect completes KErrNone (run-rom asserts it by server, and the !FileServer sequence is unchanged at -1,85,-1,14,-1,14,-1,14), and nothing is left outstanding (sync slot free, ipcCount 0) because all 9 sends are now answered and waited on.
Wall 25: unimplemented exec 0x9b at 41,865 instrs — issued by euser at 0x8019c2fc as soon as the !Loader session is open (the halt this session resolved)
By identifying it as the DLL-LOCK ACQUIRE, which also CORRECTS what the previous revision of this ledger asserted. That revision said exec 0x9b 'RETURNS A HANDLE to an object hanging off the current thread's owner at +0x114 ... it is an accessor'. That is WRONG on both counts, and the refutation was already sitting in this repo's own tables: exec-impl.tsv's slow 0x11 row records ekern 0x80082438 `b 0x800865a8` as DMutex::Wait, and 0x800865A8 is exactly what 0x80087C18 calls. No handle is created. WHAT IT REALLY IS. The handler 0x80089704 reads [0x80088D84] = 0xC800043C = &TheScheduler (the same constant struct.tsv's tscheduler row already rests on), +0x130 = iCurrentThread, -0x2c0 = the embedding DThread, and `ldr r4,[r0,#0x30]!` takes iOwningProcess while the WRITE-BACK leaves r0 = DThread+0x30 — so the following +0x108 is DThread+0x138, NOT DProcess+0x108 as the previous note claimed. That is an SDblQue of DLibrary pending detach; SDblQue::IsEmpty (0x8009D2D8) must return true or the handler panics KERN-EXEC 37. It then waits DProcess+0x114, the process's 'DLL$LOCK' DMutex (order 0xFE, created by DProcess::Create 0x800917C4 via K::MutexCreate 0x80082024; its sibling +0x110 is '$LOCK', order 0x48). Exec 0x9c is the release half — the identical handler with panic 43, tail-calling 0x80087C3C which signals the same mutex via 0x80085BF4 (this table's slow 0x12 already records that as DMutex::Signal). euser proves the pairing: the caller at 0x801A78FE issues svc 0x9b at 0x801A793E, tests the result, and issues svc 0x9c at 0x801A7964 on the failure path. So euser-svc-map's 'E32Loader::ProcessCreate' is refuted, not merely unverified: the handler takes no arguments, creates nothing, and is one half of a lock pair. Implemented as dll_lock_wait / dll_lock_signal over a DMutex modelled on the process (recursive on the same thread; the blocking leg for a lock held by ANOTHER thread HALTs rather than pretending to acquire), with the pending-library queue modelled as a real list so the KERN-EXEC 37/43 precondition is actually checked rather than skipped. New rows: exec-impl.tsv slow 0x9b + 0x9c, struct_field.tsv dprocess.process_lock 0x110, dprocess.dll_lock 0x114, dthread.pending_library_q 0x138. MEASURED: 41,865 -> 41,904, and the run now sends !Loader function 2 — which is what the RE predicted the lock was being taken FOR.
Wall 26: unimplemented !Loader op 2 at 41,904 instrs — the library-load request the game takes the process DLL lock for (the halt this session resolved)
By RE-ing the whole request and answering it. fn 2 is the DLL load: euser ordinal 1135 at 0x801A78FE sends it (0x801A7956 `movs r1,#2`), the flag word 0x1FC is a CONSTANT baked into euser's 3-arg TIpcArgs ctor 0x801B8B04 (`movs r1,#0xff ; adds r1,#0xfd`) rather than arithmetic on the observed value, and efile's CSessionLoader::ServiceL 0x801D1DCC dispatches it through __ARM_switch8 to 0x801D1F5A. The handler 0x801D1480 reads tldrinfo.uid1 and accepts only 0 or 0x10000079 — the literal [0x801D16E8] = 0x1000007A minus one — forcing 0x10000079; fn 1 checks 0x1000007A and appends '.EXE' instead, which is precisely what makes 2 the LIBRARY load. '.DLL' is appended only when the name carries no '.' (TLitC8 0x801E7574). TWO CORRECTIONS the adversarial pass produced, both re-derived here before adopting. (1) ServiceL accepts fn 1..14, NOT 1..15: 0x801D1DD6 `subs r0,r5,#1 ; cmp r0,#0xe ; blo` is an unsigned-lower test on (fn-1). (2) The 0x20-byte write-back is CONDITIONAL in the handler (skipped when req+0x2C is null, 0x801D1552), and is mandatory only because ServiceL always stores aMessage there. Declared: struct tldrinfo (0x20) with uid1/uid2/uid3/one_at_0c/handle/version — +0x14 and +0x1C are never written on the library path and are deliberately NOT declared; and fs-op row (!Loader, 2). MEASURED: 41,904 -> 42,112, and the first library the game asks for is hal.dll, which is in samples/firmware/5320/sys/bin, so the VFS resolves it (run-rom asserts the reply 'hal.dll -> handle ...'). ⚠ THE SEAM, stated plainly: slow exec 0x8e is NOT disassembled, so the kernel object the returned handle NAMES is unknown. This impl allocates a library object in KState the way every other kernel object here is allocated and hands back that handle. A non-empty aPath, and a library the VFS cannot find, both HALT rather than guess — efile answers those through the drive search at 0x801D19FE, also not disassembled.
Wall 27: unimplemented exec 0x9d at 42,112 instrs — RLibrary::Load's per-library attach list, issued once the loader returned hal.dll's handle (the halt this session resolved)
By implementing the per-library attach protocol from the real handlers (0x80089774 exec 0x9d, 0x800898e0 exec 0x9e) plus the handle bookkeeping euser does around it. exec 0x9d is the per-library analogue of exec 0x9f: r0 = the library handle (resolved as object TYPE 3 = ELibrary in the CURRENT THREAD's handle table, ekern 0x8006b2ac), r1 = TInt* aTotal (in: capacity via kumemget, out: count via kumemput), r2 = TLinAddr* aList. It reads the DLibrary state byte at +0x24: 3 -> nothing to attach (count 0, release the DLL lock); else it builds the library codeseg's dependency closure minus what the process already holds (0x80087bc4 -> TraverseDeps 0x800874d8 deps-first + the 0x80087560 subtraction), copies each DCodeSeg+0x24 entry point out ONE WORD each (0x80088798), sets the state to 2, and KEEPS the DLL lock held for the client's EDllProcessAttach; count > capacity is a kernel panic 39 (HALT, exactly as exec 0x9f). exec 0x9e commits the state 2 -> 3 (0x80089960; panics KERN-EXEC 41 if it was not 2) and releases the DLL lock via DMutex::Signal (0x80089988) that 0x9d kept. load_library now maps the resolved library as a codeseg (hal.dll is XIP, so addRom on code_address-HDR reads its TRomImageHeader already present in the ROM; a RAM library HALTs with the recipe to mount the launcher Sys/Bin) and records its state (1 = needs attach) and codeseg for 0x9d. The state-1-vs-3 determinant is DCodeSeg+0x54 bit 0x02000000 (ekern 0x80089118 create-branch), established as 1 for the loaded DLLs by TWO techniques: the create-branch disasm, and the EKA2L1 traces where every Loader::LoadLibrary is followed by SVC 0x9d then 0x9e (library_attach -> library_attached), the attach protocol state 3 would skip. exec 0x69 (HandleClose) gained ELibrary (TObjectType 3, ekern 0x80083bc8 special-cases only EChunk): euser duplicates the loader's thread-owned handle (exec 0x6d) to the owner the caller keeps, then closes the loader's copy, so the DLibrary survives under the duplicate; the LAST handle closing (the un-modeled EDllProcessDetach path on DThread+0x138) HALTs rather than silently dropping it. hal.dll is a leaf (no TRomDllRefTable), so its published list is exactly [hal's entry point 0x80683968], count 1. MEASURED: 42,112 -> 42,843, halting at the next unimplemented exec 0xe; run-rom asserts the frontier, instruction count and pc.
evidence
db/os/exec-impl.tsv slow 0x9d library_attach + slow 0x9e library_attached (each with the full handler disasm trail); web/src/kernel/exec-impl.js library_attach / library_attached / signalDllLock + handle_close ELibrary=3; web/src/kernel/codeseg.js attachLibrary + loaded[] + _attach(cs,target); web/src/fs/loader.js load_library maps the codeseg and sets state 1; CROSS-REFERENCE ONLY (CLAUDE.md §Evidence doctrine, EKA2L1): C: oolseka2l1 racesmgs-2026-09-02.log line 326->329 shows SVC 0x9b / LoadLibrary / 0x9d library_attach / 0x9e library_attached in that order
Wall 28: unimplemented exec 0xe at 42,843 instrs — RLibrary::Lookup, issued after hal.dll loaded (the halt this session resolved)
By RE-ing the handler (ekern 0x80082348) and the euser wrapper (0x801a414a): exec 0xe = Exec::LibraryLookup(TInt aHandle, TInt aOrdinal) = RLibrary::Lookup. The handler resolves the handle as object TYPE 3 = ELibrary (0x80082378 mov r1,#3 / bl 0x8006b410), takes its DCodeSeg at +0x30 (0x8008237c; null returns 0), and calls the codeseg's address-of-export virtual (vtable+0x14, 0x8008238c) with the ordinal, returning that export VA (bit 0 = Thumb, directly callable), 0 for an out-of-range ordinal. euser RLibrary::Lookup 0x801a414a reads the RLibrary's iHandle at +0 and panics USER 116 for aOrdinal < 0. euser-svc-map's 'CBufBase::Read' is REFUTED (the defective column); EKA2L1 (cross-reference) names SVC 0xe library_lookup. The model reads the ordinal's VA from the ROM export directory (codeseg.exportVA using romimageheader.export_dir_address/count); a RAM library's relocated exports HALT. MEASURED: 42,843 -> 42,965, and 0xe returned hal.dll ordinal 1 = 0x80683ae3 exactly (hal has 5 exports at export_dir_address 0x80684494) — the dlsym/lookup step of MetalGearSlibdl's dlopen('hal.dll') chain. The run then halts where the stub calls dlclose: the LAST handle to hal.dll closing, i.e. the library DETACH path (the next slice).
evidence
db/os/exec-impl.tsv slow 0xe library_lookup (handler + euser wrapper disasm); web/src/kernel/exec-impl.js library_lookup; web/src/kernel/codeseg.js exportVA + the export_dir_address/count accessors; CROSS-REFERENCE ONLY (CLAUDE.md §Evidence doctrine, EKA2L1): the traces name SVC 0xe library_lookup
Wall 29: unimplemented-detach at 42,965 instrs — the last handle to hal.dll closing (dlclose), the library DETACH path (the halt this session resolved)
By implementing the library DETACH, the mirror of the 0x9d/0x9e attach, from the real handlers: ekern 0x80089a84 exec 0xa0 (library_detach), 0x80089bb8 exec 0xa1 (library_detached), and DLibrary::Close 0x80088cb0..0x80088d08. handle_close, on the LAST handle to a library (the model's proxy for iAccessCount reaching 0), sets the DLibrary state to 4 and AddLast's it onto the thread's pending-library-detach queue (DThread+0x138 = ks.thread.pendingLibraryQ), returning TObjectType 3. exec 0xa0 drains that queue: empty gives -25 KErrEof (the routine post-close case every handle_close then sees), else it PEEKS the head, publishes its codeseg detach entry point (hal is a leaf with no TRomDllRefTable, so its own entry point 0x80683968), sets the state to 5, and returns 0 with the count in *aTotal; the euser runner 0x801a617c then calls each published entry with r0=3 (EDllProcessDetach) and issues exec 0xa1. exec 0xa1 requires the queue non-empty (panic 42), DEQUEUEs the head, resets its state to 1, drops the codeseg from ks.codesegs.loaded (a later reload re-attaches it), and returns -25 when the queue empties (else 0, so the runner loops to 0xa0). The DLL-lock precondition (0xa0/0xa1 panic 40/42 when the lock is unheld) is NOT modeled, exactly as the -25 empty-queue path never modeled it: the routine drains run with the lock unheld in this model (no 0x9b precedes them). EKA2L1 (cross-reference) names SVC 0xa0/0xa1 library_detach/library_detached and its traces show the 0x69 handle_close / 0xa0 / 0xa1 order. MEASURED: 42,965 -> 65,163 (+22,198), past the ENTIRE DLL attach/lookup/detach machinery. With hal.dll fully loaded, looked up and detached, the boot runs the CRT's remaining init and the game's Marmalade S3E loader stub, which reaches the FILESYSTEM SCAN (opendir/RFs::Entry for *.s3e under the session path) and halts at the first unimplemented !FileServer file op. run-rom's IPC assertions were updated to this frontier: 13 synchronous sends (was 10), one message outstanding mid-delivery (the halting Entry, *aStatus still KRequestPending), and the extra SessionPath(14)/PrivatePath(86) ops the scan issues.
evidence
db/os/exec-impl.tsv slow 0xa0 library_detach + slow 0xa1 library_detached (each with the full handler disasm trail) + handle_close note; web/src/kernel/exec-impl.js library_detach / library_detached / handle_close detach-queue; web/test/run-rom.mjs IPC assertions updated to the op-22 frontier; CROSS-REFERENCE ONLY (CLAUDE.md Evidence doctrine, EKA2L1): the traces name SVC 0xa0 library_detach / 0xa1 library_detached and show the 0x69 / 0xa0 / 0xa1 order
Wall 30: op-22 (!FileServer RFs::Entry) at 65,163 instrs — DE-FABRICATED: reached only by swallowing three main-thread panics
NOT a real halt. slow 0x72 was mis-modelled as 'exec72_setname' returning KErrNone for (KCurrentThreadHandle, EExitPanic), which swallowed the three User::Panic calls mgs_ngi's main thread issues (~7525 'FSCLIENT panic' 24, ~7779 'USER' 9, ~7897 'FSCLIENT panic' 1) and let the boot run ~57k instrs past them to the RFs::Entry scan for *.s3e. Implementing 0x72 as the real Exec::ThreadKill (ekern 0x8008d5a0; EKA2L1 names SVC 0x72 thread_kill) makes it HALT on the first panic, dropping the run 65,163 -> 7,525 — a de-fabrication, the same move the null-branch history already records as progress (5963 -> 340). Everything the swallowed run reached (hal.dll load/lookup/attach/detach, exec 0x9d/0x9e/0xe/0xa0/0xa1, !FileServer connect/85/14/86, op-22) is real, evidenced code and stays in the engine — it is simply downstream of a panic that must be diagnosed first. See current.
evidence
db/os/exec-impl.tsv slow 0x72 thread_kill (handler 0x8008d5a0 + euser wrapper 0x8019c71c + User::Panic ord 650 0x801a599a); web/src/kernel/exec-impl.js thread_kill; run-rom.mjs asserts the new 7,525 halt; MEASURED 65,163 -> 7,525
Wall 31: guest panic FSCLIENT 24 at 7,525 instrs — TParsePtrC over RProcess::FileName() returned KErrBadName
OUR wrong answer, exactly where the ledger suspected: Exec::ProcessFileName (slow 0x16) hands the name back in an EIGHT-bit descriptor and we wrote it with writeDes16. euser RProcess::FileName 0x801a43ba builds a TPtr8 over aFileName.Ptr()+0x100 with aMaxLength 0x100 (0x801a43cc `blx 0x8019ddf4` = Ptr(), 0x801a43d0 `adds r1,r0,#7 ; adds r1,#0xf9`, 0x801a43da `bl 0x801ade9e` = the TPtr8 ctor), passes THAT to svc 0x16 (0x801a43e2, stub 0x8019bee8 `svc #0x16`), and then widens it with TDes16::Copy(const TDesC8&) 0x801aeda8, whose loop is 0x801aedce `ldrb r1,[r4] ; adds r4,r4,#1 ; strh r1,[r0] ; adds r0,r0,#2`. So our UTF-16 bytes came back widened byte by byte as "e\0:\0\\0s\0..." and EFSrv TParseBase::DoParse 0x801f0cfa read name[1] == 0x0000 instead of 0x3a in the drive component 0x801f0e44 (0x801f0e68 `cmp r0,#0x3a ; bne`), the path component 0x801f0ec2 saw name[0] == 0x65 not 0x5c (0x801f0ee4 `cmp r0,#0x5c ; bne`) and skipped, and the name component 0x801f0f48 located a 0x5c (0x801f0f5c) so it returned KErrBadName -28 (0x801f0f60 `movs r7,#0x1b ; mvns r7,r7`, taken at 0x801f0fea), which the TParsePtrC ctor 0x801f1120 turns into FSClientPanic(24) at 0x801f1162 `movs r0,#0x18 ; bl 0x801f0a00`. Writing 8 bits makes the drive parse succeed, so RFs::SetSessionToPrivate(EDriveE) is sent and the boot runs 7,525 -> 68,015 instrs with ZERO Exec::ThreadKill anywhere in it.
evidence
ekern Kern::KUDesPut 0x80098088 sets the user length from the SOURCE length word (0x80098098 `bic r4,r1,#0xf0000000`, 0x800980c0 `bl 0x8006b9a4` = K::USetLength) and copies that many BYTES (0x800980d0 `bl 0x8006bec4`, whose tail is 0x8006beec `ldrbpl r3,[r1],#1 ; strbtpl r3,[r0],#1`); its scratch is a TBuf8 (0x8009976c `mov r2,#0x30000000 ; str r1,[r0,#4] ; str r2,[r0]`) sized 0x100 by 0x80082520 `mov r1,#0x100`, and DCodeSeg::AppendFullFileName 0x80088a20 appends into it 8-bit (0x80088a38 `mov r1,#0x40` = TDes8::Append(TChar)). db/os/exec-impl.tsv slow 0x16 and slow 0x17; web/src/kernel/exec-impl.js writeDes8; web/test/run-rom.mjs asserts the session drive is E.
A retired diagnosis, kept verbatim: null-branch at 5963 instructions
thread fn 0x82e462a8 (lr=0x82e531a7)
KEPT VERBATIM as the record of a diagnosis that was RIGHT about the symptom and WRONG about the mechanism; the resolution is the first entry of `history`. DIAGNOSIS CONVERGED -> the root is STATIC_CALL_LIST returning -25 (skips C++ ctors). Full trace: the null member [0x3ffc0094] is `str r0,[this+0x84]` from a factory `blx 0x82e52220`. BOTH heap creators inside the factory SUCCEED (0x801a67c8 chunk-create ret 0; 0x801b05bc RHeap-in-chunk ret 0 — the chunk close 0x69 is normal ownership-transfer, not failure). The factory's LAST exec is 0xa0 (static_call_list) -> -25, after which it FREES (RHeap::Free 0x801a1788) and returns NULL. So 0x82e52220 is the C++ STATIC-INIT RUNNER: with -25 ('no ctors') it constructs nothing and returns null, so the member that a ctor should create stays null -> later thunk @0x82e462a0 derefs null -> bx 0. CORRECTS the earlier 'static_call_list=-25 is faithful' assumption (the comment already flagged: MUST revisit). FIX = the E32 static-constructor subsystem. OPEN/subtle EKA2 semantics to RE: does static_call_list return per-process DLL entry points (EDllProcessAttach) and/or the EXE codeseg ctor list? Our XIP firmware DLLs' ctors normally ran at DEVICE boot (which we skip) — so their initialised globals may be the real gap, OR the game exe's own ctors must run via this list. Determine what populates [0x3ffc0094] (which module's ctor) before implementing; extract the ctor/entry list from the E32 images. Do NOT fabricate the list. REFUTED EXPERIMENT (do not repeat): returning the DLL header.entryPoint+8 addresses (0x80199080 euser, 0x82e63fc0 libc, ...) from static_call_list and letting the runner blx them with r0=3 -> boot HALTS EARLIER at a null branch (1614 instrs), member still null. Those addresses are `bx r12` interworking veneers (need r12 set up), so they are NOT the call target. AUTHORITATIVE ROOT (from ekern 0x80089a84): the handler loads r4=mvn #0x18 = -25 as default, then walks the THREAD'S CODESEG INIT-LIST at [thread+0x138] (bl 0x8009d2d0 tests it); returns -25 exactly when that list is EMPTY/exhausted, else takes the next codeseg and calls 0x80087bbc (queries_call_list) to fill *aList + sets *aTotal=count (validated <= capacity, panic 0x27/0x28 otherwise). SO: our model never populates [thread+0x138] — we map the DLLs but never ATTACH them as codesegs to the thread/process, so the list is empty -> -25 -> no ctors -> null member. This is the codeseg/module-init subsystem. PLAN: (1) model codesegs (each loaded module = {entry, deps}) and populate the thread's codeseg-init list in dependency order during load; (2) implement static_call_list to iterate it and return each codeseg's call list (queries_call_list = entry points incl deps), -25 when exhausted; (3) RE the real entry-point/E32Dll call convention (header.entryPoint+8 is a `bx r12` veneer that needs r12 — NOT directly callable; find how the real loader invokes it). This needs careful design; do NOT fake the list. Reminder: euser runtime pc -> disasm standalone at pc-8.
Generated by emulation/ngage/tools/gen-bible.mjs from emulation/ngage/db/bible.json, emulation/ngage/db/frontier.json, emulation/ngage/db/frontier-hle.json, emulation/ngage/db/last-run.json, emulation/ngage/db/os/exec-impl.tsv, emulation/ngage/db/os/struct.tsv, emulation/ngage/db/os/struct_field.tsv, emulation/ngage/db/os/hle-server.tsv, emulation/ngage/db/os/fs-op.tsv, emulation/ngage/docs/data/ekern-exec-table.tsv, emulation/ngage/docs/data/euser-svc-map.tsv, emulation/ngage/db/game/mgs/image.tsv, emulation/ngage/db/game/mgs/structs.tsv, emulation/ngage/db/game/mgs/fields.tsv, emulation/ngage/db/game/mgs/watches.tsv, emulation/ngage/db/game/mgs/transforms.tsv. The prose is db/bible.json; everything else is the tables the emulator runs on.