paavohtl Claude Opus 4.6 (1M context) commited on
Commit
c2c06b0
Β·
1 Parent(s): 1b50411

feat: port Music streaming audio system to pure Rust

Browse files

Replace WA's Music/StreamingAudio system with a pure Rust implementation
using hound for WAV parsing and DirectSound for streaming playback.

Music system (0x354-byte object, vtable 0x66B3E0):
- Unified Music + StreamingAudio into single struct with full field map
- 6-slot vtable fully replaced via vtable_replace! macro
- Constructor hooked (0x58BC10), destructor uses wa_free
- Playlist management, track ID-to-filename mapping (1-13)
- Volume control with dB lookup table, using Fixed type

Streaming engine:
- WAV streaming via hound + BufReader<File> (replaces mmio API)
- DirectSound double-buffered ring buffer with timeSetEvent refill
- Buffer size matches original formula (timer_delay * avg_bytes/1000)
- advance_track guards against premature track changes (checks
IDirectSoundBuffer::GetStatus before advancing)

Sound hooks:
- Hook PlayWormSound, StopWormSound, LoadAndPlayStreaming
- Remove old streaming.rs stub, update GameSession to use *mut Music

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-core/src/address.rs CHANGED
@@ -56,8 +56,15 @@ pub mod va {
56
  };
57
  pub use crate::task::worm::{CTASK_WORM_CONSTRUCTOR, CTASK_WORM_VTABLE};
58
 
59
- // Re-exported from modules using #[vtable(...)] attribute
60
  pub use crate::audio::dssound::DS_SOUND_VTABLE;
 
 
 
 
 
 
 
61
  pub use crate::display::base::DISPLAY_BASE_VTABLE;
62
  pub use crate::display::dd_display::DD_DISPLAY_VTABLE;
63
  pub use crate::display::palette::PALETTE_VTABLE;
@@ -538,6 +545,8 @@ pub mod va {
538
  fn/Fastcall PLAY_SOUND_POOLED_DIRECT = 0x0054_6B50;
539
  /// Distance3D_Attenuation
540
  fn/Usercall DISTANCE_3D_ATTENUATION = 0x0054_30F0;
 
 
541
  }
542
 
543
  // =========================================================================
 
56
  };
57
  pub use crate::task::worm::{CTASK_WORM_CONSTRUCTOR, CTASK_WORM_VTABLE};
58
 
59
+ // Re-exported from audio modules
60
  pub use crate::audio::dssound::DS_SOUND_VTABLE;
61
+ pub use crate::audio::music::{
62
+ MUSIC_CONSTRUCTOR, MUSIC_DESTRUCTOR, MUSIC_PLAY_TRACK, MUSIC_VOLUME_DB_TABLE,
63
+ MUSIC_VTABLE, STREAMING_AUDIO_FILL_AND_START, STREAMING_AUDIO_INIT,
64
+ STREAMING_AUDIO_INIT_PLAYBACK, STREAMING_AUDIO_OPEN, STREAMING_AUDIO_OPEN_WAV,
65
+ STREAMING_AUDIO_READ_CHUNK, STREAMING_AUDIO_RESET, STREAMING_AUDIO_STOP,
66
+ STREAMING_AUDIO_TIMER_CALLBACK,
67
+ };
68
  pub use crate::display::base::DISPLAY_BASE_VTABLE;
69
  pub use crate::display::dd_display::DD_DISPLAY_VTABLE;
70
  pub use crate::display::palette::PALETTE_VTABLE;
 
545
  fn/Fastcall PLAY_SOUND_POOLED_DIRECT = 0x0054_6B50;
546
  /// Distance3D_Attenuation
547
  fn/Usercall DISTANCE_3D_ATTENUATION = 0x0054_30F0;
548
+ /// ActiveSoundTable::stop_sound β€” stops an active streaming sound by handle.
549
+ fn ACTIVE_SOUND_TABLE_STOP_SOUND = 0x0054_6490;
550
  }
551
 
552
  // =========================================================================
crates/openwa-core/src/audio/mod.rs CHANGED
@@ -6,7 +6,6 @@ pub mod sound_id;
6
  pub mod sound_ops;
7
  pub mod speech;
8
  pub mod speech_ops;
9
- pub mod streaming;
10
  pub mod wav_player;
11
 
12
  pub use active_sound::{ActiveSoundEntry, ActiveSoundTable};
@@ -19,7 +18,6 @@ pub use dssound::{
19
  };
20
  pub use dssound::{ChannelDescriptor, DSSound, DSSoundVtable};
21
  pub use known_sound_id::KnownSoundId;
22
- pub use music::Music;
23
  pub use sound_id::SoundId;
24
  pub use speech::{SpeechLineId, SpeechLineTableEntry, SpeechSlotTable, SPEECH_LINE_TABLE_COUNT};
25
- pub use streaming::StreamingAudio;
 
6
  pub mod sound_ops;
7
  pub mod speech;
8
  pub mod speech_ops;
 
9
  pub mod wav_player;
10
 
11
  pub use active_sound::{ActiveSoundEntry, ActiveSoundTable};
 
18
  };
19
  pub use dssound::{ChannelDescriptor, DSSound, DSSoundVtable};
20
  pub use known_sound_id::KnownSoundId;
21
+ pub use music::{Music, MusicVtable, StreamingAudio};
22
  pub use sound_id::SoundId;
23
  pub use speech::{SpeechLineId, SpeechLineTableEntry, SpeechSlotTable, SPEECH_LINE_TABLE_COUNT};
 
crates/openwa-core/src/audio/music.rs CHANGED
@@ -1,12 +1,997 @@
1
- /// Music β€” music playback object.
2
  ///
3
- /// Vtable: 0x66B3E0.
4
- /// Constructor: FUN_0058BC10 (copies a string parameter).
5
- /// Passed as "music" parameter to DDGame__Constructor, stored at DDGame+0x014.
6
  ///
