Files
OpenMaidEngine/docs/asset-resolution-re.md
2026-07-21 19:44:53 -04:00

33 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.

    The manifest rule holds first for set-texture(resId) and play-voice(id). Non-SC frontend scripts such as ROOM instead carry universal raw catalog ids, so both typed resolvers fall back to raw lookup when the executing script has no SC section. 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; 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. ResourceMap.ResolveTexture therefore tries the active script's scene manifest first and then the distinct universal raw-id lookup; the scene-manifest rule above remains correct for ordinary SC texture/voice ids. ROOM voice 0x3365, for example, resolves as raw EUA0016.OGG; treating it only as a ROOM-local id produces no asset because ROOM owns no SC range.

    Explicit raw texture loader (identified and implemented 2026-07-21). Opcode 0x249 is the unambiguous packed raw-id texture path even while a scene section is active. It shares 0x1f9's surface replacement, AGF decode, and RGB colorkey contract, but passes native surface mode 1 and does not apply the executing frame's section base. FIELD uses 0x32da..0x32dd, the universal SYS4INI indexes for SO005.AGF, SO007.AGF, SO008A.AGF, and SO007A.AGF, to populate map-sheet surfaces 0x3e..0x41. The port therefore forwards those ids directly to ResolveRawTexture; it must not run them through ResolveTextureResourceId first. Native mode 1 is a large-image wrapper which tiles the same decoded logical pixels over ordinary child textures; it is not a different AGF/spritesheet interpretation or blend rule. The port's contiguous CPU image is therefore equivalent for rendering purposes.

    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.

    System chrome ownership (resolved 2026-07-20). The normal Godot entry now runs SYSTEM4 as the live root, so its SO000/SO001 loads, ADV-layout definitions, and retained slot initialization execute through GodotAdvHost before TITLE and child scenes. Asset lookup decodes VFS-owned AGF bytes directly and does not depend on a scene-limited converted BMP. CALLBACK_WINDOW.BIN consequently inherits slot 0x11 with SO001 and can crop its textbox/buttons normally. The explicit --scene SC0000 --boot diagnostic still injects the same known inherited layout and surface state because it intentionally bypasses SYSTEM4; that shortcut is no longer the shipped/default route.

    The active manifest is frame-local, not fixed to the root scene: every VM call frame brackets host work with its script context. set-texture resolves the local id at load time and retains the normalized raw catalog id in the graphics surface, so that surface remains stable after a nested helper returns or a sibling script becomes active. Voice and scene-movie op 0x236 likewise resolve against the executing frame; modal whole-movie op 0x20f and SFX are separate universal raw/packed-id families documented below.

  4. Audio. BGM/voice/SFX wired; VFS bytes complete (2026-07-11); packed-raw SFX corrected 2026-07-20. IHost.PlayBgm/PlayVoice + VM dispatch (play-bgm 0xbf / play-voice 0xc4, both argc 1); ResourceMap.ReadAudio opens the resolved catalog entry through IAssetStore; GodotAdvHost passes the bytes to Main's players (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) → SC-section manifest files[base+id] at offset 0, with a type-checked universal-raw fallback for frontend scripts without an SC section (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.ResolveBgm(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). SFX WAV entries use universal packed catalog ids through the same byte store while retaining the existing channel lifecycle. See docs/phase-a-slice-plan.md. Diagnostic: Age.Cli audio <SCENE>.

  5. Movies (OP/MVB, MPEG) — SC0000 0x236 now resolves its scene-local id through the same catalog and reads owned payload bytes through IAssetStore; see the movie section below.

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 plus SC-section voice and typed raw fallback for non-SC frontend scripts. 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 SC manifest first (files[base+id]) and universal raw lookup for non-SC frontend scripts; play-bgm uses direct names (BGM{id:03d}.OGG) — NOT unified.

Native SFX resource proof (2026-07-11; addressing corrected 2026-07-20)

