From 99ce351041b9050f1e7d98b58ffde353455fbd2b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 11 Jul 2026 02:15:17 -0400 Subject: [PATCH] Implement native-backed SC0000 SFX lifecycle --- .gitattributes | 1 + docs/asset-resolution-re.md | 11 +++ docs/engine-re.md | 40 ++++++++++ docs/opcode-reference.md | 44 ++++++----- docs/phase-a-slice-plan.md | 40 ++++++++++ docs/tools-reference.md | 1 + engine/Age.Engine.Tests/SfxOpsTests.cs | 35 +++++++++ engine/Age.Engine.Tests/TestSupport.cs | 8 ++ engine/Age.Engine/Hosting/IHost.cs | 4 + engine/Age.Engine/Sys4/ResourceMap.cs | 11 +-- engine/Age.Engine/Vm/VirtualMachine.cs | 15 +++- godot/GodotAdvHost.cs | 42 ++++++++++ godot/Main.cs | 45 ++++++++++- tools/age_opcodes_himegari.py | 6 +- tools/frida/capture_sfx_trace.py | 102 +++++++++++++++++++++++++ vm-map/opcodes.toml | 72 ++++++++--------- 16 files changed, 413 insertions(+), 64 deletions(-) create mode 100644 engine/Age.Engine.Tests/SfxOpsTests.cs create mode 100644 tools/frida/capture_sfx_trace.py diff --git a/.gitattributes b/.gitattributes index 22af217..759ba15 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ docs/opcode-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol +tools/age_opcodes_himegari.py whitespace=trailing-space,space-before-tab,cr-at-eol diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index aa1f5c2..0338a6f 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -155,6 +155,17 @@ subsystem** — native geometry ops (`0x208` + sprite position/animation) so spr alpha/blend for fades + chromakey. See `docs/phase-a-slice-plan.md` (A2b). Audio (step 4): **`play-voice` uses the manifest** (`files[base+id]`); **`play-bgm` uses direct names** (`BGM{id:03d}.OGG`) — NOT unified. +## Native SFX resource proof (2026-07-11) + +SFX uses the same scene-local rule as graphics and voice: `files[section_base(scene)+resource_id]`. +The matching native trace at SC0000 `0xc29` captures resource `0x28`, channel 0; static resolution yields +`DATA1/E0808.WAV`, and the port trace resolves the same file. The following `0xc31` preload uses the same +resource on native secondary channel 4. `play-bgm` remains the separate direct-name exception. + +The current Phase-A backend deliberately continues through the extracted-file bootstrap: `ResourceMap.AudioPath` +accepts both OGG and WAV and Godot loads the WAV bytes into its fixed SC0000 channel pool. This does not change +the scoped VFS plan below: ALF/AAI mounting and in-process asset reads remain a separate foundation track. + ## Candidate runtime asset-VFS track (scoped 2026-07-10; not started) The pre-extracted tree and `build/textures/*.BMP` pipeline were a Phase-A bootstrap, not the desired final diff --git a/docs/engine-re.md b/docs/engine-re.md index 023fb21..78d975f 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -973,6 +973,46 @@ Matching evidence: `build/native-adv-text-trace.jsonl` and Godot timeline captur non-overlapping name/dialogue bands at y=447–468, 478–500, and 507–530. A manual run progressed 14 pages: 11 clicks completed active reveals and 14 later clicks released 14 distinct waits through `0xe0c`. +### SC0000 native SFX / BGM-fade family — `0xb4`/`0xb5`/`0xb6`/`0xc2`/`0xd9` (2026-07-11) + +The three SFX opcodes are a retained channel lifecycle, not immediate fire-and-forget calls. Handler +resolution and the saved `/v2` names are: + +- `0xb4` `op_0xb4_sfx_load@0x4201d0` -> `sfx_channel_load@0x482500`: `(resource_id, channel)` opens the + scene-local SYS4 entry and replaces the channel decoder/buffer without starting it. The manager supports + 13 slots (`0..12`); SC0000 deliberately resets and uses the `0..9` subset. +- `0xb5` `op_0xb5_sfx_start_once@0x420210` -> `sfx_channel_start@0x4825d0`: starts the loaded channel with + logical loop mode 0. Adjacent op `0xba`, not this slice, passes mode 1. +- `0xb6` `op_0xb6_sfx_release@0x420250` -> `sfx_channel_release@0x482600` -> + `sound_buffer_destroy@0x4831a0`: stop/release and clear the retained resource/decoder; empty release is + safe. SC0000's `0x62b..0x646` and `0x120d..0x1228` are ten-channel reset sweeps. + +`sound_decode_channel@0x483360` selects the decoder by file signature, constructs a DirectSound buffer, and +installs four quarter-buffer notifications. `sound_buffer_start@0x484270` primes the ring and synchronously +calls `IDirectSoundBuffer::Play(0,0,DSBPLAY_LOOPING)` before returning. That flag loops the streaming ring, +not the logical clip: `sound_stream_fill_quarter@0x483b70` rewinds the decoder only for logical mode 1; +otherwise it pads after EOF and `sound_buffer_stop@0x483aa0` stops playback. This family carries no volume or +pan operands. It inherits configured SFX volume and centered pan: the first-pair capture applies DirectSound +attenuation `-2377` to both channel loads, and the shared audio service later records centered `SetPan(0)`. +`sound_buffer_set_volume@0x483f80` computes that inherited attenuation; neither value is supplied by these +five handlers. The bounded port does not yet import native audio preferences, so its extracted-WAV bootstrap +uses unity gain and centered pan rather than hard-coding the captured user's setting. + +The native trace in `build/native-sfx-trace.jsonl` captures SC0000's first pair: `0xb4@0xc29` resolves +resource `0x28` to `E0808.WAV`, loads channel 0, and `0xb5@0xc2e` starts it in the same millisecond. The next +`0xb4@0xc31` preloads the same WAV into engine-owned secondary channel 4 for a later service start. The +scratch global `G[0x6242d]` is maintained outside script-visible writes; the SC0000 port profile exposes it +as an external value of 4 rather than pretending the script assigned it. + +Normal-speed windowed validation reached `wait-for-input@0x1a58` after 45.6 seconds without an audio stall; +the user confirmed the opening effects were audible and sounded good. + +`0xc2` is BGM rather than SFX: `op_0xc2_bgm_fade@0x4204c0` sets run-state `0x200`, arms the service timer, +and calls `bgm_fade_arm@0x464830`. `bgm_fade_tick@0x464960` linearly interpolates current to target percent; +durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source. +The VM is parked for the requested duration. `0xd9` is adjacent startup control, not audio data: it clears +run/service bit `0x1000` in the primary and, when active, secondary context and has no VM-visible result. + ### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09) **Problem the oracle surfaced:** single-scene VM runs diverge from the engine because they lack the diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 0de41ee..4279c3b 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -17,16 +17,31 @@ ## audio -### 0xb6 `snd-ctrl?` (u0041D080, argc 1) -- **summary:** 1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control -- **grounding:** source=inference, confidence=low -- **evidence:** confirm via frida +### 0xb4 `sfx-load` (play-sound-effect, argc 2) +- **summary:** (resource_id)(channel) — synchronously resolve/open the scene-manifest asset and replace the channel's decoded sound buffer without starting playback. Native manager supports channels 0..12; SC0000 uses 0..9. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra op 0xb4 handler 0x4201d0 -> sfx_channel_load@0x482500 -> asset_open@0x44f390 + sound_decode_channel@0x483360. Native trace: SC0000 0xc29 loads resource 0x28 into channel 0; resource resolves by section_base+id to E0808.WAV; completion precedes 0xb5 in the same millisecond. + +### 0xb5 `sfx-start` (u0041D050, argc 1) +- **summary:** (channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra op 0xb5 handler 0x420210 passes mode 0 to sfx_channel_start@0x4825d0; mode 1 belongs to op 0xba. sound_buffer_start@0x484270 primes four quarter-buffer notifications then calls IDirectSoundBuffer::Play with flag 1 before returning. Native trace at SC0000 0xc2e: E0808 channel 0 start enters/leaves in the same ms, preloaded 1->0 and playing 0->1. + +### 0xb6 `sfx-release` (u0041D080, argc 1) +- **summary:** (channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra op 0xb6 handler 0x420250 -> sfx_channel_release@0x482600 -> sound_buffer_destroy@0x4831a0, which releases the per-channel object under its critical section and clears the slot. Native trace captured SC0000's channels 0..9 release sweep in consecutive calls. ### 0xbf `play-bgm` (play-bgm, argc 1) - **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`. +### 0xc2 `fade-bgm` (u0041D2B0, argc 2) +- **summary:** (target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms. + ### 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 (NOT play-bgm, which is direct-name BGM{id:03d}). - **grounding:** source=investigation, confidence=high @@ -79,6 +94,11 @@ This also names the whole call graph statically (build/callscript-names.json). Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class). duration<10 fast-paths via [0x56f0b8]; all real scene sleeps (100/750/1000) are >=10. The handler also writes gfx cmd-type 3 + runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for duration ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity). +### 0xd9 `clear-run-state-0x1000` (u00415880, argc 0) +- **summary:** Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra op 0xd9 handler 0x416da0: ctx->run_state_flags &= ~0x1000; when ctx+0x6f8b8 is nonzero, also clears bit 0x1000 at ctx+0x53d20. No operands, calls, or return value. + ### 0x140 `coroutine-label-yield` (u0041F9C0, argc 4) - **summary:** (out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework. - **grounding:** source=investigation, confidence=med @@ -578,14 +598,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0xb4 `play-sound-effect` (play-sound-effect, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - -### 0xb5 `u0041D050` (u0041D050, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xb7 `u0041D0E0` (u0041D0E0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -606,10 +618,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0xc2 `u0041D2B0` (u0041D2B0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xc5 `u0041D4A0` (u0041D4A0, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -646,10 +654,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0xd9 `u00415880` (u00415880, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xfb `joy_callback` (joy_callback, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=med diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index f7d2eb7..6680df1 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1089,3 +1089,43 @@ fixtures and installed-game integration checks rather than committing proprietar **Decision point:** this track is worthwhile before broadening beyond SC0000 because it establishes the modding contract and benefits scripts, UI chrome, SFX, and movies. It is not required to continue opcode coverage immediately, so choosing movie/SFX next remains valid. + +### Phase A — native SC0000 SFX family (`0xb4`/`0xb5`/`0xb6`/`0xc2`/`0xd9`) DONE (2026-07-11) + +Native RE and the matching trace resolve the bounded family. `0xb4(resource,channel)` synchronously loads +and retains a scene-manifest sound; `0xb5(channel)` starts that loaded sound once; `0xb6(channel)` destroys +and clears it. The manager supports 13 native slots, while SC0000 uses a fixed `0..9` pool. Native playback +is a notification-fed DirectSound ring: `Play(...,DSBPLAY_LOOPING)` keeps the ring alive, but logical mode 0 +stops at decoder EOF. Mode 1 belongs to adjacent op `0xba` and remains outside this slice. These five ops do +not set pan or SFX volume; SC0000 inherits centered pan and configured SFX volume. The captured loads both +apply DirectSound attenuation `-2377`; because native audio-preference import is outside this slice, the +bounded extracted-WAV port uses unity gain and centered pan instead of treating that user setting as opcode +semantics. + +At the requested first site, `0xb4@0xc29` resolves `0x28` to `E0808.WAV` and loads channel 0; +`0xb5@0xc2e` publishes playback in the same native millisecond. `0xb4@0xc31` preloads the same WAV into +engine-owned secondary channel 4. `G[0x6242d]` is native/profile-owned rather than script-written, so the +port exposes value 4 through the new external-global seam for SC0000. The matching port timeline now records +the same `(load ch0, start ch0, preload ch4)` sequence in one frame. Startup `0x62b..0x646` and the later +`0x120d..0x1228` both release channels 0..9 in order. + +`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`; +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, +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/ +volume/release, and BGM fade workers; the program is saved. Evidence is +`build/native-sfx-trace.jsonl` and `godot/build/sfx-port-timeline4.jsonl` (the latter is the final rebuilt +channel-4 trace; earlier diagnostic reruns captured the missing external-state mismatch). + +**Automated validation:** engine **111/111**; corpus sweep unchanged at **284 exit / 13 STEP-LIMIT**; +Godot build and threaded `SELFTEST OK`; all seven Python suites, opcode/ctx lint, 481-script decode, RECOVER, +and tracer bytecode compilation clean. SC0000 coverage rises from **87/129 to 92/129 handled (71.3%)**, +leaving 37 GAP ops / 101 GAP instructions. Headless sequence capture still emits the known dummy-renderer +`GetImage` diagnostics while completing successfully; it is not an audio failure. + +**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 +were audible and sounded good. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 40c212b..98ebef8 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -179,6 +179,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after | `tools/frida/capture_presentation_trace.py` | **Retained-state presentation trace:** correlates the current script offset with native draw/color writes, object composition, surface-command consumption, `gfx_render_frame`, queue clear, and D3D9 Present count. Read-only; distinguishes live retained state from state actually published to the window. | `py -3.11 -u -X utf8 tools/frida/capture_presentation_trace.py [secs] [pid\|AGE.EXE]` | native game → `build/native-presentation-trace.jsonl` | | `tools/frida/capture_adv_text_trace.py` | **ADV text trace:** correlates SC offsets with op `0x6e`/`0x7a`/`0x204`, layout cursor/origin and 20-byte record counts, CP932 strings, surface draw/bind, and timed glyph-record publication. Read-only and deliberately limited to low-frequency known handlers; the first experimental version's D3D scan plus hot per-glyph/render hooks crashed in `frida-agent.dll` during teardown and was removed. | `py -3.11 -u -X utf8 tools/frida/capture_adv_text_trace.py [secs] [pid\|AGE.EXE]` | native game → `build/native-adv-text-trace.jsonl` | +| `tools/frida/capture_sfx_trace.py` | **SFX/DirectSound trace:** correlates SC offsets with `0xb4`/`0xb5`/`0xb6`/`0xc2`/`0xd9`, resource/channel load/start/release, decoder/buffer state, BGM fade ticks, and dynamically discovered DirectSound `Play`/`Stop`/volume/pan calls. Read-only. | `py -3.11 -u -X utf8 tools/frida/capture_sfx_trace.py [secs] [pid\|AGE.EXE]` | native game → `build/native-sfx-trace.jsonl` | *(Static disassembly of `build/engine-dump/range_00400000.bin` uses **capstone** — `py -3.11 -m pip install capstone`; VA `X` → file offset `X−0x400000`.)* diff --git a/engine/Age.Engine.Tests/SfxOpsTests.cs b/engine/Age.Engine.Tests/SfxOpsTests.cs new file mode 100644 index 0000000..2294e6f --- /dev/null +++ b/engine/Age.Engine.Tests/SfxOpsTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class SfxOpsTests +{ + [Fact] + public void Sc0000SfxLifecycleAndBgmFadeReachHost() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "SFX", + new List<(int, Operand[])> + { + (0xb4, new[] { new Operand(0, 0x28), new Operand(0, 0) }), + (0xb5, new[] { new Operand(0, 0) }), + (0xb6, new[] { new Operand(0, 0) }), + (0xc2, new[] { new Operand(0, 25), new Operand(0, 3000) }), + (0xd9, Array.Empty()), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal((0x28L, 0), Assert.Single(host.SfxLoads)); + Assert.Equal(0, Assert.Single(host.SfxStarts)); + Assert.Equal(0, Assert.Single(host.SfxReleases)); + Assert.Equal((25, 3000L), Assert.Single(host.BgmFades)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 9366cd5..fe6a783 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -17,6 +17,10 @@ internal sealed class RecordingHost : IHost public readonly List<(int Slot, int X, int Y)> TextCursors = new(); public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new(); public readonly List SleptDurations = new(); + public readonly List<(long Resource, int Channel)> SfxLoads = new(); + public readonly List SfxStarts = new(); + public readonly List SfxReleases = new(); + public readonly List<(int Target, long Duration)> BgmFades = new(); public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) @@ -44,6 +48,10 @@ internal sealed class RecordingHost : IHost public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) { } public void PlayVoice(long id) { } + public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel)); + public void StartSoundEffect(int channel) => SfxStarts.Add(channel); + public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel); + public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs)); } internal sealed class MapProvider : IScriptProvider diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 3d9fb08..89d5cec 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -25,4 +25,8 @@ public interface IHost (int Width, int Height) GetTextureSize(int slot); void PlayBgm(long id); void PlayVoice(long id); + void LoadSoundEffect(long resourceId, int channel) { } + void StartSoundEffect(int channel) { } + void ReleaseSoundEffect(int channel) { } + void FadeBgm(int targetPercent, long durationMs) { } } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index da6499c..b638ca5 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -76,14 +76,15 @@ public sealed class ResourceMap 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). + /// Loose extracted OGG/WAV path for an audio asset (extracted/DATA{n}/{name}), or null. + /// Used by the current bootstrap audio backend; voices and SFX use the scene manifest via Resolve. public static string? AudioPath(AssetEntry a) { - if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase)) return null; + if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) && + !a.Name.EndsWith(".WAV", 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; + var audio = Path.Combine(Paths.Extracted, dir, a.Name); + return File.Exists(audio) ? audio : null; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 357dff2..a3e9c5a 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -25,6 +25,8 @@ public sealed class VirtualMachine public long CallScriptDispatches { get; private set; } public Dictionary Globals { get; } = new(); + /// Native/profile-owned values read by scripts but maintained outside script-visible writes. + public Dictionary ExternalGlobals { get; } = new(); public Dictionary GlobalStrings { get; } = new(); public GfxState Gfx { get; } = new(); public List<(int Offset, string Text, string Script)> Emitted { get; } = new(); @@ -37,6 +39,7 @@ public sealed class VirtualMachine _sink = sink ?? NullTraceSink.Instance; } private static long Gi(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : 0; + private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k); private static string Gs(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : ""; private static long PyDiv(long a, long b) { if (b == 0) return 0; long q = a / b, r = a % b; if (r != 0 && (r < 0) != (b < 0)) q--; return q; } private static long PyMod(long a, long b) { if (b == 0) return 0; long r = a % b; if (r != 0 && (r < 0) != (b < 0)) r += b; return r; } @@ -73,7 +76,7 @@ public sealed class VirtualMachine private long Read(Operand op) => op.Type switch { T_IMM => op.Value, - T_GINT or T_GFLOAT => Gi(Globals, (int)op.Value), + T_GINT or T_GFLOAT => ReadGlobal((int)op.Value), T_GPTR => Gi(Globals, (int)Gi(Globals, (int)op.Value)), T_LINT => Gi(_cur.Locals.I, (int)op.Value), T_LFLOAT => Gi(_cur.Locals.F, (int)op.Value), @@ -319,6 +322,16 @@ public sealed class VirtualMachine } case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1; + case "play-sound-effect": // 0xb4 / semantics: sfx-load + _host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1; + case "u0041D050": // 0xb5 / semantics: sfx-start + _host.StartSoundEffect((int)Read(a[0])); return pc + 1; + case "u0041D080": // 0xb6 / semantics: sfx-release + _host.ReleaseSoundEffect((int)Read(a[0])); return pc + 1; + case "u0041D2B0": // 0xc2 / semantics: fade-bgm + _host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1; + case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000 + return pc + 1; // ---- gfx command-buffer ops (VM-internal GfxState; docs/engine-re.md op-contract table) ---- case "query-gfx-object?": // 0x215 (out)(handle) -> slot | -1 if (_diagSetTexture) // reuse the flag: show what the slot query returns (grey-BG slot dig) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index e900f20..db731b6 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -10,6 +10,7 @@ public sealed class GodotAdvHost : IHost private readonly ResourceMap _res; private readonly string _scene; // e.g. "SC0000" — for section_base private readonly Dictionary _slotBmp = new(); // slot -> pre-converted BMP path + private readonly string?[] _sfxPaths = new string?[10]; // SC0000 native channel subset // 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). private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; @@ -275,6 +276,47 @@ public sealed class GodotAdvHost : IHost var path = asset != null ? ResourceMap.AudioPath(asset) : null; if (path != null) _main.CallDeferred("PlayVoice", path); } + + public void LoadSoundEffect(long resourceId, int channel) + { + if ((uint)channel >= (uint)_sfxPaths.Length) return; + var asset = _res.Resolve(_scene, resourceId); + var path = asset != null ? ResourceMap.AudioPath(asset) : null; + _sfxPaths[channel] = path; + _timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel, + ["file"] = path != null ? System.IO.Path.GetFileName(path) : null }); + if (path != null) _main.CallDeferred("LoadSoundEffect", path, channel); + } + + public void StartSoundEffect(int channel) + { + if ((uint)channel >= (uint)_sfxPaths.Length || _sfxPaths[channel] == null) return; + _timeline?.Event("sfx-start", new() { ["channel"] = channel, + ["file"] = System.IO.Path.GetFileName(_sfxPaths[channel]) }); + _main.CallDeferred("StartSoundEffect", channel); + } + + public void ReleaseSoundEffect(int channel) + { + if ((uint)channel >= (uint)_sfxPaths.Length) return; + _timeline?.Event("sfx-release", new() { ["channel"] = channel, + ["file"] = _sfxPaths[channel] != null ? System.IO.Path.GetFileName(_sfxPaths[channel]) : null }); + _sfxPaths[channel] = null; + _main.CallDeferred("ReleaseSoundEffect", channel); + } + + public void FadeBgm(int targetPercent, long durationMs) + { + long ms = System.Math.Clamp(durationMs, 0, 60_000); + double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); + _timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms }); + _main.CallDeferred("FadeBgm", targetPercent, realSeconds); + long deadline = _clock.NowMs + ms; + IsSleeping = true; + while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50); + IsSleeping = false; + _timeline?.State("running", new() { ["bgm_fade_complete"] = true }); + } } public readonly record struct SurfaceTextDraw(int X, int Y, string Text); diff --git a/godot/Main.cs b/godot/Main.cs index f845f0d..8c440d0 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -18,6 +18,7 @@ public partial class Main : Godot.Control private Label _status = null!; private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _voice = null!; // interrupt-on-new voice + private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9 private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; private readonly Age.Engine.Hosting.FrameClock _clock = new(); @@ -100,6 +101,11 @@ public partial class Main : Godot.Control _voice = new AudioStreamPlayer(); AddChild(_bgm); AddChild(_voice); + for (int i = 0; i < _sfx.Length; i++) + { + _sfx[i] = new AudioStreamPlayer(); + AddChild(_sfx[i]); + } var userArgs = OS.GetCmdlineUserArgs(); _selftest = System.Array.IndexOf(userArgs, "--selftest") >= 0; @@ -168,6 +174,10 @@ public partial class Main : Godot.Control foreach (var kv in session.GlobalStrings) _vm.GlobalStrings[kv.Key] = kv.Value; GD.Print($"[boot] system boot done: {session.Globals.Count} globals seeded"); } + // Native AGE owns this transient secondary-SFX channel outside script-visible writes. + // The matching SC0000 trace has value 4 at 0xc31; seed only this proven profile/slice. + if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) + _vm.ExternalGlobals[0x6242d] = 4; foreach (var (addr, val) in seeds) _vm.Globals[addr] = val; // --seed overrides boot state _ = Task.Run(() => { _vm.Run(); _done = true; }); @@ -214,7 +224,7 @@ public partial class Main : Godot.Control return; } // --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it. - if (_shotPath != null && !_shotDone && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) + if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) { if (++_shotSettle >= _shotSettleTarget) { @@ -507,6 +517,7 @@ public partial class Main : Godot.Control var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } stream.Loop = true; + _bgm.VolumeDb = 0; _bgm.Stream = stream; _bgm.Play(); } @@ -520,6 +531,38 @@ public partial class Main : Godot.Control _voice.Play(); } + public void LoadSoundEffect(string wavPath, int channel) + { + if ((uint)channel >= (uint)_sfx.Length) return; + var stream = AudioStreamWav.LoadFromBuffer(System.IO.File.ReadAllBytes(wavPath)); + if (stream == null) { GD.Print($"WAV load failed {wavPath}"); return; } + stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled; + _sfx[channel].Stop(); + _sfx[channel].VolumeDb = 0; + _sfx[channel].Stream = stream; + } + + public void StartSoundEffect(int channel) + { + if ((uint)channel < (uint)_sfx.Length && _sfx[channel].Stream != null) + _sfx[channel].Play(); + } + + public void ReleaseSoundEffect(int channel) + { + if ((uint)channel >= (uint)_sfx.Length) return; + _sfx[channel].Stop(); + _sfx[channel].Stream = null; + } + + public void FadeBgm(int targetPercent, double realDurationSeconds) + { + float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f); + float targetDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear); + if (realDurationSeconds <= 0) { _bgm.VolumeDb = targetDb; return; } + CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds); + } + public void AppendLine(string text) => _text.Text += text + "\n"; public void PageBreak() { _pageCount++; _status.Text = ""; } public void ClearPage() { _text.Text = ""; _status.Text = ""; } diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 5a95078..bde70b2 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -9,7 +9,11 @@ INFERRED: dict[int, dict] = { 0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'), 0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'), 0x97: dict(name='hotspot-reg?', category='input', noop=True, confidence='med', source='inference', summary='companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 ; NO code targets)'), - 0xb6: dict(name='snd-ctrl?', category='audio', noop=False, confidence='low', source='inference', summary='1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control'), + 0xb4: dict(name='sfx-load', category='audio', noop=False, confidence='high', source='investigation', summary="(resource_id)(channel) — synchronously resolve/open the scene-manifest asset and replace the channel's decoded sound buffer without starting playback. Native manager supports channels 0..12; SC0000 uses 0..9."), + 0xb5: dict(name='sfx-start', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback.'), + 0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'), + 0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.'), + 0xd9: dict(name='clear-run-state-0x1000', category='control', noop=True, confidence='high', source='investigation', summary='Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result.'), 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), 0x1bc: dict(name='block-mark', category='marker', noop=True, confidence='high', source='inference', summary='zero-arg; follows jcc/mov, precedes mov/ret — block boundary'), 0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'), diff --git a/tools/frida/capture_sfx_trace.py b/tools/frida/capture_sfx_trace.py new file mode 100644 index 0000000..2105855 --- /dev/null +++ b/tools/frida/capture_sfx_trace.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Capture native SC0000 SFX op/worker/DirectSound timing (read-only). + +Start at the title, arm this probe, then choose New Game. The opening reaches the first +load/start pair at 0xc29 without input. Output: build/native-sfx-trace.jsonl. +""" +import json +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +OUT = REPO / "build" / "native-sfx-trace.jsonl" +TMP = REPO / "build" / "native-sfx-trace.tmp.jsonl" +LIVE = REPO / "build" / "sfx-tracer-live.flag" + +JS = r""" +const mod=Process.getModuleByName('AGE.EXE'); +const OFF={operand:0x1b940,b4:0x201d0,b5:0x20210,b6:0x20250,c2:0x204c0,d9:0x16da0, + load:0x82500,start:0x825d0,release:0x82600,decode:0x83360,destroy:0x831a0, + dsStart:0x84270,fadeArm:0x64830,fadeTick:0x64960}; +const IDX=0x53d14,PC=0x53d2c,CB=0x53d28,STRIDE=0x78; +let ctx=null,current={codebase:0,offset:-1},seq=0; const hooked={}; +function i32(p,o=0){try{return p.add(o).readS32();}catch(e){return null;}} +function u32(p,o=0){try{return p.add(o).readU32();}catch(e){return null;}} +function sp(reg,n){try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;}} +function pp(p,o=0){try{return p.add(o).readPointer();}catch(e){return ptr(0);}} +function emit(name,x={}){send(Object.assign({kind:'event',seq:++seq,t:Date.now(),name, + codebase:current.codebase,offset:current.offset,runFlags:ctx?u32(ctx,0xa0ce4):null},x));} +function mgrState(m,ch){return {manager:m.toString(),channel:ch,resource:i32(m,0x4bc+ch*4), + object:pp(m,0x5a4+ch*4).toString()};} +function install(){ +Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){ctx=this.context.ecx;try{const n=i32(ctx,IDX); + if(n<0||n>=64)return;const pc=u32(ctx,PC+n*STRIDE),cb=u32(ctx,CB+n*STRIDE); + current={codebase:cb>>>0,offset:((pc-cb)>>>2)};}catch(e){}}}); +for(const n of ['b4','b5','b6','c2','d9']) Interceptor.attach(mod.base.add(OFF[n]),{onEnter(){emit('op-0x'+n);}}); +function hookDs(obj,ch){if(!obj||obj.isNull())return;const buf=pp(obj,0x40c);if(buf.isNull())return; + const vt=pp(buf),key=vt.toString();if(hooked[key])return;hooked[key]=true; + for(const [name,slot] of [['play',12],['set-position',13],['set-volume',15],['set-pan',16],['stop',18]]){ + const fn=pp(vt,slot*4),k=fn.toString();if(hooked[k])continue;hooked[k]=true; + Interceptor.attach(fn,{onEnter(args){emit('ds-'+name,{channel:ch,buffer:args[0].toString(),arg1:args[1].toInt32(),arg2:args[2].toInt32(),arg3:args[3].toInt32()});}}); + } emit('ds-hooks',{channel:ch,object:obj.toString(),buffer:buf.toString(),vtable:vt.toString()});} +Interceptor.attach(mod.base.add(OFF.load),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);this.res=sp(this.context,1); + emit('sfx-load-enter',Object.assign({resourceArg:this.res},mgrState(this.m,this.ch)));},onLeave(ret){const s=mgrState(this.m,this.ch); + emit('sfx-load-leave',Object.assign({ret:ret.toInt32()},s));hookDs(ptr(s.object),this.ch);}}); +Interceptor.attach(mod.base.add(OFF.start),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);this.mode=sp(this.context,1); + const s=mgrState(this.m,this.ch);emit('sfx-start-enter',Object.assign({mode:this.mode},s));hookDs(ptr(s.object),this.ch);}, + onLeave(ret){emit('sfx-start-leave',Object.assign({ret:ret.toInt32()},mgrState(this.m,this.ch)));}}); +Interceptor.attach(mod.base.add(OFF.release),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);emit('sfx-release',mgrState(this.m,this.ch));}}); +Interceptor.attach(mod.base.add(OFF.decode),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);emit('sfx-decode-enter',{manager:this.m.toString(),channel:this.ch,byteLength:sp(this.context,1),fileSlot:sp(this.context,2)});}, + onLeave(){const obj=pp(this.m,0x5a4+this.ch*4);hookDs(obj,this.ch);}}); +Interceptor.attach(mod.base.add(OFF.destroy),{onEnter(){emit('sfx-buffer-destroy',{manager:this.context.ecx.toString(),channel:sp(this.context,0)});}}); +Interceptor.attach(mod.base.add(OFF.dsStart),{onEnter(){this.obj=this.context.ecx;hookDs(this.obj,i32(this.obj,0x408));emit('ds-start-worker-enter',{object:this.obj.toString(),buffer:pp(this.obj,0x40c).toString(),preloaded:i32(this.obj,0x245c),playing:i32(this.obj,0x2460),loop:i32(this.obj,0x2464)});}, + onLeave(ret){emit('ds-start-worker-leave',{object:this.obj.toString(),ret:ret.toInt32(),preloaded:i32(this.obj,0x245c),playing:i32(this.obj,0x2460),loop:i32(this.obj,0x2464)});}}); +Interceptor.attach(mod.base.add(OFF.fadeArm),{onEnter(){emit('audio-fade-arm',{object:this.context.ecx.toString(),target:sp(this.context,0),step:sp(this.context,1),current:i32(this.context.ecx,0x420)});}}); +Interceptor.attach(mod.base.add(OFF.fadeTick),{onEnter(){emit('audio-fade-tick',{object:this.context.ecx.toString(),ticks:sp(this.context,0),progress:i32(this.context.ecx,0x418),current:i32(this.context.ecx,0x420),target:i32(this.context.ecx,0x424)});}}); +send({kind:'ready',base:mod.base.toString()}); +} +install(); +""" + +def main(): + import frida + args = sys.argv[1:] + numbers = [int(a) for a in args if a.isdigit()] + seconds = numbers[0] if numbers else 30 + target = numbers[1] if len(numbers)>1 else "AGE.EXE" + OUT.parent.mkdir(parents=True,exist_ok=True); counts={} + try: + session=frida.attach(target) + except frida.ProcessNotFoundError: + print("[frida] AGE.EXE not found; prior trace preserved") + return 2 + with TMP.open("w",encoding="utf-8") as f: + def on_message(msg,data): + if msg.get("type")=="error": print("[frida-error]",msg.get("description")); return + if msg.get("type")!="send": return + row=msg["payload"] + if row.get("kind")=="ready": print(f"[frida] SFX hooks armed @ {row['base']}"); LIVE.write_text("live",encoding="utf-8"); return + f.write(json.dumps(row,ensure_ascii=False)+"\n"); f.flush(); n=row.get("name","?");counts[n]=counts.get(n,0)+1 + if n.startswith("op-") or n in {"sfx-load-enter","sfx-start-enter","sfx-release","ds-play","ds-stop","ds-set-volume","ds-set-pan","audio-fade-arm"}: + print(f" #{row['seq']:04d} off=0x{row['offset']:05x} {n} ch={row.get('channel','-')} res={row.get('resourceArg',row.get('resource','-'))}") + try: + script=session.create_script(JS);script.on("message",on_message);script.load() + except Exception: + raise + print(f"[frida] ARMED for {seconds}s -- start New Game when the title appears.") + try: time.sleep(seconds) + except KeyboardInterrupt: pass + try: session.detach() + except Exception: pass + try: LIVE.unlink() + except OSError: pass + if counts: + TMP.replace(OUT) + else: + try: TMP.unlink() + except OSError: pass + print(f"[trace] wrote {sum(counts.values())} events -> {OUT if counts else '(prior trace preserved)'}") + print("[trace] "+", ".join(f"{k}={v}" for k,v in sorted(counts.items()))) + return 0 if counts else 3 +if __name__=="__main__": raise SystemExit(main()) diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 5021f20..83969c1 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1721,23 +1721,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "play-sound-effect" -category = "unknown" -summary = "" +name = "sfx-load" +category = "audio" +summary = "(resource_id)(channel) — synchronously resolve/open the scene-manifest asset and replace the channel's decoded sound buffer without starting playback. Native manager supports channels 0..12; SC0000 uses 0..9." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra op 0xb4 handler 0x4201d0 -> sfx_channel_load@0x482500 -> asset_open@0x44f390 + sound_decode_channel@0x483360. Native trace: SC0000 0xc29 loads resource 0x28 into channel 0; resource resolves by section_base+id to E0808.WAV; completion precedes 0xb5 in the same millisecond." [[opcode.semantics.args]] i = 1 -role = "" +role = "resource_id" observed_types = ["imm", "g-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "channel" observed_types = ["imm", "g-int", "l-int"] [[opcode]] @@ -1747,18 +1747,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041D050" -category = "unknown" -summary = "" +name = "sfx-start" +category = "audio" +summary = "(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra op 0xb5 handler 0x420210 passes mode 0 to sfx_channel_start@0x4825d0; mode 1 belongs to op 0xba. sound_buffer_start@0x484270 primes four quarter-buffer notifications then calls IDirectSoundBuffer::Play with flag 1 before returning. Native trace at SC0000 0xc2e: E0808 channel 0 start enters/leaves in the same ms, preloaded 1->0 and playing 0->1." [[opcode.semantics.args]] i = 1 -role = "" +role = "channel" observed_types = ["imm", "g-int"] [[opcode]] @@ -1768,18 +1768,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "snd-ctrl?" +name = "sfx-release" category = "audio" -summary = "1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control" +summary = "(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel." noop_headless = false -source = "inference" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "confirm via frida" +evidence = "Ghidra op 0xb6 handler 0x420250 -> sfx_channel_release@0x482600 -> sound_buffer_destroy@0x4831a0, which releases the per-channel object under its critical section and clears the slot. Native trace captured SC0000's channels 0..9 release sweep in consecutive calls." [[opcode.semantics.args]] i = 1 -role = "" +role = "channel" observed_types = ["imm"] [[opcode]] @@ -1910,23 +1910,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041D2B0" -category = "unknown" -summary = "" +name = "fade-bgm" +category = "audio" +summary = "(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms." [[opcode.semantics.args]] i = 1 -role = "" +role = "target_percent" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "duration_ms" observed_types = ["imm"] [[opcode]] @@ -2193,14 +2193,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415880" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "clear-run-state-0x1000" +category = "control" +summary = "Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra op 0xd9 handler 0x416da0: ctx->run_state_flags &= ~0x1000; when ctx+0x6f8b8 is nonzero, also clears bit 0x1000 at ctx+0x53d20. No operands, calls, or return value." [[opcode]] op = 0xfb