7
- /// OPAQUE: Size and fields not yet determined. Only vtable pointer defined.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  #[repr(C)]
9
  pub struct Music {
10
- /// 0x000: Vtable pointer (0x66B3E0)
11
- pub vtable: *mut u8,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
 
1
+ /// Music β€” music playback object (0x354 bytes).
2
  ///
3
+ /// Combines a playlist controller with an embedded [`StreamingAudio`] engine.
4
+ /// Created by `GameEngine::InitHardware`, stored at `DDGame+0x014`.
 
5
  ///
6
+ /// Constructor: 0x58BC10, usercall(ESI=this) + stack(IDirectSound*, path), RET 0x8.
7
+ /// Vtable: 0x66B3E0 (6 slots).
8
+ ///
9
+ /// The music system maps track IDs 1-13 to WAV files under the base path
10
+ /// (typically `Data\streams`).
11
+ use std::ffi::c_char;
12
+
13
+ use crate::fixed::Fixed;
14
+
15
+ /// Raw COM pointer stored as u32 (matches the 32-bit target's pointer size).
16
+ type Ptr32 = u32;
17
+
18
+ define_addresses! {
19
+ class "Music" {
20
+ ctor MUSIC_CONSTRUCTOR = 0x0058_BC10;
21
+ }
22
+ /// Music destructor (called from scalar deleting dtor).
23
+ fn MUSIC_DESTRUCTOR = 0x0058_BC80;
24
+ /// Music::PlayTrack internal β€” maps track ID to filename, calls StreamingAudio::Open.
25
+ fn/Usercall MUSIC_PLAY_TRACK = 0x0058_BD20;
26
+ /// Music volume dB lookup table (65 entries, i32).
27
+ data MUSIC_VOLUME_DB_TABLE = 0x006A_F960;
28
+ /// StreamingAudio::Init (zero-init base object).
29
+ fn/Usercall STREAMING_AUDIO_INIT = 0x0047_07B0;
30
+ /// StreamingAudio::Open β€” opens WAV, configures buffer, starts playback.
31
+ fn/Usercall STREAMING_AUDIO_OPEN = 0x0057_4B30;
32
+ /// StreamingAudio::Stop β€” kills timer, releases DS buffer, closes mmio.
33
+ fn/Usercall STREAMING_AUDIO_STOP = 0x0057_4CA0;
34
+ /// StreamingAudio::InitPlayback β€” core setup (timer caps, DS buffer, timer).
35
+ fn/Stdcall STREAMING_AUDIO_INIT_PLAYBACK = 0x0057_4D10;
36
+ /// StreamingAudio::OpenWAV β€” parse RIFF/WAVE via mmio.
37
+ fn/Usercall STREAMING_AUDIO_OPEN_WAV = 0x0057_4F80;
38
+ /// StreamingAudio::FillAndStart β€” initial buffer fill + DS Play + timeSetEvent.
39
+ fn/Usercall STREAMING_AUDIO_FILL_AND_START = 0x0057_51B0;
40
+ /// StreamingAudio::Reset β€” kill timer, stop buffer, seek back.
41
+ fn/Usercall STREAMING_AUDIO_RESET = 0x0057_52D0;
42
+ /// StreamingAudio::TimerCallback β€” periodic buffer refill (stdcall, RET 0x14).
43
+ fn/Stdcall STREAMING_AUDIO_TIMER_CALLBACK = 0x0057_53A0;
44
+ /// StreamingAudio::ReadChunk β€” read clamped WAV data from mmio.
45
+ fn STREAMING_AUDIO_READ_CHUNK = 0x0057_5370;
46
+ }
47
+
48
+ /// Music object β€” playlist controller + embedded streaming engine.
49
+ ///
50
+ /// Total size: 0x354 bytes (852 bytes).
51
  #[repr(C)]
52
  pub struct Music {
53
+ /// 0x000: Vtable pointer (0x66B3E0, 6 slots).
54
+ pub vtable: *const MusicVtable,
55
+ /// 0x004: Playlist track IDs (max 32 entries).
56
+ pub playlist: [u32; 32],
57
+ /// 0x084: Base path for music files (e.g. "DATA\\Music"), 256 bytes.
58
+ pub base_path: [c_char; 256],
59
+ /// 0x184: Number of tracks in current playlist.
60
+ pub playlist_count: u32,
61
+ /// 0x188: Index of currently playing track in playlist.
62
+ pub current_track_index: u32,
63
+ /// 0x18C: IDirectSound instance (raw COM pointer).
64
+ pub direct_sound: Ptr32,
65
+ /// 0x190: Embedded streaming audio engine (0x1C4 bytes).
66
+ pub streaming: StreamingAudio,
67
+ }
68
+
69
+ const _: () = assert!(core::mem::size_of::<Music>() == 0x354);
70
+
71
+ /// Streaming audio engine β€” WAV file streaming via DirectSound double-buffering.
72
+ ///
73
+ /// Embedded at Music+0x190. The original WA code uses mmio API for WAV I/O;
74
+ /// our Rust port uses `hound` + `std::fs::File` for reading, and
75
+ /// `timeSetEvent` for periodic buffer refill.
76
+ ///
77
+ /// Size: 0x1C4 bytes (452 bytes).
78
+ #[repr(C)]
79
+ pub struct StreamingAudio {
80
+ /// 0x00: DirectSound ring buffer size in bytes.
81
+ pub ds_buffer_size: u32,
82
+ /// 0x04: Unknown / unused.
83
+ pub _unknown_04: u32,
84
+ /// 0x08: Buffer segment count (quality-dependent: 24/5/4/3 for 8k/16k/22k/44kHz).
85
+ pub buffer_segments: u32,
86
+ /// 0x0C: log2(disk sector size) β€” shift count for sector alignment.
87
+ pub sector_shift: u8,
88
+ /// 0x0D: Padding.
89
+ pub _pad_0d: [u8; 3],
90
+ /// 0x10: Timer interval in milliseconds (quality-dependent: 2000/600/400/100ms).
91
+ pub timer_delay_ms: u32,
92
+ /// 0x14: Timer resolution in milliseconds (timer_delay_ms / 4).
93
+ pub timer_resolution_ms: u32,
94
+ /// 0x18: Number of aligned sectors in ring buffer.
95
+ pub num_sectors: u32,
96
+ /// 0x1C: Ring buffer write cursor position (bytes).
97
+ pub write_cursor: u32,
98
+ /// 0x20: Total bytes played (accumulated from play cursor delta).
99
+ pub bytes_played: u32,
100
+ /// 0x24: Total bytes read from WAV data chunk.
101
+ pub file_bytes_read: u32,
102
+ /// 0x28: Previous play cursor position for delta calculation.
103
+ pub last_play_cursor: u32,
104
+ /// 0x2C: Flags bitfield.
105
+ /// - bit 0: looping
106
+ /// - bit 1: threaded open
107
+ /// - bit 2: auto-start
108
+ /// - bit 14 (0x4000): DS buffer created/active
109
+ /// - bit 15 (0x8000): stopping
110
+ pub flags: u32,
111
+ /// 0x30: Completion callback (LPTHREAD_START_ROUTINE), called when non-looping playback ends.
112
+ pub completion_callback: u32,
113
+ /// 0x34: User data for completion callback.
114
+ pub completion_callback_data: u32,
115
+ /// 0x38: Timer capabilities (TIMECAPS: wPeriodMin, wPeriodMax).
116
+ pub time_caps: [u32; 2],
117
+ /// 0x40: Resolved timer period (clamped from TIMECAPS).
118
+ pub timer_period: u32,
119
+ /// 0x44: Timer ID from timeSetEvent.
120
+ pub timer_id: u32,
121
+ /// 0x48: Absolute path to WAV file (MAX_PATH = 260 bytes).
122
+ pub file_path: [c_char; 260],
123
+ /// 0x14C: WAV format (WAVEFORMATEX, 18 bytes for PCM).
124
+ pub wave_format: WaveFormatPcm,
125
+ /// 0x15E: Padding after WAVEFORMATEX.
126
+ pub _pad_15e: [u8; 2],
127
+ /// 0x160: mmio file handle β€” repurposed as Box<std::fs::File> in Rust.
128
+ /// Original: HMMIO. We store a raw pointer to a heap-allocated File.
129
+ /// 0 / null when no file is open.
130
+ pub file_handle: u32,
131
+ /// 0x164: Data chunk info (reused MMCKINFO in original; we store data_size + data_offset).
132
+ pub data_chunk: MmckInfo,
133
+ /// 0x178: Snapshot of bytes_played (copied each timer tick).
134
+ pub bytes_played_snapshot: u32,
135
+ /// 0x17C: 1 when DS buffer is live, 0 otherwise.
136
+ pub is_initialized: u32,
137
+ /// 0x180: Last HRESULT from DirectSound operations.
138
+ pub last_error: u32,
139
+ /// 0x184: Unknown / unused.
140
+ pub _unknown_184: u32,
141
+ /// 0x188: End-of-stream threshold in bytes (fade-out distance).
142
+ pub fade_out_bytes: u32,
143
+ /// 0x18C: Cumulative timer callback duration (timeGetTime).
144
+ pub timer_time_total: u32,
145
+ /// 0x190: Average timer callback time (timer_time_total / timer_call_count).
146
+ pub avg_timer_time: u32,
147
+ /// 0x194: Number of timer callback invocations.
148
+ pub timer_call_count: u32,
149
+ /// 0x198: DS buffer status from GetStatus.
150
+ pub ds_buffer_status: u32,
151
+ /// 0x19C: The DirectSound secondary buffer for playback (raw COM pointer).
152
+ pub ds_buffer: Ptr32,
153
+ /// 0x1A0: IDirectSound device reference (raw COM pointer).
154
+ pub direct_sound: Ptr32,
155
+ /// 0x1A4: Unknown trailing data.
156
+ pub _unknown_1a4: [u8; 0x20],
157
+ }
158
+
159
+ const _: () = assert!(core::mem::size_of::<StreamingAudio>() == 0x1C4);
160
+
161
+ /// WAVEFORMATEX PCM subset (18 bytes) β€” matches the Windows WAVEFORMATEX layout.
162
+ #[repr(C, packed)]
163
+ #[derive(Clone, Copy, Debug)]
164
+ pub struct WaveFormatPcm {
165
+ pub format_tag: u16,
166
+ pub channels: u16,
167
+ pub samples_per_sec: u32,
168
+ pub avg_bytes_per_sec: u32,
169
+ pub block_align: u16,
170
+ pub bits_per_sample: u16,
171
+ pub cb_size: u16,
172
+ }
173
+
174
+ const _: () = assert!(core::mem::size_of::<WaveFormatPcm>() == 18);
175
+
176
+ /// MMCKINFO (20 bytes) β€” multimedia chunk info.
177
+ /// We reuse the struct layout for compatibility but only care about ck_size (data length)
178
+ /// and data_offset (file offset of PCM data start).
179
+ #[repr(C)]
180
+ #[derive(Clone, Copy, Debug)]
181
+ pub struct MmckInfo {
182
+ pub ck_id: u32,
183
+ pub ck_size: u32,
184
+ pub fcc_type: u32,
185
+ pub data_offset: u32,
186
+ pub dw_flags: u32,
187
+ }
188
+
189
+ const _: () = assert!(core::mem::size_of::<MmckInfo>() == 20);
190
+
191
+ /// Music vtable β€” 6 slots at 0x66B3E0.
192
+ ///
193
+ /// Slot order verified by reading vtable data from binary:
194
+ /// +0x00: 0x58BC60 +0x04: 0x58BEE0 +0x08: 0x58BF40
195
+ /// +0x0C: 0x58BE70 +0x10: 0x58BE90 +0x14: 0x58BCE0
196
+ #[openwa_core::vtable(size = 6, va = 0x0066_B3E0, class = "Music")]
197
+ pub struct MusicVtable {
198
+ /// scalar_deleting_dtor(flags) β€” 0x58BC60
199
+ pub scalar_deleting_dtor: fn(this: *mut Music, flags: u32),
200
+ /// advance_track β€” 0x58BEE0
201
+ pub advance_track: fn(this: *mut Music),
202
+ /// set_volume(volume) β€” 0x58BF40. Volume is Fixed 16.16.
203
+ pub set_volume: fn(this: *mut Music, volume: Fixed),
204
+ /// start_music(track_id) β€” 0x58BE70
205
+ pub start_music: fn(this: *mut Music, track_id: u32),
206
+ /// set_playlist(tracks, count) β€” 0x58BE90
207
+ pub set_playlist: fn(this: *mut Music, tracks: *const u32, count: u32),
208
+ /// stop_and_cleanup β€” 0x58BCE0
209
+ pub stop_and_cleanup: fn(this: *mut Music),
210
+ }
211
+
212
+ /// Track ID to WAV filename mapping (from PlayTrack switch at 0x58BD20).
213
+ /// Track IDs 1-13 map to these filenames.
214
+ pub const MUSIC_TRACKS: [&str; 13] = [
215
+ "ingame-01-generic.wav", // 1
216
+ "ingame-02-cavern.wav", // 2
217
+ "ingame-03-jungle.wav", // 3
218
+ "ingame-04-battlezone.wav", // 4
219
+ "ingame-05-forest.wav", // 5
220
+ "ingame-06-weird-alien-plan.wav", // 6
221
+ "ingame-07-outerspace.wav", // 7
222
+ "ingame-08-desert.wav", // 8
223
+ "ingame-09-hell.wav", // 9
224
+ "ingame-10-mech-workshop.wav", // 10
225
+ "ingame-11-rain&surf.wav", // 11
226
+ "suddendeath2-loop.wav", // 12
227
+ "suddendeath1-loop.wav", // 13
228
+ ];
229
+
230
+ /// Streaming audio flags.
231
+ pub mod streaming_flags {
232
+ /// Sound should loop when reaching end of data.
233
+ pub const LOOP: u32 = 0x0001;
234
+ /// Open the file on a worker thread.
235
+ pub const THREADED: u32 = 0x0002;
236
+ /// Auto-start playback after opening.
237
+ pub const AUTO_START: u32 = 0x0004;
238
+ /// DirectSound buffer has been created and is active.
239
+ pub const DS_BUFFER_ACTIVE: u32 = 0x4000;
240
+ /// Playback is stopping / fading out.
241
+ pub const STOPPING: u32 = 0x8000;
242
+ }
243
+
244
+ // ============================================================
245
+ // Music volume dB lookup table
246
+ // ============================================================
247
+
248
+ /// Volume-to-dB lookup table for music (65 entries).
249
+ /// Index: volume * 64 / 0x10000, maps Fixed 16.16 volume to centibels.
250
+ /// Copied from WA.exe .rdata at 0x6AF960.
251
+ /// Index 0 = -10000 cB (silence), index 64 = 0 cB (full volume).
252
+ const MUSIC_VOLUME_DB: [i32; 65] = [
253
+ -10000, -6644, -5644, -5059, -4644, -4322, -4059, -3837, -3644, -3473, -3322,
254
+ -3185, -3059, -2943, -2837, -2737, -2644, -2557, -2474, -2396, -2322, -2251,
255
+ -2184, -2119, -2059, -2000, -1943, -1889, -1837, -1787, -1737, -1690, -1644,
256
+ -1600, -1557, -1515, -1474, -1434, -1396, -1358, -1322, -1286, -1251, -1217,
257
+ -1184, -1151, -1119, -1088, -1059, -1028, -1000, -971, -943, -915, -889,
258
+ -862, -837, -811, -787, -762, -737, -714, -690, -667, 0,
259
+ ];
260
+
261
+ // ============================================================
262
+ // StreamingAudio implementation
263
+ // ============================================================
264
+
265
+ use std::io::{Read, Seek, SeekFrom};
266
+ use std::sync::atomic::{AtomicU32, Ordering};
267
+
268
+ use windows::Win32::Media::Audio::DirectSound::{
269
+ IDirectSound, IDirectSoundBuffer, DSBUFFERDESC,
270
+ };
271
+ use windows::Win32::Media::Audio::{WAVEFORMATEX, WAVE_FORMAT_PCM};
272
+
273
+ /// Global pointer to the active StreamingAudio for the timer callback.
274
+ /// Only one streaming audio instance is active at a time (music playback).
275
+ static STREAMING_AUDIO_PTR: AtomicU32 = AtomicU32::new(0);
276
+
277
+ /// Reinterpret a `*const Ptr32` field (pointing to a stored raw COM pointer)
278
+ /// as `&IDirectSoundBuffer`. The pointer must point to stable memory (e.g. a
279
+ /// struct field), NOT a stack local β€” the returned reference borrows it.
280
+ #[inline]
281
+ unsafe fn ds_buf_from_field(field: *const Ptr32) -> &'static IDirectSoundBuffer {
282
+ &*(field as *const IDirectSoundBuffer)
283
+ }
284
+
285
+ /// Reinterpret a `*const Ptr32` field (pointing to a stored raw COM pointer)
286
+ /// as `&IDirectSound`.
287
+ #[inline]
288
+ unsafe fn ds_from_field(field: *const Ptr32) -> &'static IDirectSound {
289
+ &*(field as *const IDirectSound)
290
+ }
291
+
292
+ impl StreamingAudio {
293
+ /// Zero-initialize the streaming audio engine. Port of FUN_004707B0.
294
+ pub fn init(&mut self) {
295
+ // Zero everything except keep the struct's memory layout intact
296
+ unsafe {
297
+ core::ptr::write_bytes(self as *mut Self as *mut u8, 0, core::mem::size_of::<Self>());
298
+ }
299
+ }
300
+
301
+ /// Open a WAV file and start streaming playback. Port of FUN_00574B30.
302
+ ///
303
+ /// `flags` is a combination of `streaming_flags` constants.
304
+ /// `path` is the absolute path to the WAV file.
305
+ /// `ds` is the IDirectSound instance for buffer creation.
306
+ pub unsafe fn open(
307
+ &mut self,
308
+ path: &str,
309
+ flags: u32,
310
+ ds: Ptr32,
311
+ ) -> bool {
312
+ // If already initialized, stop first
313
+ if self.is_initialized != 0 {
314
+ self.stop();
315
+ }
316
+
317
+ self.direct_sound = ds;
318
+ self.flags = flags;
319
+ self.completion_callback = 0;
320
+ self.completion_callback_data = 0;
321
+
322
+ // Determine quality parameters from flags (original switch on flags & 0x78).
323
+ match flags & 0x78 {
324
+ 0x08 => {
325
+ // 8kHz quality
326
+ self.buffer_segments = 24;
327
+ self.timer_delay_ms = 2000;
328
+ }
329
+ 0x10 => {
330
+ // 16kHz
331
+ self.buffer_segments = 5;
332
+ self.timer_delay_ms = 600;
333
+ }
334
+ 0x20 => {
335
+ // 22kHz (default for music with flags 0x24/0x25)
336
+ self.buffer_segments = 4;
337
+ self.timer_delay_ms = 400;
338
+ }
339
+ 0x40 => {
340
+ // 44kHz
341
+ self.buffer_segments = 3;
342
+ self.timer_delay_ms = 100;
343
+ }
344
+ _ => {
345
+ // Invalid quality β€” original returns E_INVALIDARG
346
+ return false;
347
+ }
348
+ }
349
+
350
+ // Copy path to file_path buffer
351
+ let path_bytes = path.as_bytes();
352
+ let copy_len = path_bytes.len().min(259);
353
+ unsafe {
354
+ core::ptr::copy_nonoverlapping(
355
+ path_bytes.as_ptr(),
356
+ self.file_path.as_mut_ptr() as *mut u8,
357
+ copy_len,
358
+ );
359
+ self.file_path[copy_len] = 0; // null terminate
360
+ }
361
+
362
+ // Initialize playback (open WAV, create buffer, start timer)
363
+ self.init_playback()
364
+ }
365
+
366
+ /// Core playback setup: open WAV file, create DirectSound buffer, start timer.
367
+ /// Port of FUN_00574D10 + FUN_00574F80 + FUN_005751B0.
368
+ unsafe fn init_playback(&mut self) -> bool {
369
+ // Open the WAV file using hound
370
+ let path_cstr = std::ffi::CStr::from_ptr(self.file_path.as_ptr());
371
+ let path_str = match path_cstr.to_str() {
372
+ Ok(s) => s,
373
+ Err(_) => {
374
+ let _ = crate::log::log_line("[Music] init_playback: invalid UTF-8 path");
375
+ return false;
376
+ }
377
+ };
378
+
379
+ let reader = match hound::WavReader::open(path_str) {
380
+ Ok(r) => r,
381
+ Err(e) => {
382
+ let _ = crate::log::log_line(&format!(
383
+ "[Music] init_playback: failed to open \"{}\": {}", path_str, e
384
+ ));
385
+ return false;
386
+ }
387
+ };
388
+
389
+ let spec = reader.spec();
390
+
391
+ // Only PCM format supported
392
+ if spec.sample_format != hound::SampleFormat::Int {
393
+ return false;
394
+ }
395
+
396
+ // Store format info
397
+ let block_align = spec.channels as u16 * (spec.bits_per_sample as u16 / 8);
398
+ let avg_bytes_per_sec = spec.sample_rate * block_align as u32;
399
+ self.wave_format = WaveFormatPcm {
400
+ format_tag: 1, // WAVE_FORMAT_PCM
401
+ channels: spec.channels as u16,
402
+ samples_per_sec: spec.sample_rate,
403
+ avg_bytes_per_sec,
404
+ block_align,
405
+ bits_per_sample: spec.bits_per_sample as u16,
406
+ cb_size: 0,
407
+ };
408
+
409
+ // Store data chunk info
410
+ let data_len = reader.len() * (spec.bits_per_sample as u32 / 8);
411
+ self.data_chunk.ck_size = data_len;
412
+
413
+ // Get data start position from the inner reader, then extract the File.
414
+ // After WavReader::new(), the inner reader is positioned at data start.
415
+ let mut inner = reader.into_inner();
416
+ let data_start = inner.stream_position().unwrap_or(0);
417
+ self.data_chunk.data_offset = data_start as u32;
418
+
419
+ // Store the BufReader<File> as a raw pointer for streaming reads
420
+ let file = Box::new(inner);
421
+ self.file_handle = Box::into_raw(file) as u32;
422
+
423
+ // Compute buffer size matching original float arithmetic (constant at 0x679760 = 1000.0):
424
+ // per_tick = round(timer_delay_ms * avg_bytes_per_sec / 1000.0)
425
+ // ds_buffer_size = per_tick * buffer_segments
426
+ let per_tick = (self.timer_delay_ms as f64 * avg_bytes_per_sec as f64 / 1000.0)
427
+ .round() as u32;
428
+ let buffer_bytes = per_tick * self.buffer_segments;
429
+ self.ds_buffer_size = buffer_bytes;
430
+
431
+ // Timer resolution
432
+ self.timer_resolution_ms = self.timer_delay_ms / 4;
433
+
434
+ // Fade-out threshold
435
+ let fade_ms = self.timer_delay_ms + self.timer_delay_ms / 4;
436
+ self.fade_out_bytes = fade_ms * (avg_bytes_per_sec / 1000);
437
+
438
+ // Create DirectSound secondary buffer (streaming, no STATIC flag)
439
+ let wfx = WAVEFORMATEX {
440
+ wFormatTag: WAVE_FORMAT_PCM as u16,
441
+ nChannels: spec.channels as u16,
442
+ nSamplesPerSec: spec.sample_rate,
443
+ nAvgBytesPerSec: avg_bytes_per_sec,
444
+ nBlockAlign: block_align,
445
+ wBitsPerSample: spec.bits_per_sample as u16,
446
+ cbSize: 0,
447
+ };
448
+
449
+ // Flags matching original: 0xE8 = DSBCAPS_LOCSOFTWARE(0x08) |
450
+ // DSBCAPS_CTRLPAN(0x20) | DSBCAPS_CTRLFREQUENCY(0x40) | DSBCAPS_CTRLVOLUME(0x80)
451
+ let desc = DSBUFFERDESC {
452
+ dwSize: core::mem::size_of::<DSBUFFERDESC>() as u32,
453
+ dwFlags: 0xE8,
454
+ dwBufferBytes: buffer_bytes,
455
+ dwReserved: 0,
456
+ lpwfxFormat: &wfx as *const _ as *mut _,
457
+ ..Default::default()
458
+ };
459
+
460
+ let ds = ds_from_field(core::ptr::addr_of!(self.direct_sound));
461
+ let mut buf_opt: Option<IDirectSoundBuffer> = None;
462
+ if ds.CreateSoundBuffer(&desc, &mut buf_opt, None).is_err() {
463
+ self.close_file();
464
+ return false;
465
+ }
466
+ let ds_buf = match buf_opt {
467
+ Some(b) => b,
468
+ None => {
469
+ self.close_file();
470
+ return false;
471
+ }
472
+ };
473
+
474
+ // Store buffer as raw COM pointer (u32)
475
+ self.ds_buffer = core::mem::transmute_copy::<IDirectSoundBuffer, u32>(&ds_buf);
476
+ core::mem::forget(ds_buf); // Don't Release β€” we own the pointer now
477
+ self.is_initialized = 1;
478
+ self.flags |= streaming_flags::DS_BUFFER_ACTIVE;
479
+
480
+ // Reset playback state
481
+ self.write_cursor = 0;
482
+ self.bytes_played = 0;
483
+ self.file_bytes_read = 0;
484
+ self.last_play_cursor = 0;
485
+ self.bytes_played_snapshot = 0;
486
+ self.timer_time_total = 0;
487
+ self.avg_timer_time = 0;
488
+ self.timer_call_count = 0;
489
+
490
+ // Fill initial buffer and start playback
491
+ self.fill_and_start()
492
+ }
493
+
494
+ /// Fill the buffer with initial data and start playback + timer.
495
+ /// Port of FUN_005751B0.
496
+ unsafe fn fill_and_start(&mut self) -> bool {
497
+ if self.is_initialized == 0 || self.ds_buffer == 0 {
498
+ return false;
499
+ }
500
+
501
+ let buf = ds_buf_from_field(core::ptr::addr_of!(self.ds_buffer));
502
+
503
+ // Lock the entire buffer for initial fill
504
+ let mut ptr1: *mut core::ffi::c_void = core::ptr::null_mut();
505
+ let mut len1: u32 = 0;
506
+ let mut ptr2: *mut core::ffi::c_void = core::ptr::null_mut();
507
+ let mut len2: u32 = 0;
508
+
509
+ if buf
510
+ .Lock(
511
+ 0,
512
+ self.ds_buffer_size,
513
+ &mut ptr1,
514
+ &mut len1,
515
+ Some(&mut ptr2),
516
+ Some(&mut len2),
517
+ 0,
518
+ )
519
+ .is_err()
520
+ {
521
+ return false;
522
+ }
523
+
524
+ // Read from WAV file into buffer
525
+ let bytes_read = self.read_file_data(
526
+ core::slice::from_raw_parts_mut(ptr1 as *mut u8, len1 as usize),
527
+ );
528
+
529
+ // Zero-fill remainder if we didn't fill the whole buffer
530
+ if (bytes_read as u32) < len1 {
531
+ core::ptr::write_bytes(
532
+ (ptr1 as *mut u8).add(bytes_read),
533
+ 0,
534
+ (len1 as usize) - bytes_read,
535
+ );
536
+ }
537
+
538
+ let _ = buf.Unlock(ptr1 as *const _, len1, Some(ptr2 as *const _), len2);
539
+
540
+ self.write_cursor = bytes_read as u32;
541
+ if self.write_cursor >= self.ds_buffer_size {
542
+ self.write_cursor = 0;
543
+ }
544
+
545
+ // Start playback (looping the DS buffer β€” we manage the data ourselves)
546
+ // DSBPLAY_LOOPING = 1
547
+ let _ = buf.Play(0, 0, 1);
548
+
549
+ // Set up the global pointer for the timer callback
550
+ STREAMING_AUDIO_PTR.store(self as *mut Self as u32, Ordering::Release);
551
+
552
+ // Start periodic timer using timeSetEvent
553
+ let timer_id = time_set_event(
554
+ self.timer_delay_ms,
555
+ self.timer_resolution_ms,
556
+ streaming_timer_callback,
557
+ 0,
558
+ 1, // TIME_PERIODIC
559
+ );
560
+ self.timer_id = timer_id;
561
+
562
+ true
563
+ }
564
+
565
+ /// Stop streaming playback and release resources. Port of FUN_00574CA0 + FUN_005752D0.
566
+ pub unsafe fn stop(&mut self) {
567
+ // Kill timer first
568
+ if self.timer_id != 0 {
569
+ time_kill_event(self.timer_id);
570
+ self.timer_id = 0;
571
+ }
572
+
573
+ // Clear the global pointer
574
+ STREAMING_AUDIO_PTR.store(0, Ordering::Release);
575
+
576
+ // Stop and release DS buffer
577
+ if self.is_initialized != 0 {
578
+ if self.ds_buffer != 0 {
579
+ let buf = ds_buf_from_field(core::ptr::addr_of!(self.ds_buffer));
580
+ let _ = buf.Stop();
581
+ // Release COM reference via transmute + drop
582
+ let com: IDirectSoundBuffer = core::mem::transmute_copy(&self.ds_buffer);
583
+ drop(com);
584
+ self.ds_buffer = 0;
585
+ }
586
+ self.is_initialized = 0;
587
+ }
588
+
589
+ self.flags &= !streaming_flags::DS_BUFFER_ACTIVE;
590
+ self.close_file();
591
+
592
+ // Reset state
593
+ self.write_cursor = 0;
594
+ self.bytes_played = 0;
595
+ self.file_bytes_read = 0;
596
+ self.last_play_cursor = 0;
597
+ self.bytes_played_snapshot = 0;
598
+ }
599
+
600
+
601
+ /// Close the WAV file handle.
602
+ unsafe fn close_file(&mut self) {
603
+ if self.file_handle != 0 {
604
+ let _ = Box::from_raw(self.file_handle as *mut std::io::BufReader<std::fs::File>);
605
+ self.file_handle = 0;
606
+ }
607
+ }
608
+
609
+ /// Read PCM data from the WAV file. Reads until `buf` is full or EOF.
610
+ /// Returns number of bytes actually read.
611
+ unsafe fn read_file_data(&mut self, buf: &mut [u8]) -> usize {
612
+ if self.file_handle == 0 {
613
+ return 0;
614
+ }
615
+
616
+ let file = &mut *(self.file_handle as *mut std::io::BufReader<std::fs::File>);
617
+ let data_remaining = self.data_chunk.ck_size.saturating_sub(self.file_bytes_read);
618
+ let to_read = buf.len().min(data_remaining as usize);
619
+
620
+ if to_read == 0 {
621
+ return 0;
622
+ }
623
+
624
+ // Loop to fill the entire requested amount (read() may return partial).
625
+ let mut total = 0;
626
+ while total < to_read {
627
+ match file.read(&mut buf[total..to_read]) {
628
+ Ok(0) => break, // EOF
629
+ Ok(n) => total += n,
630
+ Err(_) => break,
631
+ }
632
+ }
633
+ self.file_bytes_read += total as u32;
634
+ total
635
+ }
636
+
637
+ /// Seek back to the start of the PCM data (for looping).
638
+ unsafe fn seek_to_data_start(&mut self) {
639
+ if self.file_handle == 0 {
640
+ return;
641
+ }
642
+ let file = &mut *(self.file_handle as *mut std::io::BufReader<std::fs::File>);
643
+ let _ = file.seek(SeekFrom::Start(self.data_chunk.data_offset as u64));
644
+ self.file_bytes_read = 0;
645
+ }
646
+
647
+ /// Timer callback handler β€” refills the DirectSound buffer.
648
+ /// Port of FUN_005753A0 (the heart of streaming).
649
+ unsafe fn timer_tick(&mut self) {
650
+ if self.is_initialized == 0 || self.ds_buffer == 0 {
651
+ return;
652
+ }
653
+
654
+ let buf = ds_buf_from_field(core::ptr::addr_of!(self.ds_buffer));
655
+
656
+ // Get current play position
657
+ let mut play_cursor: u32 = 0;
658
+ let mut _write_cursor: u32 = 0;
659
+ if buf
660
+ .GetCurrentPosition(Some(&mut play_cursor), Some(&mut _write_cursor))
661
+ .is_err()
662
+ {
663
+ return;
664
+ }
665
+
666
+ // Calculate how many bytes have been played since last tick
667
+ let played_delta = if play_cursor >= self.last_play_cursor {
668
+ play_cursor - self.last_play_cursor
669
+ } else {
670
+ // Wraparound
671
+ (self.ds_buffer_size - self.last_play_cursor) + play_cursor
672
+ };
673
+ self.last_play_cursor = play_cursor;
674
+ self.bytes_played += played_delta;
675
+ self.bytes_played_snapshot = self.bytes_played;
676
+ self.timer_call_count += 1;
677
+
678
+ // Check if we've reached end of stream (non-looping)
679
+ let is_looping = self.flags & streaming_flags::LOOP != 0;
680
+ if !is_looping
681
+ && self.bytes_played >= self.data_chunk.ck_size.saturating_sub(self.fade_out_bytes)
682
+ {
683
+ self.flags |= streaming_flags::STOPPING;
684
+ }
685
+
686
+ if self.flags & streaming_flags::STOPPING != 0 {
687
+ // Check if playback has finished
688
+ if self.bytes_played >= self.data_chunk.ck_size {
689
+ // Signal completion via callback if set
690
+ if self.completion_callback != 0 {
691
+ // Original spawns a thread; we just call it directly since
692
+ // the callback is typically Music::advance_track
693
+ let cb: unsafe extern "system" fn(u32) -> u32 =
694
+ core::mem::transmute(self.completion_callback as usize);
695
+ cb(self.completion_callback_data);
696
+ }
697
+ return;
698
+ }
699
+ }
700
+
701
+ // Calculate how much buffer space is available to fill
702
+ let available = if play_cursor >= self.write_cursor {
703
+ play_cursor - self.write_cursor
704
+ } else {
705
+ (self.ds_buffer_size - self.write_cursor) + play_cursor
706
+ };
707
+
708
+ if available == 0 {
709
+ return;
710
+ }
711
+
712
+ // Lock the buffer region from write_cursor for `available` bytes
713
+ let mut ptr1: *mut core::ffi::c_void = core::ptr::null_mut();
714
+ let mut len1: u32 = 0;
715
+ let mut ptr2: *mut core::ffi::c_void = core::ptr::null_mut();
716
+ let mut len2: u32 = 0;
717
+
718
+ if buf
719
+ .Lock(
720
+ self.write_cursor,
721
+ available,
722
+ &mut ptr1,
723
+ &mut len1,
724
+ Some(&mut ptr2),
725
+ Some(&mut len2),
726
+ 0,
727
+ )
728
+ .is_err()
729
+ {
730
+ return;
731
+ }
732
+
733
+ // Fill first region
734
+ let mut total_written: u32 = 0;
735
+ if len1 > 0 {
736
+ let slice = core::slice::from_raw_parts_mut(ptr1 as *mut u8, len1 as usize);
737
+ let read = self.read_file_data(slice);
738
+ if (read as u32) < len1 {
739
+ if is_looping {
740
+ // Loop: seek back and read more
741
+ self.seek_to_data_start();
742
+ self.bytes_played = 0;
743
+ self.last_play_cursor = play_cursor;
744
+ let remaining = &mut slice[read..];
745
+ let extra = self.read_file_data(remaining);
746
+ total_written += read as u32 + extra as u32;
747
+ // Zero any remaining
748
+ if read + extra < len1 as usize {
749
+ core::ptr::write_bytes(
750
+ (ptr1 as *mut u8).add(read + extra),
751
+ 0,
752
+ len1 as usize - read - extra,
753
+ );
754
+ }
755
+ } else {
756
+ // End of file: zero-fill remainder
757
+ core::ptr::write_bytes(
758
+ (ptr1 as *mut u8).add(read),
759
+ 0,
760
+ len1 as usize - read,
761
+ );
762
+ total_written += len1;
763
+ }
764
+ } else {
765
+ total_written += len1;
766
+ }
767
+ }
768
+
769
+ // Fill second region (wraparound)
770
+ if len2 > 0 {
771
+ let slice = core::slice::from_raw_parts_mut(ptr2 as *mut u8, len2 as usize);
772
+ let read = self.read_file_data(slice);
773
+ if (read as u32) < len2 {
774
+ if is_looping {
775
+ self.seek_to_data_start();
776
+ self.bytes_played = 0;
777
+ self.last_play_cursor = play_cursor;
778
+ let remaining = &mut slice[read..];
779
+ let extra = self.read_file_data(remaining);
780
+ total_written += read as u32 + extra as u32;
781
+ if read + extra < len2 as usize {
782
+ core::ptr::write_bytes(
783
+ (ptr2 as *mut u8).add(read + extra),
784
+ 0,
785
+ len2 as usize - read - extra,
786
+ );
787
+ }
788
+ } else {
789
+ core::ptr::write_bytes(
790
+ (ptr2 as *mut u8).add(read),
791
+ 0,
792
+ len2 as usize - read,
793
+ );
794
+ total_written += len2;
795
+ }
796
+ } else {
797
+ total_written += len2;
798
+ }
799
+ }
800
+
801
+ let _ = buf.Unlock(ptr1 as *const _, len1, Some(ptr2 as *const _), len2);
802
+
803
+ // Advance write cursor
804
+ self.write_cursor = (self.write_cursor + total_written) % self.ds_buffer_size;
805
+ }
806
+
807
+ /// Check if the DS buffer is currently playing.
808
+ pub unsafe fn is_playing(&self) -> bool {
809
+ if self.is_initialized == 0 || self.ds_buffer == 0 {
810
+ return false;
811
+ }
812
+ let buf = ds_buf_from_field(core::ptr::addr_of!(self.ds_buffer));
813
+ match buf.GetStatus() {
814
+ Ok(status) => status & 1 != 0, // DSBSTATUS_PLAYING
815
+ Err(_) => false,
816
+ }
817
+ }
818
+
819
+ /// Set the playback volume. Volume is in centibels (0 = full, -10000 = silent).
820
+ pub unsafe fn set_ds_volume(&self, volume_cb: i32) {
821
+ if self.is_initialized == 0 || self.ds_buffer == 0 {
822
+ return;
823
+ }
824
+ let buf = ds_buf_from_field(core::ptr::addr_of!(self.ds_buffer));
825
+ let _ = buf.SetVolume(volume_cb);
826
+ }
827
+ }
828
+
829
+ /// Timer callback invoked by timeSetEvent. Dispatches to the active StreamingAudio.
830
+ unsafe extern "system" fn streaming_timer_callback(
831
+ _timer_id: u32,
832
+ _msg: u32,
833
+ _user: u32,
834
+ _dw1: u32,
835
+ _dw2: u32,
836
+ ) {
837
+ let ptr = STREAMING_AUDIO_PTR.load(Ordering::Acquire);
838
+ if ptr != 0 {
839
+ let stream = &mut *(ptr as *mut StreamingAudio);
840
+ stream.timer_tick();
841
+ }
842
+ }
843
+
844
+ // ============================================================
845
+ // Music implementation
846
+ // ============================================================
847
+
848
+ impl Music {
849
+ /// Initialize a Music object. Port of constructor at 0x58BC10.
850
+ ///
851
+ /// `ds` is the IDirectSound instance (from DSSound).
852
+ /// `base_path` is the music directory path (e.g. "DATA\\Music").
853
+ pub unsafe fn init(
854
+ this: *mut Music,
855
+ ds: Ptr32,
856
+ base_path: &str,
857
+ ) {
858
+ // Zero the streaming sub-object
859
+ (*this).streaming.init();
860
+
861
+ // Set vtable β€” will be replaced by hook installation
862
+ (*this).vtable = core::ptr::null();
863
+
864
+ // Copy base path
865
+ let bytes = base_path.as_bytes();
866
+ let copy_len = bytes.len().min(255);
867
+ core::ptr::copy_nonoverlapping(
868
+ bytes.as_ptr(),
869
+ (*this).base_path.as_mut_ptr() as *mut u8,
870
+ copy_len,
871
+ );
872
+ (*this).base_path[copy_len] = 0;
873
+
874
+ (*this).playlist_count = 0;
875
+ (*this).current_track_index = 0;
876
+ (*this).direct_sound = ds;
877
+ }
878
+
879
+ /// Play a specific track by ID (1-13). Port of FUN_0058BD20.
880
+ ///
881
+ /// Maps the track ID to a WAV filename, builds the full path, and starts streaming.
882
+ pub unsafe fn play_track(this: *mut Music, track_id: u32, do_loop: bool) {
883
+ if track_id < 1 || track_id > 13 {
884
+ return;
885
+ }
886
+
887
+ let filename = MUSIC_TRACKS[(track_id - 1) as usize];
888
+
889
+ // Build full path: base_path/filename
890
+ let base = std::ffi::CStr::from_ptr((*this).base_path.as_ptr())
891
+ .to_str()
892
+ .unwrap_or("");
893
+ let full_path = format!("{}/{}", base, filename);
894
+
895
+ // Flags: 0x24 = AUTO_START | quality=2 (22kHz), plus LOOP if requested
896
+ let mut flags: u32 = 0x24;
897
+ if do_loop {
898
+ flags |= streaming_flags::LOOP;
899
+ }
900
+
901
+ let _ = (*this).streaming.open(&full_path, flags, (*this).direct_sound);
902
+ }
903
+
904
+ /// Start a specific music track (looping). Port of vtable[1] at 0x58BE70.
905
+ ///
906
+ /// Clears the playlist (count=0) and plays the given track with loop=true.
907
+ pub unsafe fn start_music(this: *mut Music, track_id: u32) {
908
+ (*this).playlist_count = 0;
909
+ Music::play_track(this, track_id, true);
910
+ }
911
+
912
+ /// Set a playlist and start the first track. Port of vtable[4] at 0x58BE90.
913
+ ///
914
+ /// Original passes loop=false (0) to PlayTrack β€” the track plays once,
915
+ /// then advance_track is called via the completion callback.
916
+ pub unsafe fn set_playlist(this: *mut Music, tracks: *const u32, count: u32) {
917
+ let count = count.min(32) as usize;
918
+ core::ptr::copy_nonoverlapping(tracks, (*this).playlist.as_mut_ptr(), count);
919
+ (*this).playlist_count = count as u32;
920
+ (*this).current_track_index = 0;
921
+
922
+ if count > 0 {
923
+ let first_track = (*this).playlist[0];
924
+ Music::play_track(this, first_track, false);
925
+ }
926
+ }
927
+
928
+ /// Advance to the next track in the playlist (wrapping). Port of vtable[1] at 0x58BEE0.
929
+ ///
930
+ /// The original checks if the DS buffer is still playing β€” if so, it returns
931
+ /// without advancing. The game calls this speculatively (e.g., each turn),
932
+ /// relying on the "still playing" guard to avoid premature track changes.
933
+ pub unsafe fn advance_track(this: *mut Music) {
934
+ if (*this).playlist_count == 0 {
935
+ return;
936
+ }
937
+ // Don't advance if the current track is still playing
938
+ if (*this).streaming.is_playing() {
939
+ return;
940
+ }
941
+ (*this).current_track_index =
942
+ ((*this).current_track_index + 1) % (*this).playlist_count;
943
+ let track_id = (*this).playlist[(*this).current_track_index as usize];
944
+ Music::play_track(this, track_id, false);
945
+ }
946
+
947
+ /// Set music volume. Port of vtable[2] at 0x58BF40.
948
+ pub unsafe fn set_volume(this: *mut Music, volume: Fixed) {
949
+ let clamped = volume.clamp(Fixed::ZERO, Fixed::ONE);
950
+ // Map to dB lookup table: index = volume * 64 / ONE
951
+ let idx = ((clamped.0 as u64 * 64) / Fixed::ONE.0 as u64) as usize;
952
+ let db = MUSIC_VOLUME_DB[idx.min(64)];
953
+ (*this).streaming.set_ds_volume(db);
954
+ }
955
+
956
+ /// Stop playback and clean up. Port of vtable[5] at 0x58BCE0.
957
+ pub unsafe fn stop_and_cleanup(this: *mut Music) {
958
+ (*this).streaming.stop();
959
+ }
960
+
961
+ /// Destructor. Port of 0x58BC80.
962
+ pub unsafe fn destructor(this: *mut Music) {
963
+ // Original does Sleep(1) for thread safety β€” we rely on atomic ptr clearing in stop()
964
+ (*this).streaming.stop();
965
+ }
966
+ }
967
+
968
+ // ============================================================
969
+ // Windows timer API bindings (timeSetEvent / timeKillEvent)
970
+ // ============================================================
971
+
972
+ #[link(name = "winmm")]
973
+ extern "system" {
974
+ fn timeSetEvent(
975
+ delay: u32,
976
+ resolution: u32,
977
+ callback: unsafe extern "system" fn(u32, u32, u32, u32, u32),
978
+ user: u32,
979
+ event_type: u32,
980
+ ) -> u32;
981
+
982
+ fn timeKillEvent(timer_id: u32) -> u32;
983
+ }
984
+
985
+ fn time_set_event(
986
+ delay: u32,
987
+ resolution: u32,
988
+ callback: unsafe extern "system" fn(u32, u32, u32, u32, u32),
989
+ user: u32,
990
+ event_type: u32,
991
+ ) -> u32 {
992
+ unsafe { timeSetEvent(delay, resolution, callback, user, event_type) }
993
+ }
994
+
995
+ fn time_kill_event(timer_id: u32) -> u32 {
996
+ unsafe { timeKillEvent(timer_id) }
997
  }
