344 lines
28 KiB
Markdown
344 lines
28 KiB
Markdown
# 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 0xc4` → `IHost.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.py` → `build/asset-index.json`: 5 archives (DATA1–5),
|
||
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 --build` → `build/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.
|
||
|
||
*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 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 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.
|
||
|
||
### 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.
|
||
|
||
### 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](https://github.com/morkt/GARbro/blob/master/ArcFormats/Eushully/ImageAGF.cs)
|
||
and [ALF reader](https://github.com/morkt/GARbro/blob/master/ArcFormats/Eushully/ArcALF.cs), MIT licensed.
|
||
Secondary validation reference: [Kelebek's extractor](https://github.com/Kelebek1/Eushully-Decompiler/blob/master/extract_alf.py).
|