diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 63fc8bf..9bfd753 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -12,7 +12,8 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b `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 named, not yet wired:** `play-voice 0xc4`, `play-bgm`. +- **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). @@ -63,13 +64,15 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b > **`resId → files[ section_base(scene) + resId ]`**, where `section_base` = the start of the SYS4INI > section containing the scene's `SCxxxx.BIN`. - Unified for `set-texture(resId)`, `play-bgm(id)`, `play-voice(id)` — one manifest. **Tool:** - `tools/resolve_asset.py --build` → `build/asset-sections.json` (359 sections, 136 scenes); - `resolve_asset.py [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`, `play-bgm 5→BGM006`); 586/595 distinct captured loads + Manifest rule holds for `set-texture(resId)` and `play-voice(id)`. **⚠ `play-bgm` is the EXCEPTION — + it does NOT use the manifest; it uses direct literal names `BGM{id:03d}.OGG` (see step 4, by-ear + corrected 2026-07-06).** **Tool:** `tools/resolve_asset.py --build` → `build/asset-sections.json` + (359 sections, 136 scenes); `resolve_asset.py [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.) *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 @@ -90,9 +93,26 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b get-texture-size + the sprite position/animation chain); fades (`AE*`) draw opaque (no alpha); slot model approximates the game's immediate-mode blit-onto-slot-0 canvas. See `docs/phase-a-slice-plan.md` (A2b section) for the full write-up + the graphics-subsystem plan. -4. **Audio** (parallel, same shape): resolve `play-voice`/`play-bgm` `id → OGG` via SYS4INI + a - Frida audio capture (hook `DATA3.ALF` reads or the audio-play fn); play via Godot. Reuses the - `tools/frida/` framework. +4. **Audio.** **✅ WIRED (2026-07-06) — no Frida needed.** Same rule as textures: + `play-bgm(id)`/`play-voice(id)` → `files[section_base(scene)+id]` → OGG. `IHost.PlayBgm/PlayVoice` + + VM dispatch (`play-bgm` 0xbf / `play-voice` 0xc4, both argc 1); `ResourceMap.AudioPath` → loose + `extracted/DATA{n}/{name}.OGG`; `GodotAdvHost` → `Main`'s two `AudioStreamPlayer` nodes + (`AudioStreamOggVorbis.LoadFromBuffer`; BGM loops, voice interrupt-on-new). Non-Godot hosts no-op it + → `--selftest`/8-8 byte-identical. SC0000 fires 18 BGM + 198 voice. **By-ear VALIDATED + (2026-07-06):** voices play on their lines (`play-voice` med→HIGH). **BUT the two audio ops use DIFFERENT + addressing — the earlier "unified graphics+audio manifest" claim was WRONG for BGM:** + - **Voice** (`play-voice`) → per-scene manifest, `files[base+id]`, **offset 0** (same as textures). Proven: + the manifest interleaves graphics/voice (`files[35]=EV049AA`, `[36]=MAN999`, `[37]=EV052CA`, `[38]=SYL0001`), + so `id-1` would land voices on `.AGF` (silent) — they play, so offset is exactly 0. + - **BGM** (`play-bgm`) → **DIRECT LITERAL NAME**, `id → BGM{id:03d}.OGG` (DATA3), NOT the manifest. + Confirmed by ear (`play-bgm 5→BGM005`, `8→BGM008`; the manifest gave BGM006/009 = off-by-one) and proven + by `play-bgm 0x23→BGM035.OGG` — a real standalone track (BGM set skips 030-034) the manifest mis-resolved + to a graphics entry. Implemented as `ResourceMap.BgmPathById(id)`; `GodotAdvHost.PlayBgm` uses it. + The prior "Frida-confirmed play-bgm 5→BGM006" record was a mis-attribution. + + Lily silent = correct (form-gated on `G[0xa57/0xa58/0xa59]`, unseeded). `play-sound-effect` (0xb4, argc 2) + left stubbed — arg roles unconfirmed. See `docs/phase-a-slice-plan.md` (A2b-Audio). Diagnostic: `Age.Cli + audio `. 5. **Movies** (`OP`/`MVB`, MPEG) — a separate video-playback path; deferred. ## Validation reality (why this is the big haul) @@ -111,5 +131,5 @@ files[section_base(scene) + resId]`** via SYS4INI per-scene sections (`tools/res 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) uses the *same* -resolver (`play-bgm/play-voice id → files[base+id]`). +alpha/blend for fades + chromakey. See `docs/phase-a-slice-plan.md` (A2b). Audio (step 4): **`play-voice` +uses the manifest** (`files[base+id]`); **`play-bgm` uses direct names** (`BGM{id:03d}.OGG`) — NOT unified. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 6dd9757..4d90953 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -18,14 +18,14 @@ - **evidence:** confirm via frida ### 0xbf `play-bgm` (play-bgm, argc 1) -- **summary:** Play background music by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (OGG in DATA3). Same resolution as set-texture. -- **grounding:** source=frida, confidence=high -- **evidence:** Frida capture: `play-bgm 0x5` in SC0000 (section base 0) loaded BGM006.OGG = files[5]. Unified with set-texture resolution rule. +- **summary:** Play background music by id. BGM is addressed by DIRECT LITERAL NAME: id -> BGM{id:03d}.OGG (in DATA3), NOT the per-scene section manifest (that's voices/textures). E.g. play-bgm 5 -> BGM005. +- **grounding:** source=investigation, confidence=high +- **evidence:** By-ear confirmed (2026-07-06): SC0000 real game plays BGM005 for play-bgm 0x5 and BGM008 for play-bgm 0x8 (we initially mis-played BGM006/BGM009 via the manifest = off-by-one). Direct-name proven by play-bgm 0x23 -> BGM035.OGG, a real standalone track (BGM set skips 030-034) that the manifest mis-resolved to a graphics entry (EV049AA.AGF). CORRECTS the earlier 'unified manifest / Frida BGM006' claim, which was wrong by one. Voices/textures still use the manifest (files[base+id], offset 0). Diagnostic: `Age.Cli audio SC0000.BIN`. ### 0xc4 `play-voice` (play-voice, argc 1) -- **summary:** Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture/play-bgm. -- **grounding:** source=investigation, confidence=med -- **evidence:** Section-manifest resolution validated across archives incl. DATA4 voice OGGs (586/595 captured loads); per-clip id->OGG not individually Frida-pinned yet. +- **summary:** Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}). +- **grounding:** source=investigation, confidence=high +- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. ## compute diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index c82a5d2..743f637 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -210,6 +210,41 @@ dims) + the sprite position/registration ops so geometry is correct; add alpha/a green chromakey; likely move to a proper canvas/blit compositor. Fixes sprites, background placement, and fades together. (Superseded: the id-specific plan in `docs/superpowers/plans/2026-07-06-a2b-background.md`.) +### A2b-Audio — WIRED, plays end-to-end (2026-07-06) +Audio wired, OGG plays natively in Godot (no Frida, no decode/geometry work). **KEY FINDING — the two audio +ops use DIFFERENT addressing (the initial "unified manifest" assumption was WRONG for BGM):** +- **`play-voice`** → per-scene manifest `files[base+id]`, **offset 0** (same as `set-texture`). +- **`play-bgm`** → **DIRECT LITERAL NAME** `id → BGM{id:03d}.OGG` (DATA3), NOT the manifest. + +**Shipped:** `IHost.PlayBgm/PlayVoice`; VM dispatch routes `play-bgm`(0xbf)/`play-voice`(0xc4) (both argc 1); +the three non-Godot hosts (`CaptureHost`, test `RecHost`/`CountHost`) no-op them so `--selftest` + engine 8/8 +stay byte-identical (audio ops still `pc+1`, step count unchanged); `ResourceMap.BgmPathById(id)` (direct +name) for BGM + `ResourceMap.AudioPath(AssetEntry)` (manifest `Resolve`) for voice; `Main` loads via +`AudioStreamOggVorbis.LoadFromBuffer` into two `AudioStreamPlayer` nodes (BGM `Loop=true`; voice `Loop=false`, +interrupt-on-new). Headless run: 0 OGG-load failures, selftest byte-parity OK. + +**BY-EAR VALIDATED (2026-07-06, systematic-debugging).** User confirmed voices play on their lines +(`play-voice` med→HIGH). Two reports root-caused: +- **BGM off-by-one → FIXED (real root cause, resolver changed for BGM only).** Real game plays BGM005 for + `play-bgm 0x5` and BGM008 for `0x8`; we mis-played BGM006/009 because we resolved BGM via the manifest + (`files[5]=BGM006`). BGM is actually addressed by **direct name** `BGM{id:03d}.OGG`. **Proof:** `play-bgm + 0x23 → BGM035.OGG`, a real standalone track (the BGM set skips 030-034) that the manifest mis-resolved to a + graphics entry (`files[35]=EV049AA.AGF`). Voices are NOT off-by-one — the manifest interleaves graphics/ + voice (`files[35]=EV049AA`, `[36]=MAN999`, `[37]=EV052CA`, `[38]=SYL0001`), so `id-1` would land voices on + `.AGF` (silent) but they play ⇒ voice offset is exactly 0. So the fix is BGM-specific; voices/textures + unchanged. Corrects the earlier "Frida-confirmed play-bgm 5→BGM006" record (a mis-attribution). +- **Lily silent = correct, form-gated (NOT a bug).** Her lines use a 3-way dispatch on form flags + `G[0xa57]`(A)/`G[0xa58]`(B)/`G[0xa59]`(C): exactly one is 1 in the real game (her current form), else the + line `jmp`s past with no voice. Our harness seeds no globals → all zero → every Lily line skipped. Proven + by seeding: `audio SC0000.BIN 0xa57=1` → 35 LILA clips fire in order (form B→LILB, C→LILC). Left unseeded + by user choice (no dummy state); Lily stays silent until real cross-scene state flow (Phase B) exists. + +**Diagnostic tool added:** `Age.Cli audio [0xADDR=VAL ...]` — runs a scene and dumps executed +`play-bgm`/`play-voice` ops in order (BGM direct-name, voice manifest), optional global seeding. Used for all +of the above. **Watch-items:** BGM looping is whole-file for now (Eushully OGGs may carry `LOOPSTART`/ +`LOOPLENGTH` Vorbis comments — refine later); `play-sound-effect`(0xb4, argc 2) left stubbed (arg roles +unconfirmed). + --- ## Risks / open questions for A0 diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index f86a0f6..16d9e83 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -18,6 +18,30 @@ if (args[0] == "run") return 0; } +if (args[0] == "audio") +{ + // audio — run the scene and dump executed play-bgm/play-voice ops in order, + // each resolved via ResourceMap (same rule as the Godot host). Diagnostic only. + var sceneName = args[1]; + var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); + var res = ResourceMap.Load(); + var host = new AudioTraceHost(res, sceneKey); + var vm = new VirtualMachine(Sys4Loader.Load(Paths.Scripts()[sceneName.ToUpperInvariant()], table), table, host); + // optional: seed globals, e.g. `audio SC0000.BIN 0xa57=1` to set Lily's form-A flag + foreach (var s in args.Skip(2)) + { + var kv = s.Split('='); + int k = kv[0].StartsWith("0x") ? Convert.ToInt32(kv[0], 16) : int.Parse(kv[0]); + long v = kv[1].StartsWith("0x") ? Convert.ToInt64(kv[1], 16) : long.Parse(kv[1]); + vm.Globals[k] = v; + } + vm.Run(); + Console.WriteLine($"{sceneName}: {host.Events.Count} audio ops (halt: {vm.HaltReason})"); + foreach (var (kind, id, resolved) in host.Events) + Console.WriteLine($" {kind,-10} 0x{id:x2} -> {resolved}"); + return 0; +} + if (args[0] == "trace") { var scene = new Regex(@"^S[CP]\d{4}\.BIN$"); @@ -35,3 +59,29 @@ if (args[0] == "trace") return 0; } Console.WriteLine("unknown command"); return 1; + +sealed class AudioTraceHost : IHost +{ + private readonly ResourceMap _res; + private readonly string _scene; + public List<(string Kind, long Id, string Resolved)> Events { get; } = new(); + public AudioTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } + public void PlayBgm(long id) // BGM: direct name, not the manifest + { + var path = _res.BgmPathById(id); + Events.Add(("play-bgm", id, path != null ? $"DATA3 {System.IO.Path.GetFileName(path)}" : $"BGM{id:D3}.OGG ")); + } + public void PlayVoice(long id) // voice: per-scene manifest + { + var e = _res.Resolve(_scene, id); + Events.Add(("play-voice", id, e == null ? "" + : $"{e.Archive} {e.Name}" + (ResourceMap.AudioPath(e) == null ? " [NO FILE]" : ""))); + } + public void ShowText(int offset, string text) { } + public void CallScript(long id) { } + public void OnStub(int opcode) { } + public void WaitForInput() { } + public void CreateTexture(int slot, int width, int height) { } + public void SetTexture(long resourceId, int slot) { } + public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } +} diff --git a/engine/Age.Engine.Tests/TextureOpsTests.cs b/engine/Age.Engine.Tests/TextureOpsTests.cs index edcf08c..4dddd1d 100644 --- a/engine/Age.Engine.Tests/TextureOpsTests.cs +++ b/engine/Age.Engine.Tests/TextureOpsTests.cs @@ -18,6 +18,8 @@ public class TextureOpsTests public void CreateTexture(int slot, int w, int h) => Creates++; public void SetTexture(long resId, int slot) => Sets.Add((resId, slot)); public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) => Draws.Add((slot, w, h)); + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } } [Fact] diff --git a/engine/Age.Engine.Tests/WaitForInputTests.cs b/engine/Age.Engine.Tests/WaitForInputTests.cs index d3a35b9..e423163 100644 --- a/engine/Age.Engine.Tests/WaitForInputTests.cs +++ b/engine/Age.Engine.Tests/WaitForInputTests.cs @@ -17,6 +17,8 @@ public class WaitForInputTests public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } } [Fact] diff --git a/engine/Age.Engine/Hosting/CaptureHost.cs b/engine/Age.Engine/Hosting/CaptureHost.cs index 70bfc1c..a58d801 100644 --- a/engine/Age.Engine/Hosting/CaptureHost.cs +++ b/engine/Age.Engine/Hosting/CaptureHost.cs @@ -11,4 +11,6 @@ public sealed class CaptureHost : IHost public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 6b02dfb..ed85b41 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -8,4 +8,6 @@ public interface IHost void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); + void PlayBgm(long id); + void PlayVoice(long id); } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 62f3abd..da6499c 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -60,4 +60,30 @@ public sealed class ResourceMap var bmp = Path.Combine(Paths.Textures, Path.GetFileNameWithoutExtension(a.Name) + ".BMP"); return File.Exists(bmp) ? bmp : null; } + + /// + /// Resolve a BGM id to its OGG path. 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) + /// 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. + /// + public string? BgmPathById(long id) + { + var name = $"BGM{id:D3}.OGG"; + foreach (var f in _files) + if (f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + return AudioPath(f); + return null; + } + + /// Loose extracted OGG path for an audio asset (extracted/DATA{n}/{name}), or null. + /// OGG plays natively in Godot. Used for voices (which DO use the per-scene manifest via Resolve). + public static string? AudioPath(AssetEntry a) + { + if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase)) return null; + var dir = a.Archive.EndsWith(".ALF", StringComparison.OrdinalIgnoreCase) + ? a.Archive[..^4] : a.Archive; // "DATA3.ALF" -> "DATA3" + var ogg = Path.Combine(Paths.Extracted, dir, a.Name); + return File.Exists(ogg) ? ogg : null; + } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 7e6f639..617d604 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -175,6 +175,8 @@ public sealed class VirtualMachine case "draw-texture": // (handle, slot, srcX, srcY, w, h, dstX, dstY) _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); return pc + 1; + case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; + case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1; default: _host.OnStub(op); return pc + 1; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 05f558e..a42be03 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -53,4 +53,19 @@ public sealed class GodotAdvHost : IHost if (_slotBmp.TryGetValue(slot, out var bmp) && bmp != null) _main.CallDeferred("DrawSlot", slot, bmp, dstX, dstY, width, height); } + + // ---- audio ops (OGG plays natively in Godot) ---- + // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. + public void PlayBgm(long id) + { + var path = _res.BgmPathById(id); + if (path != null) _main.CallDeferred("PlayBgm", path); + } + + public void PlayVoice(long id) + { + var asset = _res.Resolve(_scene, id); + var path = asset != null ? ResourceMap.AudioPath(asset) : null; + if (path != null) _main.CallDeferred("PlayVoice", path); + } } diff --git a/godot/Main.cs b/godot/Main.cs index 463fedc..99cd416 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -11,6 +11,8 @@ public partial class Main : Godot.Control private readonly Dictionary _slots = new(); private Label _text = null!; private Label _status = null!; + private AudioStreamPlayer _bgm = null!; // looping background music + private AudioStreamPlayer _voice = null!; // interrupt-on-new voice private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; private volatile bool _done; @@ -50,6 +52,11 @@ public partial class Main : Godot.Control catch { /* fall back to the default font */ } } + _bgm = new AudioStreamPlayer(); + _voice = new AudioStreamPlayer(); + AddChild(_bgm); + AddChild(_voice); + _selftest = System.Array.IndexOf(OS.GetCmdlineUserArgs(), "--selftest") >= 0; var table = OpcodeTableJson.Load(Paths.OpcodesJson); @@ -109,6 +116,25 @@ public partial class Main : Godot.Control tr.Visible = true; } + // Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line. + public void PlayBgm(string oggPath) + { + var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); + if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } + stream.Loop = true; + _bgm.Stream = stream; + _bgm.Play(); + } + + public void PlayVoice(string oggPath) + { + var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); + if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } + stream.Loop = false; + _voice.Stream = stream; + _voice.Play(); + } + public void AppendLine(string text) => _text.Text += text + "\n"; public void PageBreak() => _status.Text = "▼ click / Enter"; public void ClearPage() { _text.Text = ""; _status.Text = ""; } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 745bf2d..53ca7af 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1850,16 +1850,16 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "play-bgm" category = "audio" -summary = "Play background music by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (OGG in DATA3). Same resolution as set-texture." +summary = "Play background music by id. BGM is addressed by DIRECT LITERAL NAME: id -> BGM{id:03d}.OGG (in DATA3), NOT the per-scene section manifest (that's voices/textures). E.g. play-bgm 5 -> BGM005." noop_headless = false -source = "frida" +source = "investigation" confidence = "high" depends_on = [] -evidence = "Frida capture: `play-bgm 0x5` in SC0000 (section base 0) loaded BGM006.OGG = files[5]. Unified with set-texture resolution rule." +evidence = "By-ear confirmed (2026-07-06): SC0000 real game plays BGM005 for play-bgm 0x5 and BGM008 for play-bgm 0x8 (we initially mis-played BGM006/BGM009 via the manifest = off-by-one). Direct-name proven by play-bgm 0x23 -> BGM035.OGG, a real standalone track (BGM set skips 030-034) that the manifest mis-resolved to a graphics entry (EV049AA.AGF). CORRECTS the earlier 'unified manifest / Frida BGM006' claim, which was wrong by one. Voices/textures still use the manifest (files[base+id], offset 0). Diagnostic: `Age.Cli audio SC0000.BIN`." [[opcode.semantics.args]] i = 1 -role = "bgm id (section-manifest index)" +role = "bgm id (direct file-name number: BGM{id:03d}.OGG)" observed_types = ["imm", "g-int", "l-int", "l-ptr"] [[opcode]] @@ -1918,12 +1918,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "play-voice" category = "audio" -summary = "Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture/play-bgm." +summary = "Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})." noop_headless = false source = "investigation" -confidence = "med" +confidence = "high" depends_on = [] -evidence = "Section-manifest resolution validated across archives incl. DATA4 voice OGGs (586/595 captured loads); per-clip id->OGG not individually Frida-pinned yet." +evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug." [[opcode.semantics.args]] i = 1