Wire audio end-to-end (OGG plays natively in Godot; no Frida, no decode):
- IHost.PlayBgm/PlayVoice + VM dispatch for play-bgm(0xbf)/play-voice(0xc4).
Non-Godot hosts no-op them so --selftest + engine 8/8 stay byte-identical.
- GodotAdvHost resolves id->OGG; Main plays via two AudioStreamPlayer nodes
(BGM looping; voice interrupt-on-new).
Key finding (by-ear, systematic-debugging): the two audio ops use DIFFERENT
addressing — the earlier "unified manifest" assumption was wrong for BGM.
- play-voice -> per-scene manifest files[base+id] (offset 0, same as textures).
Confirmed by ear; upgraded med->HIGH.
- play-bgm -> DIRECT LITERAL NAME BGM{id:03d}.OGG, NOT the manifest.
Real game: play-bgm 5->BGM005, 8->BGM008 (manifest gave +1). Proven by
play-bgm 0x23->BGM035 (real standalone track; BGM set skips 030-034) that the
manifest mis-resolved to a graphics entry. Fix is BGM-only:
ResourceMap.BgmPathById; voices/textures unchanged.
Also: Lily's silence root-caused as correct form-gating (G[0xa57/0xa58/0xa59]),
left unseeded by choice (no dummy state). Added diagnostic
`Age.Cli audio <SCENE.BIN> [0xADDR=VAL ...]`. Corrected opcodes.toml (play-bgm
direct-name, play-voice HIGH) + docs/memory (dropped the bogus unified-manifest
/ Frida-BGM006 claims).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
Asset Resolution — foundational RE (graphics + audio)
The problem. The bytecode loads assets by a small numeric resource id (set-texture 0x23,
play-voice N, …). To render/play the real asset — driven by the bytecode, not hardcoded — the
engine must resolve resId → asset file. This is foundational (nearly all visuals + all audio
depend on it) and not machine-verifiable (no pixel/audio oracle), which makes it the largest,
highest-risk area of the port. This doc is the steering state; it feeds the A2b render/audio slices.
What's already landed
- Graphics ops wired (A2b-background, engine-driven):
create-texture 0x1f8(slot,w,h),set-texture 0x1f9(resId,slot),draw-texture 0x1fb(slot,x,y,w,h)promoted from VM stubs to typedIHostmethods;CaptureHostno-ops them (A1 trace-diff/A2a selftest stay green). The VM now drives graphics; only resolution + backend rendering remain. - Audio ops WIRED (2026-07-06):
play-bgm 0xbf/play-voice 0xc4→IHost.PlayBgm/PlayVoice→ same resolver → OGG via GodotAudioStreamPlayer. See step 4 below. - Tools:
tools/convert_agf.py(AGF→BMP for stills viaAGF2BMP2AGF.exe);tools/frida/(runtime capture harness — see its README); Frida core installed (17.15.3).
Findings (2026-07-06)
- SC0000 background = a slot-0 full-screen slideshow. The intro loads ~30 distinct full-screen
images into slot 0 in order (
set-texture 0x23→0,0x25→0,0x27→0, …), each drawn 800×600. Res0x23is the first. (There is also a persistent full-screen slot 3 set cross-context, not in SC0000 — inherited from the parent/system scene.) - The opening mixes movies + stills.
AGF2BMP2AGFreportsOP.AGF/MVB*.AGFas "unsupported type (possibly MPEG)" → DATA5MVB*(210) andOP/EDare movies, not stills. The opening's visible background did not match anyEV001*still (confirmed by eye), so res0x23's file is not obvious from the name space alone — resolution is required. - Asset name spaces: DATA2 =
EV*/EVM*stills (985). DATA5 =MVB*/OP/EDmovies (210). DATA3 =.OGGaudio (BGM*,ANA*voice). - The resolution chain is opaque statically.
CGINIT(build/data/CGINIT.json) is a 925-column numeric record table (row-major, sparse) — not an id→filename map.SYS4INI.BIN(magicS4IC422) is the authoritative asset index the game +BinExtractALFuse (name ↔ archive ↔ offset ↔ size). Filenames aren't plain ASCII because the whole directory is LZSS-compressed (not encrypted). DONE (2026-07-06):tools/parse_sys4ini.pyparses it →build/asset-index.json(13206 entries). See step 1 below. - Frida file-I/O is noisy.
ReadFilehooks onDATA2.ALFcapture reads during the opening, but the offsets/spans don't line up with extracted AGF sizes → the game likely memory-maps the archives (soReadFileoffsets are OS paging, not clean per-asset loads) and/or uses async reads. The robust hook is the game's internal load-by-id function, not file I/O.
The RE plan (ordered)
-
Parse
SYS4INI(S4IC422) → an asset index{name, archive, offset, size}. ✅ DONE (2026-07-06).tools/parse_sys4ini.py→build/asset-index.json: 5 archives (DATA1–5), 13206 real entries (2@placeholders skipped). Format:uint32 packed_size @0x134, then an LZSS stream at0x138running to EOF (GARbro-style: 0x1000 zero-filled ring buffer, init pos 0xFEE, control bits LSB→MSB, 1=literal / 0=two-byte backrefoff=(hi&0xf0)<<4|lo,len=3+(hi&0xf)). Decompresses touint32 arc_count,arc_count × char[256]archive names,uint32 file_count, thenfile_count ×80-byte records{char name[64]; u32 arc_id, file_number, offset, size}. Validated: decompressed length (1058783) equals the stored size dword at0x12c; per-archive counts match theextracted/ground truth exactly (DATA2=985, DATA3=39, DATA4=9733, DATA5=210); all 13206offset+sizefit inside their real.ALF; 837 name-matched files → 0 size mismatches.files[]preserves directory order (feeds step 2's order-correlation). Re-run:py -3.11 -X utf8 tools/parse_sys4ini.py --check. (Ref: asmodean'sexs4alf/ GARbro EushullyArcALF.cs.) -
Resolve
resId → asset file. ✅ SOLVED (2026-07-06) — fully static & general; NO runtime capture.The rule: SYS4INI's file list is organized into SECTIONS, one per scene — each is a
SCxxxx.BINscript entry followed by that scene's asset MANIFEST: every asset it references, across all archives and types (EV/BG/CS/AE graphics and OGG/WAV audio), interleaved in usage order.file_numberis the 0-based index within the section. So:resId → files[ section_base(scene) + resId ], wheresection_base= the start of the SYS4INI section containing the scene'sSCxxxx.BIN.Manifest rule holds for
set-texture(resId)andplay-voice(id). ⚠play-bgmis the EXCEPTION — it does NOT use the manifest; it uses direct literal namesBGM{id:03d}.OGG(see step 4, by-ear corrected 2026-07-06). Tool:tools/resolve_asset.py --build→build/asset-sections.json(359 sections, 136 scenes);resolve_asset.py <SCENE> [resId]resolves. Validated:file_number == position − section_basefor 12848/13206 files (97%); SC0000 resolves 17/17 across archives vs the Frida capture (0x25→EV052CA,0x36→BG030Abackground,0x6c→EM* effect); 586/595 distinct captured loads (all sections) satisfyfiles[base+fn]==name. This is the derivable rule that generalizes to any AGE game with the same container — the "scope" was just which SYS4INI section the scene lives in. (The oldplay-bgm 5→BGM006validation point was a mis-attribution — the real game plays BGM005.)How we got here (condensed): first confirmed
resId == file_numbervia Frida load-order correlation for SC0000's opening, butfile_numberis not globally unique so a per-scene "scope" was needed. A long hunt for the selector (thought it was native scene state; even tried readingG[0x62424]live — the VM global memory is structured/packed, seedocs/global-memory-re.md) missed the real structure until a full multi-archive capture (user domain tip: DATA1 holds BG/CS/CB/CA/CP graphics by name prefix, not just DATA2 EV CGs) revealedfile_number == SYS4INI positioninside per-scene sections. Superseded tools:tools/correlate_scope.py,vm0.py --settex(VM set-texture trace; still useful, but vm0 diverges on branchy non-opening scenes — use the C# VM to trace those). Runtime note for future work: the game is packed (main VM logic in a per-run heapr-xregion) and streams archives through a heap block-cache viaReadFile(not mmap); the stable AGF decoder isAGE.EXE+0x74f1f. -
Wire the backend. ✅ FIRST-PASS RENDER LANDED (2026-07-06).
Age.Engine/Sys4/ResourceMap.cs(Resolve + BMP path) +GodotAdvHosttexture ops →TextureRectcompositing behind the dialogue;IHost.DrawTextureextended with dst x/y; 800×600 window;convert_agf.py --scenepre-converts a scene's manifest AGFs → BMP. The full-screen event-CG layer renders end-to-end from the executed bytecode. Limitations (next chunk = graphics geometry/blend): sprites +BG*(routed through the CG-load subroutine) have garbage geometry because native graphics ops are stubbed (0x208get-texture-size + the sprite position/animation chain); fades (AE*) draw opaque (no alpha); slot model approximates the game's immediate-mode blit-onto-slot-0 canvas. Seedocs/phase-a-slice-plan.md(A2b section) for the full write-up + the graphics-subsystem plan. -
Audio. ✅ WIRED (2026-07-06) — no Frida needed. Same rule as textures:
play-bgm(id)/play-voice(id)→files[section_base(scene)+id]→ OGG.IHost.PlayBgm/PlayVoice+ VM dispatch (play-bgm0xbf /play-voice0xc4, both argc 1);ResourceMap.AudioPath→ looseextracted/DATA{n}/{name}.OGG;GodotAdvHost→Main's twoAudioStreamPlayernodes (AudioStreamOggVorbis.LoadFromBuffer; BGM loops, voice interrupt-on-new). Non-Godot hosts no-op it →--selftest/8-8 byte-identical. SC0000 fires 18 BGM + 198 voice. By-ear VALIDATED (2026-07-06): voices play on their lines (play-voicemed→HIGH). BUT the two audio ops use DIFFERENT addressing — the earlier "unified graphics+audio manifest" claim was WRONG for BGM:- Voice (
play-voice) → per-scene manifest,files[base+id], offset 0 (same as textures). Proven: the manifest interleaves graphics/voice (files[35]=EV049AA,[36]=MAN999,[37]=EV052CA,[38]=SYL0001), soid-1would land voices on.AGF(silent) — they play, so offset is exactly 0. - BGM (
play-bgm) → DIRECT LITERAL NAME,id → BGM{id:03d}.OGG(DATA3), NOT the manifest. Confirmed by ear (play-bgm 5→BGM005,8→BGM008; the manifest gave BGM006/009 = off-by-one) and proven byplay-bgm 0x23→BGM035.OGG— a real standalone track (BGM set skips 030-034) the manifest mis-resolved to a graphics entry. Implemented asResourceMap.BgmPathById(id);GodotAdvHost.PlayBgmuses it. The prior "Frida-confirmed play-bgm 5→BGM006" record was a mis-attribution.
Lily silent = correct (form-gated on
G[0xa57/0xa58/0xa59], unseeded).play-sound-effect(0xb4, argc 2) left stubbed — arg roles unconfirmed. Seedocs/phase-a-slice-plan.md(A2b-Audio). Diagnostic:Age.Cli audio <SCENE>. - Voice (
-
Movies (
OP/MVB, MPEG) — a separate video-playback path; deferred.
Validation reality (why this is the big haul)
Unlike the VM/dialogue work (byte-exact trace oracle), graphics + audio have no machine oracle.
Validation is: Frida ground truth (what the real game loads/plays for a scene) as the correctness
anchor, plus human eyeball/ear. Treat every mapping as provisional until Frida-confirmed; the
resId→file map is data we curate against ground truth, and the engine stays honest by only ever
rendering what the executed bytecode + the map produce (never a hardcoded image).
Status
A2b-background: steps 1–3 landed. Step 1 = build/asset-index.json. Step 2 = resId → files[section_base(scene) + resId] via SYS4INI per-scene sections (tools/resolve_asset.py +
build/asset-sections.json) — no runtime capture, all archives/types + audio. Step 3 = first-pass
render (ResourceMap + GodotAdvHost texture ops → TextureRect compositing): the full-screen event-CG
layer renders end-to-end from the bytecode. Remaining (next chunk): the graphics geometry/blend
subsystem — native geometry ops (0x208 + sprite position/animation) so sprites/BG* position, plus
alpha/blend for fades + chromakey. See docs/phase-a-slice-plan.md (A2b). Audio (step 4): play-voice
uses the manifest (files[base+id]); play-bgm uses direct names (BGM{id:03d}.OGG) — NOT unified.