crates/openwa-core/src/audio/streaming.rs DELETED
@@ -1,16 +0,0 @@
1
- /// StreamingAudio β€” streaming audio playback subsystem (music).
2
- ///
3
- /// Constructor: FUN_0058BC10, usercall(ESI=this) + 2 stack params, RET 0x8.
4
- /// Size: 0x354 bytes.
5
- ///
6
- /// Created by GameEngine__InitHardware when speech is enabled
7
- /// (GameInfo+0xDAA4 != 0) and DSSound initialized successfully.
8
- /// Stored at GameSession+0xB4.
9
- ///
10
- /// OPAQUE: Internal layout not yet mapped.
11
- #[repr(C)]
12
- pub struct StreamingAudio {
13
- pub _data: [u8; 0x354],
14
- }
15
-
16
- const _: () = assert!(core::mem::size_of::<StreamingAudio>() == 0x354);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
crates/openwa-core/src/engine/game_session.rs CHANGED
@@ -1,4 +1,5 @@
1
  use crate::audio::dssound::DSSound;
 
2
  use crate::display::palette::Palette;
3
  use crate::engine::ddgame_wrapper::DDGameWrapper;
4
  use crate::input::keyboard::DDKeyboard;
@@ -81,8 +82,9 @@ pub struct GameSession {
81
  pub display: *mut u8,
82
  /// 0x0B0: `Palette*` β€” 0x28 bytes, vtable `Palette_vtable_Maybe`
83
  pub palette: *mut Palette,
84
- /// 0x0B4: streaming audio object β€” 0x354 bytes (`FUN_0058bc10`)
85
- pub streaming_audio: *mut u8,
 
86
  /// 0x0B8: input controller β€” 0x1800 bytes; null if `param_4 == 0` at init
87
  pub input_ctrl: *mut u8,
88
  /// 0x0BC: timing object β€” 0x30 bytes (`FUN_0053e950`)
 
1
  use crate::audio::dssound::DSSound;
2
+ use crate::audio::music::Music;
3
  use crate::display::palette::Palette;
4
  use crate::engine::ddgame_wrapper::DDGameWrapper;
5
  use crate::input::keyboard::DDKeyboard;
 
82
  pub display: *mut u8,
83
  /// 0x0B0: `Palette*` β€” 0x28 bytes, vtable `Palette_vtable_Maybe`
84
  pub palette: *mut Palette,
85
+ /// 0x0B4: Music object β€” 0x354 bytes (constructor 0x58BC10, vtable 0x66B3E0).
86
+ /// Combines playlist controller + embedded streaming audio engine.
87
+ pub streaming_audio: *mut Music,
88
  /// 0x0B8: input controller β€” 0x1800 bytes; null if `param_4 == 0` at init
89
  pub input_ctrl: *mut u8,
90
  /// 0x0BC: timing object β€” 0x30 bytes (`FUN_0053e950`)
crates/openwa-wormkit/src/replacements/hardware_init.rs CHANGED
@@ -45,7 +45,7 @@ use super::game_session;
45
  use crate::hook;
46
  use crate::log_line;
47
  use openwa_core::address::va;
48
- use openwa_core::audio::{DSSound, StreamingAudio};
49
  use openwa_core::display::{DDDisplay, DisplayBase, DisplayGfx, Palette};
50
  use openwa_core::engine::{DDGameWrapper, DDNetGameWrapper, GameInfo, GameSession, GameTimer};
51
  use openwa_core::input::{DDKeyboard, InputCtrl, InputCtrlVtable};
@@ -377,14 +377,14 @@ unsafe extern "cdecl" fn impl_init_hardware(
377
  // ── Streaming audio ───────────────────────────────────────────────────
378
  (*session).streaming_audio = core::ptr::null_mut();
379
  if !(*session).sound.is_null() && gi.speech_enabled != 0 {
380
- let stream = WABox::<StreamingAudio>::alloc(0x354, 0x334).leak();
381
  let ids = (*(*session).sound).direct_sound as *mut u8;
382
  call_streaming_audio_ctor(
383
  stream as *mut u8,
384
  ids,
385
  gi.streaming_audio_config.as_mut_ptr(),
386
  );
387
- (*session).streaming_audio = stream as *mut u8;
388
  }
389
  } else {
390
  // ── Headless / stats mode ─────────────────────────────────────────────
@@ -408,7 +408,7 @@ unsafe extern "cdecl" fn impl_init_hardware(
408
  (*session).sound,
409
  (*session).keyboard as *mut u8,
410
  (*session).palette,
411
- (*session).streaming_audio,
412
  (*session).input_ctrl,
413
  );
414
  (*session).ddgame_wrapper = wrapper;
 
45
  use crate::hook;
46
  use crate::log_line;
47
  use openwa_core::address::va;
48
+ use openwa_core::audio::{DSSound, Music};
49
  use openwa_core::display::{DDDisplay, DisplayBase, DisplayGfx, Palette};
50
  use openwa_core::engine::{DDGameWrapper, DDNetGameWrapper, GameInfo, GameSession, GameTimer};
51
  use openwa_core::input::{DDKeyboard, InputCtrl, InputCtrlVtable};
 
377
  // ── Streaming audio ───────────────────────────────────────────────────
378
  (*session).streaming_audio = core::ptr::null_mut();
379
  if !(*session).sound.is_null() && gi.speech_enabled != 0 {
380
+ let stream = WABox::<Music>::alloc(0x354, 0x334).leak();
381
  let ids = (*(*session).sound).direct_sound as *mut u8;
382
  call_streaming_audio_ctor(
383
  stream as *mut u8,
384
  ids,
385
  gi.streaming_audio_config.as_mut_ptr(),
386
  );
387
+ (*session).streaming_audio = stream;
388
  }
389
  } else {
390
  // ── Headless / stats mode ─────────────────────────────────────────────
 
408
  (*session).sound,
409
  (*session).keyboard as *mut u8,
410
  (*session).palette,
411
+ (*session).streaming_audio as *mut u8,
412
  (*session).input_ctrl,
413
  );
414
  (*session).ddgame_wrapper = wrapper;
crates/openwa-wormkit/src/replacements/mod.rs CHANGED
@@ -9,6 +9,7 @@ mod game_session;
9
  mod game_state_hooks;
10
  mod hardware_init;
11
  mod headless;
 
12
  mod render;
13
  mod replay;
14
  mod replay_test;
@@ -56,6 +57,7 @@ pub fn install_all() -> Result<(), String> {
56
  sprite::install()?;
57
  sound::install()?;
58
  speech::install()?;
 
59
  ddgame_init::install()?;
60
  game_state_hooks::install()?;
61
  replay::install()?;
 
9
  mod game_state_hooks;
10
  mod hardware_init;
11
  mod headless;
12
+ mod music;
13
  mod render;
14
  mod replay;
15
  mod replay_test;
 
57
  sprite::install()?;
58
  sound::install()?;
59
  speech::install()?;
60
+ music::install()?;
61
  ddgame_init::install()?;
62
  game_state_hooks::install()?;
63
  replay::install()?;
crates/openwa-wormkit/src/replacements/music.rs ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Music playback hooks.
2
+ //!
3
+ //! Replaces WA's Music vtable (0x66B3E0, 6 slots) with Rust implementations
4
+ //! from `openwa_core::audio::music`.
5
+ //!
6
+ //! The Music system handles background music during gameplay:
7
+ //! - Track selection from playlist (track IDs 1-13 β†’ WAV filenames)
8
+ //! - Streaming WAV playback via DirectSound double-buffering
9
+ //! - Volume control with dB lookup table
10
+ //! - Playlist cycling (advance to next track when current finishes)
11
+
12
+ use openwa_core::address::va;
13
+ use openwa_core::audio::music::{Music, MusicVtable};
14
+ use openwa_core::fixed::Fixed;
15
+ use openwa_core::rebase::rb;
16
+ use openwa_core::wa_alloc::wa_free;
17
+
18
+ use crate::hook;
19
+ use crate::log_line;
20
+
21
+ // ── Music vtable replacement methods ──
22
+
23
+ /// Slot 0: scalar_deleting_dtor β€” calls destructor, optionally frees.
24
+ unsafe extern "thiscall" fn hook_scalar_deleting_dtor(this: *mut Music, flags: u32) {
25
+ let _ = log_line(&format!(
26
+ "[Music] scalar_deleting_dtor: this=0x{:08X} flags={}",
27
+ this as u32, flags
28
+ ));
29
+ Music::destructor(this);
30
+ if flags & 1 != 0 {
31
+ // Free via WA's operator delete (0x5C0AE8) β€” matches WABox allocation.
32
+ wa_free(this as *mut u8);
33
+ }
34
+ }
35
+
36
+ /// Slot 1: start_music β€” start a specific track (looping).
37
+ unsafe extern "thiscall" fn hook_start_music(this: *mut Music, track_id: u32) {
38
+ let _ = log_line(&format!("[Music] start_music: track_id={}", track_id));
39
+ Music::start_music(this, track_id);
40
+ }
41
+
42
+ /// Slot 2: set_playlist β€” copy track IDs and start first track.
43
+ unsafe extern "thiscall" fn hook_set_playlist(this: *mut Music, tracks: *const u32, count: u32) {
44
+ let _ = log_line(&format!("[Music] set_playlist: count={}", count));
45
+ Music::set_playlist(this, tracks, count);
46
+ }
47
+
48
+ /// Slot 3: advance_track β€” next track in playlist (wrapping).
49
+ unsafe extern "thiscall" fn hook_advance_track(this: *mut Music) {
50
+ let _ = log_line("[Music] advance_track");
51
+ Music::advance_track(this);
52
+ }
53
+
54
+ /// Slot 4: set_volume β€” set music volume (Fixed 16.16).
55
+ unsafe extern "thiscall" fn hook_set_volume(this: *mut Music, volume: Fixed) {
56
+ Music::set_volume(this, volume);
57
+ }
58
+
59
+ /// Slot 5: stop_and_cleanup β€” stop playback.
60
+ unsafe extern "thiscall" fn hook_stop_and_cleanup(this: *mut Music) {
61
+ let _ = log_line("[Music] stop_and_cleanup");
62
+ Music::stop_and_cleanup(this);
63
+ }
64
+
65
+ // ── Music constructor hook ──
66
+
67
+ /// Hook for Music::Constructor (0x58BC10).
68
+ /// usercall(ESI=this) + stack(IDirectSound*, path_ptr), RET 0x8.
69
+ #[unsafe(naked)]
70
+ unsafe extern "C" fn trampoline_music_constructor() {
71
+ core::arch::naked_asm!(
72
+ "push [esp+8]", // path_ptr
73
+ "push [esp+8]", // ids (was +4, shifted +4)
74
+ "push esi", // this
75
+ "call {f}",
76
+ "add esp, 12",
77
+ "ret 0x8",
78
+ f = sym music_constructor_cdecl,
79
+ );
80
+ }
81
+
82
+ unsafe extern "cdecl" fn music_constructor_cdecl(this: *mut Music, ids: u32, path_ptr: *const u8) {
83
+ let path = std::ffi::CStr::from_ptr(path_ptr as *const i8)
84
+ .to_str()
85
+ .unwrap_or("DATA\\Music");
86
+ let _ = log_line(&format!(
87
+ "[Music] Constructor: this=0x{:08X} ids=0x{:08X} path=\"{}\"",
88
+ this as u32, ids, path
89
+ ));
90
+ Music::init(this, ids, path);
91
+ // Set vtable to the patched version (points to our hooked functions)
92
+ (*this).vtable = rb(va::MUSIC_VTABLE) as *const MusicVtable;
93
+ }
94
+
95
+ // ── Hook installation ──
96
+
97
+ pub fn install() -> Result<(), String> {
98
+ unsafe {
99
+ // Patch the Music vtable using vtable_replace!
100
+ use openwa_core::audio::music::MusicVtable;
101
+ use openwa_core::vtable_replace;
102
+
103
+ vtable_replace!(MusicVtable, va::MUSIC_VTABLE, {
104
+ scalar_deleting_dtor => hook_scalar_deleting_dtor,
105
+ advance_track => hook_advance_track,
106
+ set_volume => hook_set_volume,
107
+ start_music => hook_start_music,
108
+ set_playlist => hook_set_playlist,
109
+ stop_and_cleanup => hook_stop_and_cleanup,
110
+ })?;
111
+
112
+ let _ = log_line("[Music] vtable: patched 6/6 slots with Rust");
113
+
114
+ // Hook the Music constructor
115
+ let _ = hook::install(
116
+ "Music_Constructor",
117
+ va::MUSIC_CONSTRUCTOR,
118
+ trampoline_music_constructor as *const (),
119
+ )?;
120
+
121
+ // Trap the internal destructor (only called from our scalar_deleting_dtor)
122
+ hook::install_trap!("Music_Destructor", va::MUSIC_DESTRUCTOR);
123
+ // Trap PlayTrack (only called from our vtable methods)
124
+ hook::install_trap!("Music_PlayTrack", va::MUSIC_PLAY_TRACK);
125
+
126
+ // NOTE: StreamingAudio functions have WA callers outside the Music system
127
+ // (GameEngine::Shutdown calls Stop, Init is used generically).
128
+ // These can only be trapped once the shutdown path is also ported.
129
+ }
130
+
131
+ Ok(())
132
+ }
crates/openwa-wormkit/src/replacements/sound.rs CHANGED
@@ -130,6 +130,71 @@ unsafe extern "fastcall" fn hook_play_sound_pooled_direct(
130
  sound_ops::play_sound_pooled_direct(task, slot, priority, volume)
131
  }
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  // ── Hook installation ──
134
 
135
  pub fn install() -> Result<(), String> {
@@ -180,6 +245,30 @@ pub fn install() -> Result<(), String> {
180
  "LoadAndPlayStreamingPositional",
181
  va::LOAD_AND_PLAY_STREAMING_POSITIONAL
182
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  }
184
 
185
  Ok(())
 
130
  sound_ops::play_sound_pooled_direct(task, slot, priority, volume)
131
  }
132
 
133
+ // ── PlayWormSound (0x5150D0): usercall(EDI=worm) + stack(sound_id, volume), RET 0x8 ──
134
+
135
+ #[unsafe(naked)]
136
+ unsafe extern "C" fn trampoline_play_worm_sound() {
137
+ core::arch::naked_asm!(
138
+ "push [esp+8]", // volume
139
+ "push [esp+8]", // sound_id (was +4, shifted +4)
140
+ "push edi", // worm
141
+ "call {f}",
142
+ "add esp, 12",
143
+ "ret 0x8",
144
+ f = sym play_worm_sound_cdecl,
145
+ );
146
+ }
147
+
148
+ unsafe extern "cdecl" fn play_worm_sound_cdecl(
149
+ worm: *mut CTaskWorm,
150
+ sound_id: SoundId,
151
+ volume: Fixed,
152
+ ) {
153
+ sound_ops::play_worm_sound(worm, sound_id, volume);
154
+ }
155
+
156
+ // ── StopWormSound (0x515180): usercall(ESI=worm), plain RET ──
157
+
158
+ #[unsafe(naked)]
159
+ unsafe extern "C" fn trampoline_stop_worm_sound() {
160
+ core::arch::naked_asm!(
161
+ "push esi",
162
+ "call {f}",
163
+ "add esp, 4",
164
+ "ret",
165
+ f = sym stop_worm_sound_cdecl,
166
+ );
167
+ }
168
+
169
+ unsafe extern "cdecl" fn stop_worm_sound_cdecl(worm: *mut CTaskWorm) {
170
+ sound_ops::stop_worm_sound(worm);
171
+ }
172
+
173
+ // ── LoadAndPlayStreaming (0x546C20): usercall(EAX=task, ESI=emitter) + stack(sound_id, flags, volume), RET 0xC ──
174
+
175
+ #[unsafe(naked)]
176
+ unsafe extern "C" fn trampoline_load_and_play_streaming() {
177
+ core::arch::naked_asm!(
178
+ "push [esp+12]", // volume
179
+ "push [esp+12]", // flags (was +8, shifted +4)
180
+ "push [esp+12]", // sound_id (was +4, shifted +8)
181
+ "push eax", // task (EAX)
182
+ "call {f}",
183
+ "add esp, 16",
184
+ "ret 0xC",
185
+ f = sym load_and_play_streaming_cdecl,
186
+ );
187
+ }
188
+
189
+ unsafe extern "cdecl" fn load_and_play_streaming_cdecl(
190
+ task: *mut CGameTask,
191
+ sound_id: SoundId,
192
+ flags: u32,
193
+ volume: Fixed,
194
+ ) -> i32 {
195
+ sound_ops::load_and_play_streaming(task, sound_id, flags, volume)
196
+ }
197
+
198
  // ── Hook installation ──
199
 
200
  pub fn install() -> Result<(), String> {
 
245
  "LoadAndPlayStreamingPositional",
246
  va::LOAD_AND_PLAY_STREAMING_POSITIONAL
247
  );
248
+
249
+ // Hook PlayWormSound + StopWormSound β€” these have WA callers beyond weapon_release.rs
250
+ let _ = hook::install(
251
+ "PlayWormSound",
252
+ va::PLAY_WORM_SOUND,
253
+ trampoline_play_worm_sound as *const (),
254
+ )?;
255
+ let _ = hook::install(
256
+ "StopWormSound",
257
+ va::STOP_WORM_SOUND,
258
+ trampoline_stop_worm_sound as *const (),
259
+ )?;
260
+
261
+ // NOTE: Sound sub-functions (Distance3D_Attenuation, ActiveSoundTable::stop_sound,
262
+ // RecordActiveSound, DispatchLocalSound, ComputeDistanceParams) all have WA callers
263
+ // through unported paths (PlayLocalNoEmitter, PlayLocalWithEmitter) that are
264
+ // exercised in headful mode. Cannot trap until those entry points are also hooked.
265
+
266
+ // Hook LoadAndPlayStreaming β€” has many WA callers (CTaskMissile, etc.)
267
+ let _ = hook::install(
268
+ "LoadAndPlayStreaming",
269
+ va::LOAD_AND_PLAY_STREAMING,
270
+ trampoline_load_and_play_streaming as *const (),
271
+ )?;
272
  }
273
 
274
  Ok(())