SFX uses a universal packed catalog id, not the scene-local graphics/voice rule. A zero high byte directly indexes SYS4INI; a nonzero high byte selects the matching AAI mount and uses the low 24-bit index. The matching native trace at SC0000 0xc29 captures raw id 0x28, channel 0, which is DATA1/E0808.WAV; SC0000 being the first section previously hid the distinction. TITLE makes it decisive: 0x2aea is raw SE020.WAV for hover, 0x3321 is raw SE015.WAV for activation, and neither fits TITLE's 14-entry manifest. play-bgm remains the separate direct-name family.

The Phase-A backend now resolves the OGG/WAV catalog entry and opens it through IAssetStore; Godot decodes the returned bytes into its existing BGM, voice, and fixed SC0000 SFX channel players. The earlier ResourceMap.AudioPath extracted-file bootstrap is retired.

Runtime asset-VFS track (VFS-A/B/C 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 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 (VFS-B DONE). Sys4AssetCatalog also parses the installed APPEND01.AAI (S4AC422) and mounts its paired APPEND01.ALF. This is a separate selector-keyed catalog, not a filename overlay: AGE scans *.AAI, reads the selector at AAI header offset 0x108, and stores the successfully loaded catalog in mounted_aai[selector]. A resource id with a nonzero high byte selects that slot; its low 24 bits index only the selected append table. Installed APPEND01 is selector 1. Within that catalog the ordinary exact loose-record-name then ALF-range precedence still applies, so the literal $1$... names are preserved. A later successfully enumerated AAI with the same selector replaces the earlier pointer in the native loop; no base/append name replacement occurs. Native extracts the selector with arithmetic SAR 24; the port rejects sign-bit selectors rather than guessing behavior for ids that would index before AGE's mount table.
  3. AGF decoder (VFS-C DONE). Age.Engine/Sys4/AgfDecoder.cs decodes an opened AGF payload directly to a tightly packed, top-down width/height + RGBA8 surface. 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. The focused LzssDecoder is shared with Sys4AssetCatalog; raw and compressed information/pixel/ACIF sections use the same bounded primitive.
  4. Runtime consumers (complete for scripts, textures, and current audio families). ResourceMap.ResolveTexture preserves scene-local resolution and falls back to universal raw ids for SYSTEM4 assets; GodotAdvHost caches decoded RGBA surfaces by catalog identity and supplies synchronous dimensions to opcode 0x208. Godot no longer reads build/textures/*.BMP. BGM direct-name, scene-first/raw-fallback voice, and packed-raw SFX entries are opened through the same IAssetStore. ResourceMap.ResolveSoundEffect owns op 0xb4's ResolvePacked lookup and audio filtering; the existing Godot WAV/channel path consumes its result. Extraction and conversion tools remain diagnostics.

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.

VFS-C passes its bounded gates in AgfDecoderTests: synthesized fixtures cover raw/compressed sections, 4/8-bit palettes, 24/32-bit truecolor expansion, padded bottom-up rows, type 1/2, and ACIF/no-ACIF alpha. Five installed assets spanning raw/compressed metadata and pixel/alpha combinations match the existing AGF2BMP2AGF BMP oracle pixel-for-pixel. SO001 resolves through universal raw id 0x337e, decodes to 800×300 with intermediate alpha values, and is inherited in surface slot 17 before SC0000. A windowed page-1 capture with build/textures/ moved aside showed the translucent textbox edge and bottom-right controls. Texture runtime no longer consults extracted/ or build/textures/.

Audio byte migration passes its bounded gate in Sys4AssetStoreTests: archive-only SC0000 reads identify BGM005.OGG and MAN999.OGG as Ogg streams and E0808.WAV as RIFF/WAVE without consulting extracted/. A windowed SC0000 run with extracted/ moved aside crossed both voice sites and the first SFX sequence, recording BGM005 plus E0808.WAV load/start/preload on channels 0/0/4 with no Godot OGG/WAV decode errors. Channel, loop, interruption, timing, fade, load/start, and release behavior is unchanged.

Windows cursor payloads (implemented 2026-07-21)

Cursor artwork is catalog-backed, not embedded in AGE.EXE. The eight ADV edge cursors at raw ids 0x3318..0x331f are 326-byte, uncompressed 1-bpp 32x32 Windows CUR payloads. FIELD's drag-pan callback instead loads raw id 0x32ce, catalog entry CURSOR09.CUR at DATA1.ALF:611394962+766. Its CUR directory and DIB header specify one 32x32 image, hotspot (16,14), 4 bits per pixel, no compression, and a default 16-entry BGRA palette. The 744-byte image consists of the 40-byte BITMAPINFOHEADER, 64-byte palette, 512-byte color/XOR bitmap, and 128-byte 1-bpp AND mask. The original runtime decoder accepted only 1-bpp XOR data and therefore rejected this valid color cursor. CurDecoder now accepts the installed uncompressed 1-bpp and 4-bpp variants, derives each palette size from the DIB metadata, calculates separate DWORD-aligned XOR and AND strides, expands packed high-nibble-first 4-bpp indices through the BGRA palette, and retains the separate 1-bpp AND transparency mask. The installed-asset regression checks CURSOR09's dimensions, hotspot, transparent background, grayscale, and a non-gray palette pixel.

SC0000 movie payload and presentation (2026-07-11)

The scene-local implementation was first validated at SC0000 0x236@0x13c8. Resource 0x33 resolves through the authoritative catalog to DATA1.ALF:CHAPTER.AGF (archive offset 3,908,816; size 8,194,052). Despite the .AGF name, its payload begins with MPEG program-stream pack start code 00 00 01 BA; the installed asset is MPEG-1 program stream video at 800x600, 29.97 fps, approximately 11.98 seconds, with video stream E0 and audio stream C0.

ResourceMap.ReadMovie accepts the resolved AssetEntry, reads it through the injected IAssetStore, and rejects a non-MPEG-pack payload. Godot has no built-in MPEG decoder suitable for this asset, so the Windows backend adapts those already-owned VFS bytes to the system DirectShow MPEG source using a private temporary file. DirectShow decodes RGB32 on an MTA thread; the sample-grabber callback converts bottom-up BGRA to top-down RGBA and publishes only the newest frame to the retained compositor surface. The temporary file is an implementation adapter, never an alternate resolver or dependency on extracted/. The audio pin is intentionally left unrendered for this bounded slice.

Archive-only tests verify the exact catalog entry, payload size/header, and an actual decoded 800x600 RGBA frame; both still pass with the entire extracted/ tree physically moved aside and restored afterward. SC0000's native capture verifies operands (0x33, 0, 2, 0) and immediate VM continuation at 0x13d1. In Godot the real site opens the same 8,194,052 VFS bytes, publishes changing 800x600 frames, and retains them across pre-yield static surface preparation. The later 0x21c presentation service remains parked until DirectShow EOF, then scene cleanup stops the movie. The initial invisible result was compositor-only: the static (assetId,colorKey) image cache froze the first movie sample, while an extra current-sample background copy was covered by the correctly positioned retained movie object. Dynamic movie surfaces now bypass that cache and publish only at their retained z-position. A windowed run reached first frame 101 and stop frame 190, and manual observation confirmed visible changing video. Movie audio remains intentionally unrendered. The real-scene trace remains a separate extracted-present test because the current Paths.Scripts() test bootstrap still locates its root *.BIN fixtures there; migrating that test/bootstrap path is unrelated to movie asset loading and was not folded into this slice.

BTL combat-effect movie resolution and decoder boundary (diagnosed 2026-07-21)

BTL's movie call at BTL.BIN@0x2b21 is the same non-modal opcode 0x236, but its table supplies universal raw SYS4INI ids because BTL.BIN has no SC#### manifest section. The first accepted combat run reached five such ids:

Raw id Catalog asset MPEG size Sequence size
0x2bd8 MVB958.AGF 40,964 bytes 280x352
0x2af1 MVB001.AGF 90,116 bytes 280x352
0x2bde MVB955.AGF 22,532 bytes 280x352
0x2af5 MVB004.AGF 69,636 bytes 280x352
0x2bca MVB914.AGF 133,124 bytes 400x400

All five archive payloads begin with MPEG program-stream pack code 00 00 01 BA; none is a corrupt still AGF. GodotAdvHost.PlayMovieToSurface currently calls only ResourceMap.Resolve(BTL,id), which must return null because the catalog builds local ranges only for SC####.BIN. Texture and voice resolution already use the required ordering: active SC manifest first, then a type-checked universal raw fallback. Non-modal movie resolution needs the same typed ordering. The logged AGF decode failed MVB914.AGF is downstream: after movie lookup fails, no movie identity is registered, the VM still binds the raw id to the surface, and the compositor falls through to the still-image decoder because MPEG movies also use .AGF names.

Resolution is not the only blocker. An archive-backed probe of the actual Windows backend found that the current DirectShow graph decodes MVB914 and exposes a positive stop time, but rejects all four 280x352 assets while connecting the MPEG video decoder with HRESULT 0x80040217. Wider probes establish the boundary in the current filter stack: 208/288/304/400/800-pixel widths decode, while 280/360/520/600-pixel widths fail; the latter are all 8 mod 16. This is not a rare content edge: 125 installed MVB*.AGF files are 280x352. Requesting RGB24 instead of RGB32 does not change negotiation. The evidence localizes the failure to DirectShow/filter compatibility with these non-16-aligned MPEG display widths; it does not prove which internal stock filter imposes the restriction.

The correct implementation boundary therefore has three parts: add a typed scene-first/raw-fallback resolver for non-modal movies; introduce the already-planned decoder interface/factory and a software MPEG fallback that handles the installed non-16-aligned effects; and preserve the destination surface's created dimensions instead of replacing every movie surface with the SC0000-specific 800x600 value. Decoder failure should also leave a completed movie identity long enough to suppress bogus still-AGF fallback. Regressions must cover at least raw BTL resolution, a 280x352 effect (MVB001), a 400x400 effect (MVB914), immediate 0x23f stop time, RGBA frame publication, completion, and release.

Modal startup/ending movie resources (implemented 2026-07-20)

Opcode 0x20f uses universal raw SYS4INI indexes rather than the executing script's manifest. Its complete corpus is LOGO.BIN (0x335f,42,4), OP.BIN (0x3364,42,4), and ED.BIN (0x3324,42,dynamic_flags). Raw records 0x335f, 0x3364, and 0x3324 are respectively LOGO.AGF, OP.AGF, and ED.AGF; all begin with MPEG pack code 00 00 01 BA. The current ResourceMap.Resolve(scene,id) path correctly returns out-of-range for the large LOGO/OP values, proving that an implementation must expose a typed raw-movie resolver rather than add manifest fallbacks globally. Native 0x20f also arms modal run-state 0x2000; unlike 0x236, these six-instruction wrapper scripts depend on the movie service itself to park until EOF/input cancellation before they release surface 42.

ResourceMap.ResolveRawMovie now supplies that typed universal lookup, while ReadMovie remains the MPEG signature gate. IHost.PlayModalMovieToSurface is distinct from the scene-local non-modal call: Godot reuses the asynchronous DirectShow frame decoder and retained compositor but parks the VM thread until EOF or mouse/Accept/Cancel input. The wrapper's following release then tears down the completed/cancelled movie. The decoder still intentionally leaves audio unrendered; OP/ED audio parity needs an explicit synchronized movie-audio/backend contract rather than an unmanaged default-device side path.

VFS-B passes its bounded gates in Sys4AssetStoreTests: the installed AAI expands from the LZSS stream at 0x118 (expanded size at 0x110, packed size at 0x114) to one APPEND01.ALF archive and 81 80-byte records. All records carry selector 1 and literal $1$ names. The full directory has stable SHA-256 23F0C104A45C099CEFB7D333362716EDE6F20B9EC53E4C3705A8E3A87063708E over its ordered record fields. The integration gate runs BinExtractALF.exe into a disposable directory, compares all 81 names, validates every range and size, and byte-compares all 81 payloads. Sys4ScriptProvider resolves a real append script through 0x01xxxxxx; direct base-name lookup deliberately does not see append records.

Deliberate non-goals

  • Writing/repacking ALF or AAI; loose overrides already provide the native mod/translation workflow.
  • AGF encoding, movie audio, or generalized video APIs. Implemented 0x236 and 0x20f playback remains deliberately limited to Windows' native DirectShow MPEG decoder.
  • 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.