Implement native-backed SC0000 SFX lifecycle
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.)*
|
||||
|
||||
|
||||
35
engine/Age.Engine.Tests/SfxOpsTests.cs
Normal file
35
engine/Age.Engine.Tests/SfxOpsTests.cs
Normal file
@@ -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<Operand>()),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<long> SleptDurations = new();
|
||||
public readonly List<(long Resource, int Channel)> SfxLoads = new();
|
||||
public readonly List<int> SfxStarts = new();
|
||||
public readonly List<int> 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
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -76,14 +76,15 @@ public sealed class ResourceMap
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ public sealed class VirtualMachine
|
||||
public long CallScriptDispatches { get; private set; }
|
||||
|
||||
public Dictionary<int, long> Globals { get; } = new();
|
||||
/// <summary>Native/profile-owned values read by scripts but maintained outside script-visible writes.</summary>
|
||||
public Dictionary<int, long> ExternalGlobals { get; } = new();
|
||||
public Dictionary<int, string> 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<int, long> 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<int, string> 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)
|
||||
|
||||
@@ -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<int, string?> _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<int, (int W, int H)> _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);
|
||||
|
||||
@@ -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 = ""; }
|
||||
|
||||
@@ -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 <action-id>; 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'),
|
||||
|
||||
102
tools/frida/capture_sfx_trace.py
Normal file
102
tools/frida/capture_sfx_trace.py
Normal file
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user