Files
OpenMaidEngine/docs/asset-resolution-re.md
2026-07-11 09:14:14 -04:00

20 KiB
Raw Blame History

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 typed IHost methods; CaptureHost no-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 0xc4IHost.PlayBgm/PlayVoice → same resolver → OGG via Godot AudioStreamPlayer. See step 4 below.
  • Tools: tools/convert_agf.py (AGF→BMP for stills via AGF2BMP2AGF.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. Res 0x23 is 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. AGF2BMP2AGF reports OP.AGF/MVB*.AGF as "unsupported type (possibly MPEG)" → DATA5 MVB* (210) and OP/ED are movies, not stills. The opening's visible background did not match any EV001* still (confirmed by eye), so res 0x23's file is not obvious from the name space alone — resolution is required.
  • Asset name spaces: DATA2 = EV*/EVM* stills (985). DATA5 = MVB*/OP/ED movies (210). DATA3 = .OGG audio (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 (magic S4IC422) is the authoritative asset index the game + BinExtractALF use (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.py parses it → build/asset-index.json (13206 entries). See step 1 below.
  • Frida file-I/O is noisy. ReadFile hooks on DATA2.ALF capture reads during the opening, but the offsets/spans don't line up with extracted AGF sizes → the game likely memory-maps the archives (so ReadFile offsets 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)

  1. Parse SYS4INI (S4IC422) → an asset index {name, archive, offset, size}. DONE (2026-07-06). tools/parse_sys4ini.pybuild/asset-index.json: 5 archives (DATA15), 13206 real entries (2 @ placeholders skipped). Format: uint32 packed_size @0x134, then an LZSS stream at 0x138 running to EOF (GARbro-style: 0x1000 zero-filled ring buffer, init pos 0xFEE, control bits LSB→MSB, 1=literal / 0=two-byte backref off=(hi&0xf0)<<4|lo, len=3+(hi&0xf)). Decompresses to uint32 arc_count, arc_count × char[256] archive names, uint32 file_count, then file_count × 80-byte records {char name[64]; u32 arc_id, file_number, offset, size}. Validated: decompressed length (1058783) equals the stored size dword at 0x12c; per-archive counts match the extracted/ ground truth exactly (DATA2=985, DATA3=39, DATA4=9733, DATA5=210); all 13206 offset+size fit 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's exs4alf / GARbro Eushully ArcALF.cs.)

  2. Resolve resId → asset file. SOLVED for scene-manifest references (2026-07-06); system/global raw ids are a separate path identified 2026-07-10.

    The rule: SYS4INI's file list is organized into SECTIONS, one per scene — each is a SCxxxx.BIN script 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_number is the 0-based index within the section. So:

    resId → files[ section_base(scene) + resId ], where section_base = the start of the SYS4INI section containing the scene's SCxxxx.BIN.

    Manifest rule holds for set-texture(resId) and play-voice(id). play-bgm is the EXCEPTION — it does NOT use the manifest; it uses direct literal names BGM{id:03d}.OGG (see step 4, by-ear corrected 2026-07-06). Tool: tools/resolve_asset.py --buildbuild/asset-sections.json (359 sections, 136 scenes); resolve_asset.py <SCENE> [resId] resolves. Validated: file_number == position section_base for 12848/13206 files (97%); SC0000 resolves 17/17 across archives vs the Frida capture (0x25→EV052CA, 0x36→BG030A background, 0x6c→EM* effect); 586/595 distinct captured loads (all sections) satisfy files[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 old play-bgm 5→BGM006 validation point was a mis-attribution — the real game plays BGM005.)

    System/global-id exception (identified 2026-07-10; not yet implemented). Some SYSTEM4 loads use the SYS4INI record's universal raw_index directly, including the two @ placeholder records, rather than a scene-local manifest index. SYSTEM4.BIN writes G[0x69b]=0x337e, then set-texture(G[0x69b], slot=0x11). SYS4INI raw_index 0x337e is DATA1/SO001.AGF, the shared 800×300 RGBA system-chrome sheet. The filtered files[] list omits placeholders, so treating 0x337e as a files[] position currently mis-resolves it to SETROUTE.BIN. This path needs a distinct raw_index → entry lookup; the scene-manifest rule above remains correct for SC texture/voice ids.

    How we got here (condensed): first confirmed resId == file_number via Frida load-order correlation for SC0000's opening, but file_number is not globally unique so a per-scene "scope" was needed. A long hunt for the selector (thought it was native scene state; even tried reading G[0x62424] live — the VM global memory is structured/packed, see docs/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) revealed file_number == SYS4INI position inside 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 heap r-x region) and streams archives through a heap block-cache via ReadFile (not mmap); the stable AGF decoder is AGE.EXE+0x74f1f.

  3. Wire the backend. FIRST-PASS RENDER LANDED (2026-07-06). Age.Engine/Sys4/ResourceMap.cs (Resolve + BMP path) + GodotAdvHost texture ops → TextureRect compositing behind the dialogue; IHost.DrawTexture extended with dst x/y; 800×600 window; convert_agf.py --scene pre-converts a scene's manifest AGFs → BMP. The full-screen event-CG layer renders end-to-end from the executed bytecode. Historical limitations at first landing (subsequently resolved in the Phase-A graphics slices): sprites + BG* (routed through the CG-load subroutine) had garbage geometry because native graphics ops were stubbed (0x208 get-texture-size + the sprite position/animation chain); fades (AE*) drew opaque (no alpha); the slot model approximated the game's immediate-mode blit-onto-slot-0 canvas. See docs/phase-a-slice-plan.md (A2b section) for the implementation history and current retained-object model.

    Current system-chrome shortcut/gap (confirmed 2026-07-10). Main --boot executes only INITCONFIG/INIT2/INIT through CaptureHost and copies their globals into the scene VM; it does not replay SYSTEM4's graphics side effects through GodotAdvHost. convert_agf.py --scene SC0000 also converts only SC0000's manifest, so build/textures/SO001.BMP is absent. CALLBACK_WINDOW.BIN expects slot 0x11 to already contain SO001, draws the 800×227 textbox from (0,0), and crops the lower-right buttons from the same sheet. In the port slot 17 is unpopulated, so retained handle 0xd2f0 resolves as a colored surfaceless object and the compositor draws the observed opaque black fill. A temporary decode verified SO001 is 800×300, 32-bpp, with substantial per-pixel alpha; the current rasterizer already consumes source alpha. The missing prerequisites are system-asset resolution/conversion and retained slot initialization, not new textbox drawing or button interaction.

  4. 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-bgm 0xbf / play-voice 0xc4, both argc 1); ResourceMap.AudioPath → loose extracted/DATA{n}/{name}.OGG; GodotAdvHostMain's two AudioStreamPlayer nodes (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-voice med→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), so id-1 would 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 by play-bgm 0x23→BGM035.OGG — a real standalone track (BGM set skips 030-034) the manifest mis-resolved to a graphics entry. Implemented as ResourceMap.BgmPathById(id); GodotAdvHost.PlayBgm uses 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. See docs/phase-a-slice-plan.md (A2b-Audio). Diagnostic: Age.Cli audio <SCENE>.

  5. 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 13 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.

Native SFX resource proof (2026-07-11)

SFX uses the same scene-local rule as graphics and voice: files[section_base(scene)+resource_id]. The matching native trace at SC0000 0xc29 captures resource 0x28, channel 0; static resolution yields DATA1/E0808.WAV, and the port trace resolves the same file. The following 0xc31 preload uses the same resource on native secondary channel 4. play-bgm remains the separate direct-name exception.

The current Phase-A backend deliberately continues through the extracted-file bootstrap: ResourceMap.AudioPath accepts both OGG and WAV and Godot loads the WAV bytes into its fixed SC0000 channel pool. This does not change the scoped VFS plan below: ALF/AAI mounting and in-process asset reads remain a separate foundation track.

Runtime asset-VFS track (VFS-A complete 2026-07-11)

The pre-extracted tree and build/textures/*.BMP pipeline were a Phase-A bootstrap, not the desired final runtime. The native-compatible target is a read-only virtual filesystem that preserves AGE's translation/mod behavior:

resolve the resource record → try a loose file with that record's name in the game/mod root → otherwise read exactly offset..offset+size from the record's ALF → decode the contained format in process.

Native evidence already proves this ordering for scripts: resource_open_by_raw_id@0x44f390 indexes the 80-byte SYS4 record and calls CreateFileA(record.name) before opening record.archive, seeking to record.offset, and reading record.size. The same service is the correct common seam for scripts, graphics, voice/SFX, and later movie bytes. Resolution and opening must remain separate: scene-local ids and universal raw_index ids select a record differently, but both records flow through the same loose-first store.

Proposed layers

  1. Catalog + read-only ALF store (VFS-A DONE). Sys4AssetCatalog parses SYS4INI at runtime while preserving all 13208 raw records (including the two @ placeholders), archive names, scene sections, and the existing three lookup modes: universal raw id, scene-local manifest id, and direct name where the opcode family genuinely uses one. An ALF is a payload container at this layer: open the named archive and return a bounded stream/byte range at the indexed offset/size. Before that fallback, probe the configured loose override roots by the record's exact basename. Sys4AssetStore opens a separate read-only file handle per request and constrains archive seek/read operations to the record range. Sys4ScriptProvider now loads both root scenes and nested call-script targets through this seam; ResourceMap uses the same live catalog. build/asset-index.json, build/asset-sections.json, build/callscript-names.json, and extracted/ are validation/temporary graphics-audio artifacts, not script-runtime dependencies.
  2. AAI append mount. Parse the installed APPEND01.AAI (S4AC422) and its paired APPEND01.ALF with the same catalog abstractions. First prove whether Himegari joins append records by a separate pack/tag, by name replacement, or by another table selected by the native high-byte-id path; do not invent mount precedence. Validate every parsed append entry against BinExtractALF.exe output before exposing it to the runtime.
  3. AGF decoder. Decode an opened AGF stream directly to width/height + RGBA8. The MIT-licensed GARbro ArcFormats/Eushully/ImageAGF.cs provides a compact reference: ACGF (or zero) signature, type 1/2, LZSS-or-raw header section, 4/8/truecolor source pixels, LZSS-or-raw pixel section, bottom-up row/stride conversion, and optional ACIF LZSS alpha plane. Port only the algorithm and attribution into platform-neutral .NET code; do not carry GARbro's WPF/GameRes dependencies. Kelebek's extractor and the on-disk BinExtractALF.exe are validation references; the Kelebek repository exposes no clear license, so its code should not be copied without clarification.
  4. Runtime consumers. Script loading is complete. Next make texture surfaces own decoded RGBA pixels rather than BMP paths, and load OGG/WAV from store bytes. Migrate one consumer at a time; retain extraction/conversion tools as diagnostics until parity is established.

Acceptance gates

  • Catalog: 13208 raw slots / 13206 real base entries; every ALF range is in bounds; scene-local mappings remain identical to the current resolver and raw_index 0x337e resolves to SO001.AGF.
  • Store: representative base reads are byte-identical to extracted/; a temporary loose file with the same record name wins, and removing it deterministically reveals the archive bytes. Root path traversal is rejected and archive reads are bounded/thread-safe.
  • AAI: entry names/counts/ranges and representative bytes match a disposable BinExtractALF APPEND01.AAI extraction; the native mount/selection rule is documented before integration.
  • AGF: a sample matrix covers compressed/uncompressed sections, 4/8/24-bit source pixels, type 1/2, and alpha/no-alpha. Decoded dimensions and RGBA hashes/pixels match GARbro or AGF2BMP2AGF; SO001.AGF specifically decodes as 800×300 with its alpha plane intact.
  • End to end: SC0000 can run without consulting extracted/ or build/textures; slot 17 receives SO001, the translucent textbox/button chrome appears, root .BIN overrides still win, and the standard VM/Godot validation matrix remains green.

VFS-A passes these bounded gates in Sys4AssetStoreTests: every catalog field matches the generated diagnostic index, all 136 scene views match the generated section oracle without cross-section spill, all 13206 archive ranges fit, raw_index 0x337e is SO001.AGF, representative payloads from every base archive are byte-identical to extracted/, and synthetic removal of a loose override reveals the bounded ALF bytes. Traversal, past-range seek/read, and concurrent reads are covered. Installed override enumeration corrected an older inventory error: this tree contains 51 loose root BINs, comprising 49 archive-backed v1.03 script overrides (all byte-proven to win and differ from DATA1) plus root-only SYS4INI.BIN and SYS4AB.BIN. There are not 52 archive copies available to shadow. APPEND01/AAI, AGF decode, audio consumers, and movie 0x236 remain unimplemented by design.

Deliberate non-goals

  • Writing/repacking ALF or AAI; loose overrides already provide the native mod/translation workflow.
  • AGF encoding, movie/video decoding for MPEG-like OP/MVB*.AGF, or SFX channel semantics.
  • A generalized multi-mod dependency manager. Start with native game-root loose overrides; configurable ordered mod roots can be layered onto the same store later.
  • Removing the extraction/conversion tools immediately. They remain independent parity oracles until the runtime readers have broad corpus coverage.

Primary implementation reference: GARbro's Eushully AGF reader and ALF reader, MIT licensed. Secondary validation reference: Kelebek's extractor.