Migrate audio payloads onto asset VFS

This commit is contained in:
gamer147
2026-07-11 11:01:11 -04:00
parent e0ca7936e2
commit 233f791c10
9 changed files with 132 additions and 64 deletions

View File

@@ -114,10 +114,9 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b
verified SO001 is 800×300, 32-bpp, with substantial per-pixel alpha; the current rasterizer already 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 consumes source alpha. The missing prerequisites are system-asset resolution/conversion and retained
slot initialization, not new textbox drawing or button interaction. slot initialization, not new textbox drawing or button interaction.
4. **Audio.** **✅ WIRED (2026-07-06) — no Frida needed.** Same rule as textures: 4. **Audio.** **✅ WIRED (2026-07-06), VFS bytes complete (2026-07-11).** `IHost.PlayBgm/PlayVoice` +
`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.ReadAudio` opens the
VM dispatch (`play-bgm` 0xbf / `play-voice` 0xc4, both argc 1); `ResourceMap.AudioPath` → loose resolved catalog entry through `IAssetStore`; `GodotAdvHost` passes the bytes to `Main`'s players
`extracted/DATA{n}/{name}.OGG`; `GodotAdvHost``Main`'s two `AudioStreamPlayer` nodes
(`AudioStreamOggVorbis.LoadFromBuffer`; BGM loops, voice interrupt-on-new). Non-Godot hosts no-op it (`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 `--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 (2026-07-06):** voices play on their lines (`play-voice` med→HIGH). **BUT the two audio ops use DIFFERENT
@@ -128,12 +127,12 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b
- **BGM** (`play-bgm`) → **DIRECT LITERAL NAME**, `id → BGM{id:03d}.OGG` (DATA3), NOT the manifest. - **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 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 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. 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. 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) Lily silent = correct (form-gated on `G[0xa57/0xa58/0xa59]`, unseeded). Scene-local SFX WAV entries use
left stubbed — arg roles unconfirmed. See `docs/phase-a-slice-plan.md` (A2b-Audio). Diagnostic: `Age.Cli the same byte path while retaining the existing channel lifecycle. See `docs/phase-a-slice-plan.md`.
audio <SCENE>`. Diagnostic: `Age.Cli audio <SCENE>`.
5. **Movies** (`OP`/`MVB`, MPEG) — a separate video-playback path; deferred. 5. **Movies** (`OP`/`MVB`, MPEG) — a separate video-playback path; deferred.
## Validation reality (why this is the big haul) ## Validation reality (why this is the big haul)
@@ -162,9 +161,9 @@ The matching native trace at SC0000 `0xc29` captures resource `0x28`, channel 0;
`DATA1/E0808.WAV`, and the port trace resolves the same file. The following `0xc31` preload uses the same `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. 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` The Phase-A backend now resolves the OGG/WAV catalog entry and opens it through `IAssetStore`; Godot decodes
accepts both OGG and WAV and Godot loads the WAV bytes into its fixed SC0000 channel pool. This does not change the returned bytes into its existing BGM, voice, and fixed SC0000 SFX channel players. The earlier
the scoped VFS plan below: ALF/AAI mounting and in-process asset reads remain a separate foundation track. `ResourceMap.AudioPath` extracted-file bootstrap is retired.
## Runtime asset-VFS track (VFS-A/B/C complete 2026-07-11) ## Runtime asset-VFS track (VFS-A/B/C complete 2026-07-11)
@@ -213,11 +212,12 @@ store.
on-disk `BinExtractALF.exe` are validation references; the Kelebek repository exposes no clear license, 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 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. `Sys4AssetCatalog`; raw and compressed information/pixel/ACIF sections use the same bounded primitive.
4. **Runtime consumers.** Script and texture loading are complete. `ResourceMap.ResolveTexture` preserves 4. **Runtime consumers (complete for scripts, textures, and audio).** `ResourceMap.ResolveTexture` preserves
scene-local resolution and falls back to universal raw ids for SYSTEM4 assets; `GodotAdvHost` caches 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`. decoded RGBA surfaces by catalog identity and supplies synchronous dimensions to opcode `0x208`.
Godot no longer reads `build/textures/*.BMP`. OGG/WAV byte migration remains a separate follow-up; Godot no longer reads `build/textures/*.BMP`. BGM direct-name entries plus scene-local voice/SFX entries
retain extraction/conversion tools as diagnostics until parity is established. are opened through the same `IAssetStore`; Godot decodes their OGG/WAV bytes in process. Extraction and
conversion tools remain diagnostics.
### Acceptance gates ### Acceptance gates
@@ -250,8 +250,14 @@ Five installed assets spanning raw/compressed metadata and pixel/alpha combinati
`AGF2BMP2AGF` BMP oracle pixel-for-pixel. SO001 resolves through universal raw id `0x337e`, decodes to `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 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 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/`; current audio consumers controls. Texture runtime no longer consults `extracted/` or `build/textures/`.
still use extracted OGG/WAV paths by design. Audio migration and movie `0x236` remain unimplemented.
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. Movie `0x236`
remains unimplemented.
VFS-B passes its bounded gates in `Sys4AssetStoreTests`: the installed AAI expands from the LZSS stream at 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 `0x118` (expanded size at `0x110`, packed size at `0x114`) to one `APPEND01.ALF` archive and 81 80-byte
@@ -264,7 +270,7 @@ through `0x01xxxxxx`; direct base-name lookup deliberately does not see append r
### Deliberate non-goals ### Deliberate non-goals
- Writing/repacking ALF or AAI; loose overrides already provide the native mod/translation workflow. - 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. - AGF encoding or movie/video decoding for MPEG-like `OP/MVB*.AGF`.
- A generalized multi-mod dependency manager. Start with native game-root loose overrides; configurable - 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. ordered mod roots can be layered onto the same store later.
- Removing the extraction/conversion tools immediately. They remain independent parity oracles until the - Removing the extraction/conversion tools immediately. They remain independent parity oracles until the

View File

@@ -1169,7 +1169,7 @@ the same `(load ch0, start ch0, preload ch4)` sequence in one frame. Startup `0x
`0xc2(target,duration)` is the adjacent blocking BGM fade: 100 linear steps for durations at least 1000 ms, `0xc2(target,duration)` is the adjacent blocking BGM fade: 100 linear steps for durations at least 1000 ms,
10 steps below that, with target zero releasing the source. `0xd9` only clears native service bit `0x1000`; 10 steps below that, with target zero releasing the source. `0xd9` only clears native service bit `0x1000`;
the isolated VM recognizes it with no host-visible effect. The Godot backend retains ten `AudioStreamPlayer` the isolated VM recognizes it with no host-visible effect. The Godot backend retains ten `AudioStreamPlayer`
channels, loads the existing extracted WAV bytes, separates load from start, releases buffers deterministically, channels, loads catalog-resolved WAV bytes, separates load from start, releases buffers deterministically,
and parks BGM fades on the unified virtual clock. ALF/AAI/AGF VFS work and movie `0x236` remain separate. and parks BGM fades on the unified virtual clock. ALF/AAI/AGF VFS work and movie `0x236` remain separate.
Ghidra `/v2` now names/comments all five handlers plus the asset-open, decode, DirectSound start/refill/stop/ Ghidra `/v2` now names/comments all five handlers plus the asset-open, decode, DirectSound start/refill/stop/
@@ -1186,3 +1186,20 @@ leaving 37 GAP ops / 101 GAP instructions. Headless sequence capture still emits
**Manual validation:** the normal-speed windowed port advanced through `wait-for-input@0x1a58` at 45.6 s, **Manual validation:** the normal-speed windowed port advanced through `wait-for-input@0x1a58` at 45.6 s,
well past the first effects and dialogue pages, with no audio-related stall. The user confirmed the effects well past the first effects and dialogue pages, with no audio-related stall. The user confirmed the effects
were audible and sounded good. were audible and sounded good.
### Audio-byte migration onto the asset VFS DONE (2026-07-11)
`ResourceMap.BgmPathById` and `ResourceMap.AudioPath` are removed. BGM direct-name resolution now returns a
catalog entry, while voice and SFX retain their scene-local resolution; all three flow through
`ResourceMap.ReadAudio` and the injected `IAssetStore`. `GodotAdvHost` passes owned byte arrays across the
existing deferred main-thread boundary, and `Main` decodes them with
`AudioStreamOggVorbis.LoadFromBuffer` / `AudioStreamWav.LoadFromBuffer`. The existing BGM loop and fade,
voice interruption, ten-channel SFX load/start/release, clock pacing, and gain behavior are unchanged.
Validation: engine **128/128** and both .NET/Godot builds pass. An archive-only SC0000 regression verifies
the Ogg signatures for `BGM005.OGG` and `MAN999.OGG` plus the RIFF/WAVE signature for `E0808.WAV`. With
`extracted/` physically moved aside, the CLI completed SC0000's 127-op audio trace and a windowed Godot run
crossed voice at `0xa0a`/`0xbf6`, BGM005 at `0x7fa`, and the established SFX load/start/preload sequence at
`0xc29`/`0xc2e`/`0xc31` on channels 0/0/4. Godot reported no OGG/WAV read, decode, or deferred-call errors;
`extracted/` was restored afterward. Movie `0x236`, AGF work, opcode semantics, and unrelated VM behavior
were untouched.

View File

@@ -72,7 +72,9 @@ Three layers, cleanly separated:
- **Native content foundation status (2026-07-11).** VFS-A runtime-parses the base SYS4 catalog and - **Native content foundation status (2026-07-11).** VFS-A runtime-parses the base SYS4 catalog and
applies loose-first bounded ALF reads; VFS-B mounts selector-keyed S4AC append catalogs and resolves the applies loose-first bounded ALF reads; VFS-B mounts selector-keyed S4AC append catalogs and resolves the
native high-byte/low-24-bit id split; VFS-C decodes AGF directly to platform-neutral RGBA8 and feeds Godot native high-byte/low-24-bit id split; VFS-C decodes AGF directly to platform-neutral RGBA8 and feeds Godot
without pre-extracted or pre-converted texture files. Audio-byte consumers remain a separate follow-up. without pre-extracted or pre-converted texture files. BGM, voice, and SFX now open catalog entries through
the same store and decode OGG/WAV from bytes in Godot, so runtime scripts, textures, and audio no longer
depend on `extracted/`.
- **Profile = manifest.** Adding a game = a new profile + its maps. Adding an engine version = a new - **Profile = manifest.** Adding a game = a new profile + its maps. Adding an engine version = a new
front-end plugin + profiles that reference it. See §5. front-end plugin + profiles that reference it. See §5.

View File

@@ -87,7 +87,7 @@ root/call-script parsing). Generated asset/callscript JSON remains a tooling and
| `run <file.BIN>` | Execute a script; print steps, show-text count, **call-script dispatch count**, the first 30 lines (each tagged with its source script), and the distinct source scripts. | `CaptureHost` (headless); **executes call-script**. | | `run <file.BIN>` | Execute a script; print steps, show-text count, **call-script dispatch count**, the first 30 lines (each tagged with its source script), and the distinct source scripts. | `CaptureHost` (headless); **executes call-script**. |
| `trace <out.json>` | Trace every SC/SP scene → offsets + halt + steps. **Provider-less** (call-script stubbed) = a base-ISA offset dump. | writes JSON. (Was the vm0 differential oracle; vm0 is retired from oracle duty — `TraceDiffTests` removed.) | | `trace <out.json>` | Trace every SC/SP scene → offsets + halt + steps. **Provider-less** (call-script stubbed) = a base-ISA offset dump. | writes JSON. (Was the vm0 differential oracle; vm0 is retired from oracle duty — `TraceDiffTests` removed.) |
| `trace <SCENE.BIN> [--boot] [--state <f>] [0xADDR=VAL…] --trace-json <out>` | ★ Emit the **full per-op executed-offset path** of one scene (not just show-text), filtered to the scene's own frame — the VM side of the differential offset-path oracle (`diff_optrace.py`). `--boot` runs the SYSTEM4 state prefix; **`--state <f>` loads a captured scene-entry snapshot** (`capture_global_writes.py`) = the engine's real pre-scene state; `0xADDR=VAL` hand-seeds. | `JsonOffsetTraceSink` (observe-only, parity held) → `{scene, offsets:[…]}` JSON. | | `trace <SCENE.BIN> [--boot] [--state <f>] [0xADDR=VAL…] --trace-json <out>` | ★ Emit the **full per-op executed-offset path** of one scene (not just show-text), filtered to the scene's own frame — the VM side of the differential offset-path oracle (`diff_optrace.py`). `--boot` runs the SYSTEM4 state prefix; **`--state <f>` loads a captured scene-entry snapshot** (`capture_global_writes.py`) = the engine's real pre-scene state; `0xADDR=VAL` hand-seeds. | `JsonOffsetTraceSink` (observe-only, parity held) → `{scene, offsets:[…]}` JSON. |
| `audio <SCENE.BIN> [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved file. | optional seeds. provider-less (stub) for now. | | `audio <SCENE.BIN> [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved catalog record. | optional seeds. provider-less (stub) for now. |
| `gfx [--boot] <SCENE.BIN> [0xADDR=VAL…]` | Dump executed `set-texture`/`get-texture-size`/`draw-texture` (resolved file + computed geometry) **plus the per-object gfx slots** — the headless geometry oracle. **`--boot`** runs SYSTEM4's state prefix (`INITCONFIG/INIT2/INIT`) via `GameSession` first (so INIT2's gfx handle array is present) and runs the target with call-script on; without it, seeds-only + provider-less. | gfx ops now execute against `GfxState`. | | `gfx [--boot] <SCENE.BIN> [0xADDR=VAL…]` | Dump executed `set-texture`/`get-texture-size`/`draw-texture` (resolved file + computed geometry) **plus the per-object gfx slots** — the headless geometry oracle. **`--boot`** runs SYSTEM4's state prefix (`INITCONFIG/INIT2/INIT`) via `GameSession` first (so INIT2's gfx handle array is present) and runs the target with call-script on; without it, seeds-only + provider-less. | gfx ops now execute against `GfxState`. |
| `play [--boot] [--state <f>] [--save-state <f>] <SCENE.BIN…> [0xADDR=VAL…]` | ★ Cross-scene **state runner**: run a scene sequence carrying persistent globals. `--boot` first runs the 9 `*INIT` data scripts (real skill/item/unit/map/stage state). `--state`/`--save-state` load/persist a JSON snapshot. | `GameSession`; **executes call-script**. | | `play [--boot] [--state <f>] [--save-state <f>] <SCENE.BIN…> [0xADDR=VAL…]` | ★ Cross-scene **state runner**: run a scene sequence carrying persistent globals. `--boot` first runs the 9 `*INIT` data scripts (real skill/item/unit/map/stage state). `--state`/`--save-state` load/persist a JSON snapshot. | `GameSession`; **executes call-script**. |
| `sweep [--boot] [0xADDR=VAL…]` | Corpus-scale run. **With call-script execution on: 284/297 exit, 13 STEP-LIMIT** (input/state-gated ADV scenes spin headless once subroutine global-writes drive their loops — state divergence, not a bug; 0 depth-cap/unresolved). **With seeds = a story-state explorer**: reports which scenes' dialogue changes ±seed (e.g. form flag `0xa57=1` → 34/297 scenes). | | | `sweep [--boot] [0xADDR=VAL…]` | Corpus-scale run. **With call-script execution on: 284/297 exit, 13 STEP-LIMIT** (input/state-gated ADV scenes spin headless once subroutine global-writes drive their loops — state divergence, not a bug; 0 depth-cap/unresolved). **With seeds = a story-state explorer**: reports which scenes' dialogue changes ±seed (e.g. form flag `0xa57=1` → 34/297 scenes). | |

View File

@@ -350,14 +350,13 @@ sealed class AudioTraceHost : IHost
public AudioTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } public AudioTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; }
public void PlayBgm(long id) // BGM: direct name, not the manifest public void PlayBgm(long id) // BGM: direct name, not the manifest
{ {
var path = _res.BgmPathById(id); var entry = _res.ResolveBgm(id);
Events.Add(("play-bgm", id, path != null ? $"DATA3 {System.IO.Path.GetFileName(path)}" : $"BGM{id:D3}.OGG <missing>")); Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>"));
} }
public void PlayVoice(long id) // voice: per-scene manifest public void PlayVoice(long id) // voice: per-scene manifest
{ {
var e = _res.Resolve(_scene, id); var e = _res.Resolve(_scene, id);
Events.Add(("play-voice", id, e == null ? "<unresolved>" Events.Add(("play-voice", id, e == null ? "<unresolved>" : $"{e.Archive} {e.Name}"));
: $"{e.Archive} {e.Name}" + (ResourceMap.AudioPath(e) == null ? " [NO FILE]" : "")));
} }
public void ShowText(int offset, string text) { } public void ShowText(int offset, string text) { }
public void WaitForInput() { } public void WaitForInput() { }

View File

@@ -167,6 +167,31 @@ public class Sys4AssetStoreTests
} }
} }
[Fact]
public void Sc0000BgmVoiceAndSfxPayloadsReadDirectlyFromArchives()
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir);
var resources = new ResourceMap(catalog, archiveOnly);
var bgm = resources.ResolveBgm(5);
Assert.Equal("BGM005.OGG", bgm?.Name);
AssertOgg(resources.ReadAudio(bgm!));
var voice = resources.Resolve("SC0000", 0x24);
Assert.Equal("MAN999.OGG", voice?.Name);
AssertOgg(resources.ReadAudio(voice!));
var sfx = resources.Resolve("SC0000", 0x28);
Assert.Equal("E0808.WAV", sfx?.Name);
var wav = resources.ReadAudio(sfx!);
Assert.Equal("E0808.WAV", wav.Name);
Assert.Equal("RIFF", Encoding.ASCII.GetString(wav.Bytes, 0, 4));
Assert.Equal("WAVE", Encoding.ASCII.GetString(wav.Bytes, 8, 4));
Assert.Throws<InvalidDataException>(() => resources.ReadAudio(catalog.ResolveName("SO001.AGF")!));
}
[Fact] [Fact]
public void AllInstalledLooseScriptOverridesShadowArchiveCopies() public void AllInstalledLooseScriptOverridesShadowArchiveCopies()
{ {
@@ -235,4 +260,10 @@ public class Sys4AssetStoreTests
stream.CopyTo(copy); stream.CopyTo(copy);
return copy.ToArray(); return copy.ToArray();
} }
private static void AssertOgg(AudioPayload payload)
{
Assert.EndsWith(".OGG", payload.Name, StringComparison.OrdinalIgnoreCase);
Assert.Equal("OggS", Encoding.ASCII.GetString(payload.Bytes, 0, 4));
}
} }

View File

@@ -4,7 +4,7 @@ namespace Age.Engine.Sys4;
/// Compatibility facade over the runtime SYS4 catalog. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its /// Compatibility facade over the runtime SYS4 catalog. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its
/// cross-archive asset manifest); file_number is the index within a section. So a bytecode /// cross-archive asset manifest); file_number is the index within a section. So a bytecode
/// resId resolves as files[section_base(scene) + resId] -- unified for graphics and audio. /// resId resolves as files[section_base(scene) + resId] -- unified for graphics and audio.
/// See docs/asset-resolution-re.md. Extracted paths remain temporary graphics/audio backends only. /// See docs/asset-resolution-re.md.
/// </summary> /// </summary>
public sealed class ResourceMap public sealed class ResourceMap
{ {
@@ -40,27 +40,29 @@ public sealed class ResourceMap
public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name); public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name);
/// <summary> /// <summary>
/// Resolve a BGM id to its OGG path. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the /// Resolve a BGM id to its catalog entry. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the
/// per-scene section manifest that voices/textures use. Confirmed by ear (play-bgm 5->BGM005, 8->BGM008) /// per-scene section manifest that voices/textures use. Confirmed by ear (play-bgm 5->BGM005, 8->BGM008)
/// and by the play-bgm 0x23->BGM035 case: BGM035 is a real standalone track (the BGM set skips 030-034), /// and by the play-bgm 0x23->BGM035 case: BGM035 is a real standalone track (the BGM set skips 030-034),
/// which the manifest mis-resolved to a graphics entry. See docs/asset-resolution-re.md. /// which the manifest mis-resolved to a graphics entry. See docs/asset-resolution-re.md.
/// </summary> /// </summary>
public string? BgmPathById(long id) public AssetEntry? ResolveBgm(long id)
{ {
var name = $"BGM{id:D3}.OGG"; var name = $"BGM{id:D3}.OGG";
var f = _catalog.ResolveName(name); var entry = _catalog.ResolveName(name);
return f == null ? null : AudioPath(f); return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null;
} }
/// <summary>Loose extracted OGG/WAV path for an audio asset (extracted/DATA{n}/{name}), or null. /// <summary>Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store.</summary>
/// Used by the current bootstrap audio backend; voices and SFX use the scene manifest via Resolve.</summary> public AudioPayload ReadAudio(AssetEntry entry)
public static string? AudioPath(AssetEntry a)
{ {
if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) && if (entry.IsPlaceholder || !IsAudio(entry))
!a.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase)) return null; throw new InvalidDataException($"not an OGG/WAV asset: {entry.Name}");
var dir = a.Archive.EndsWith(".ALF", StringComparison.OrdinalIgnoreCase) return new AudioPayload(entry.Name, _store.ReadAll(entry));
? a.Archive[..^4] : a.Archive; // "DATA3.ALF" -> "DATA3"
var audio = Path.Combine(Paths.Extracted, dir, a.Name);
return File.Exists(audio) ? audio : null;
} }
private static bool IsAudio(AssetEntry entry)
=> entry.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase)
|| entry.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase);
} }
public sealed record AudioPayload(string Name, byte[] Bytes);

View File

@@ -11,7 +11,7 @@ public sealed class GodotAdvHost : IHost
private readonly string _scene; // e.g. "SC0000" — for section_base private readonly string _scene; // e.g. "SC0000" — for section_base
private readonly object _imageLock = new(); private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // raw catalog id -> decoded pixels private readonly Dictionary<int, RgbaImage?> _images = new(); // raw catalog id -> decoded pixels
private readonly string?[] _sfxPaths = new string?[10]; // SC0000 native channel subset private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which // slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which
// the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0). // the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0).
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } }; private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
@@ -280,46 +280,57 @@ public sealed class GodotAdvHost : IHost
// BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest.
public void PlayBgm(long id) public void PlayBgm(long id)
{ {
var path = _res.BgmPathById(id); var asset = _res.ResolveBgm(id);
_timeline?.Event("bgm", new() { ["id"] = id, ["file"] = path != null ? System.IO.Path.GetFileName(path) : null }); var audio = asset != null ? LoadAudio(asset) : null;
if (path != null) _main.CallDeferred("PlayBgm", path); _timeline?.Event("bgm", new() { ["id"] = id, ["file"] = audio?.Name });
if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name);
} }
public void PlayVoice(long id) public void PlayVoice(long id)
{ {
var asset = _res.Resolve(_scene, id); var asset = _res.Resolve(_scene, id);
var path = asset != null ? ResourceMap.AudioPath(asset) : null; var audio = asset != null ? LoadAudio(asset) : null;
if (path != null) _main.CallDeferred("PlayVoice", path); if (audio != null) _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name);
} }
public void LoadSoundEffect(long resourceId, int channel) public void LoadSoundEffect(long resourceId, int channel)
{ {
if ((uint)channel >= (uint)_sfxPaths.Length) return; if ((uint)channel >= (uint)_sfxNames.Length) return;
var asset = _res.Resolve(_scene, resourceId); var asset = _res.Resolve(_scene, resourceId);
var path = asset != null ? ResourceMap.AudioPath(asset) : null; var audio = asset != null ? LoadAudio(asset) : null;
_sfxPaths[channel] = path; _sfxNames[channel] = audio?.Name;
_timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel, _timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel,
["file"] = path != null ? System.IO.Path.GetFileName(path) : null }); ["file"] = audio?.Name });
if (path != null) _main.CallDeferred("LoadSoundEffect", path, channel); if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel);
} }
public void StartSoundEffect(int channel) public void StartSoundEffect(int channel)
{ {
if ((uint)channel >= (uint)_sfxPaths.Length || _sfxPaths[channel] == null) return; if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return;
_timeline?.Event("sfx-start", new() { ["channel"] = channel, _timeline?.Event("sfx-start", new() { ["channel"] = channel,
["file"] = System.IO.Path.GetFileName(_sfxPaths[channel]) }); ["file"] = _sfxNames[channel] });
_main.CallDeferred("StartSoundEffect", channel); _main.CallDeferred("StartSoundEffect", channel);
} }
public void ReleaseSoundEffect(int channel) public void ReleaseSoundEffect(int channel)
{ {
if ((uint)channel >= (uint)_sfxPaths.Length) return; if ((uint)channel >= (uint)_sfxNames.Length) return;
_timeline?.Event("sfx-release", new() { ["channel"] = channel, _timeline?.Event("sfx-release", new() { ["channel"] = channel,
["file"] = _sfxPaths[channel] != null ? System.IO.Path.GetFileName(_sfxPaths[channel]) : null }); ["file"] = _sfxNames[channel] });
_sfxPaths[channel] = null; _sfxNames[channel] = null;
_main.CallDeferred("ReleaseSoundEffect", channel); _main.CallDeferred("ReleaseSoundEffect", channel);
} }
private AudioPayload? LoadAudio(AssetEntry asset)
{
try { return _res.ReadAudio(asset); }
catch (System.Exception e)
{
Godot.GD.Print($"audio read failed {asset.Name}: {e.Message}");
return null;
}
}
public void FadeBgm(int targetPercent, long durationMs) public void FadeBgm(int targetPercent, long durationMs)
{ {
long ms = System.Math.Clamp(durationMs, 0, 60_000); long ms = System.Math.Clamp(durationMs, 0, 60_000);

View File

@@ -514,31 +514,31 @@ public partial class Main : Godot.Control
img.SetData(w, h, false, img.GetFormat(), px); img.SetData(w, h, false, img.GetFormat(), px);
} }
// Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line. // Decode VFS-owned bytes in Godot. BGM loops; voice plays once, cutting off any prior line.
public void PlayBgm(string oggPath) public void PlayBgm(byte[] oggBytes, string assetName)
{ {
var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; }
stream.Loop = true; stream.Loop = true;
_bgm.VolumeDb = 0; _bgm.VolumeDb = 0;
_bgm.Stream = stream; _bgm.Stream = stream;
_bgm.Play(); _bgm.Play();
} }
public void PlayVoice(string oggPath) public void PlayVoice(byte[] oggBytes, string assetName)
{ {
var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; }
stream.Loop = false; stream.Loop = false;
_voice.Stream = stream; _voice.Stream = stream;
_voice.Play(); _voice.Play();
} }
public void LoadSoundEffect(string wavPath, int channel) public void LoadSoundEffect(byte[] wavBytes, string assetName, int channel)
{ {
if ((uint)channel >= (uint)_sfx.Length) return; if ((uint)channel >= (uint)_sfx.Length) return;
var stream = AudioStreamWav.LoadFromBuffer(System.IO.File.ReadAllBytes(wavPath)); var stream = AudioStreamWav.LoadFromBuffer(wavBytes);
if (stream == null) { GD.Print($"WAV load failed {wavPath}"); return; } if (stream == null) { GD.Print($"WAV load failed {assetName}"); return; }
stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled; stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled;
_sfx[channel].Stop(); _sfx[channel].Stop();
_sfx[channel].VolumeDb = 0; _sfx[channel].VolumeDb = 0;