Implement create_ddgame() covering: allocation, zero-fill, InitFields,
param storage, version flags, GfxHandler creation, display palette,
color entries, secondary GfxDir, audio init, PCLandscape bridge,
TaskStateMachine, 8x SpriteRegion bridges, landscape value.
Not yet wired in — game_session.rs still calls the original DDGame
constructor via bridge. create_ddgame() will replace it once the
remaining sections (arrow sprites, coord list, gradients, weapons,
HUD, display finalization) are ported.
15 new sub-function addresses, SpriteRegion fastcall bridge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace DDGame__Constructor with Rust create_ddgame()
Incremental approach: decompile and map fields, create DDGame::new()
with bridges for sub-calls, then eliminate bridges one at a time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chore: re-enable scheme hooks after engine speed fix
The constant is now verified correct (runtime-dumped from ROM). Use it
directly rather than reading from WA.exe memory — we're replacing the
executable, not depending on it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chore: remove unused EXTENDED_OPTIONS_DEFAULTS import from scheme hooks
The hand-transcribed EXTENDED_OPTIONS_DEFAULTS constant had bytes
shifted from offset 0x13 onwards, causing game_engine_speed to default
to 0x01018080 (257x) instead of 0x00010000 (1.0x). This made all
.wsc scheme files play at unlocked frame rate.
Two fixes:
1. write_scheme_to_dest now reads defaults from WA's ROM at runtime
via rb(SCHEME_V3_DEFAULTS), making it impossible to diverge
2. Replaced the constant with byte-exact ROM dump (verified at runtime)
Also accept variable-length V3 scheme files (payload between 0x124
and 0x192 bytes), padding missing extended options with defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add CTaskCrate struct, raw field debug UI, new task fields
Since all 24 DSSound vtable slots are Rust, skip the vtable indirection
and transmute dance — call play_sound/play_sound_pooled as regular Rust
function calls from the dispatch hooks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: add typed fields for sound dispatch, remove pointer arithmetic
Add DDGame.frame_counter (0x5CC) and GameInfo sound_start_frame
(0xF344) / sound_mute (0xF348) as proper struct fields. Use typed
pointers in all hook signatures instead of u32 casts — 32-bit target
means pointers and u32 are interchangeable in ABI.
Also remove DSSound vtable from validation (all 24 slots are Rust).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: convert sound dispatch helpers and fix ActiveSoundEntry fields
Remove all per-sound debug log_line calls from core_play_sound and
load_wav. Keep error-path returns but without logging (matching
original's silent failure behavior).
Remove unused DSSound import from validation module.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: correct play_sound param order and descriptor state
Careful stack offset tracing reveals the 5 params to play_sound are:
(flags_and_slot, priority, frequency, volume, pan) — frequency and
volume are separate params, not a single dual-purpose value.
Fix descriptor state: flags_b stores flags_and_slot (identifies which
sound is playing), not -1. flags_a is -1 (marks buffer for auto-release
when finished).
Remove naked asm trampolines — pure thiscall confirmed.
All 24/24 DSSound vtable slots Rust with correct params.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement core_play_sound and allocate_channel in pure Rust:
- Channel allocator: finds free descriptor or evicts lowest-priority
- Buffer duplication via IDirectSound::DuplicateSoundBuffer
- Volume from dB lookup table with master volume scaling
- Frequency from pitch * base_freq (fixed-point 16.16)
- Pan from reversed dB table lookup
- Playback via IDirectSoundBuffer::Play
Slots 3 and 4 are pure thiscall with 5 stack params (RET 0x14), not
usercall — ESI/EBX are not implicit params (confirmed via logging that
showed garbage in those registers).
Param mapping: (flags_and_slot, priority, pitch, pan, unused).
Pitch serves dual purpose: clamped to 0x10000 for volume scaling,
unclamped for frequency multiplication.
All 24/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace DSSound slot 0 (destructor) with Rust
Slot 2: frequency scaling — adjusts playback frequency on all playing
channels by multiplying channel_freq * param2 / param1. Clamped to
200000 Hz. Rename _field_0c → channel_freq.
Slot 5: stereo panning — uses VOLUME_DB_TABLE read backwards for
pan-to-dB conversion. Positive pan negates the dB value.
21/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pan uses the same VOLUME_DB_TABLE read backwards — index 0 from the end
gives near-0 dB (no attenuation), larger indices give more attenuation.
Positive pan negates the dB value for right-channel bias.
20/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add VOLUME_DB_TABLE (64 i16 entries from WA .rdata 0x6A6A60) and
volume_to_db() helper for fixed-point → dB attenuation conversion.
Slot 7: iterates channel descs, computes combined master*channel volume,
calls IDirectSoundBuffer::SetVolume with dB from lookup table.
Slot 8: per-channel volume with flag-dependent master scaling.
Rename _field_10 → channel_volume (per-channel volume, confirmed from
slot 8 assembly storing the clamped value there).
19/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slot 9 was named is_channel_playing but actually implements
is_channel_finished: the original inverts DSBSTATUS_PLAYING via
NOT+AND (playing→0, stopped→1). Our code returned 1 for playing,
causing callers to think channels were free and immediately reuse
them — killing all sound playback.
Rename to is_channel_finished with correct semantics. Re-enable
slots 1 (update_channels), 10 (stop_channel), 11 (release_finished)
which all work correctly with the fixed descriptor layout.
17/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix SpeechSlotTable::get/set to use line_id directly as array index
(1-based, slot 0 per team unused) matching WA's assembly. Previous
code subtracted 1, causing all voice lines to map to wrong slots.
Add F7 hotkey for runtime DSSound channel descriptor dumps to verify
field layouts. Runtime dumps confirmed ds_buffer at descriptor +0x14.
Add slot 14 (sub_destructor) to Rust. Slots 1, 9, 10, 11 remain
disabled — slot 9's return value semantics need caller analysis
(always returning 1 breaks sound scheduling).
13/24 DSSound vtable slots enabled in Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SpeechSlotTable::get/set subtracted 1 from line_id, but WA uses the
raw ID directly as the array index (1-based, slot 0 unused per team) —
same pattern as channel_slots. This caused voice lines to be stored and
looked up at the wrong index, playing wrong speech for each event.
Add DSSound vtable slot 14 (sub_destructor) in Rust — sets secondary
vtable and optionally frees.
Add ChannelDescriptor _field_00 at +0x00 and shift ds_buffer to +0x14
to match original code's piVar4 indexing. Slots 1, 9, 10, 11 remain
disabled pending runtime verification of the full descriptor layout.
13/24 DSSound vtable slots enabled in Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slot 14 (sub_destructor): sets secondary vtable, optionally frees.
Slots 1 and 11 implemented but disabled pending investigation —
update_channels crashes when iterating channel descriptors, likely
due to COM pointer state mismatch during frame updates.
16/24 DSSound vtable slots now Rust (14 enabled + 2 disabled).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace DSSound vtable slots 9 (is_channel_playing) and 10 (stop_channel)
Add typed ChannelDescriptor::buffer() and take_buffer() helpers for
safe IDirectSoundBuffer access. Remove cfg(target_os) guards — this
project is 32-bit Windows only.
Slot 9: checks IDirectSoundBuffer::GetStatus via typed helper.
Slot 10: stops buffer via take_buffer() + Drop, resets descriptor,
returns pool index to free list, removes from used list.
14/24 DSSound vtable slots now Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: pure Rust DDGameWrapper__LoadSpeechWAV, typed struct fields
Replace the naked asm bridge to DDGameWrapper__LoadSpeechWAV (0x571530)
with a pure Rust implementation that uses typed struct field access.
Add speech_name_table (360 x 0x40-byte entries at +0x14E8) and
speech_name_count (+0x6EE8) fields to DDGameWrapper, eliminating all
pointer arithmetic from the speech WAV loading path.
Rename address constant DSSOUND_LOAD_SPEECH_WAV to
DDGAMEWRAPPER_LOAD_SPEECH_WAV and update Ghidra label to match
(function operates on DDGameWrapper, not DSSound).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chore: update Cargo.lock for hound and windows crate additions
WA's load_wav returns 1 on success and 0 on failure. Our Rust version
had them inverted, causing DSSound_LoadSpeechWAV to think every load
failed. This triggered fallback paths that assigned voice lines to
wrong slots — "ohdear" played on worm hurt instead of "oof/ow".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WA uses slot indices directly as array indices (1-based, slot 0 unused).
Our is_slot_loaded and load_wav incorrectly subtracted 1, causing all
sounds to be stored and looked up at the wrong slot — resulting in
scrambled sound effects and voice lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: move log_line to openwa-core for shared logging
Move log_line() from openwa-wormkit to openwa_core::log so that core
types (e.g. DSSound::load_wav) can emit debug logging. openwa-wormkit
re-exports via pub use.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace 12/24 DSSound vtable slots with Rust
Add typed DSSoundVtable struct with all 24 virtual method signatures,
named from decompilation analysis (update_channels, play_sound,
set_master_volume, load_wav, stop_channel, etc.).
Refine ChannelDescriptor fields from vtable method analysis: flags_a/b,
volume_num/val, ds_buffer, pool_index. Rename buffer pool fields to
reflect free-list/used-list semantics. Rename _field_bb0 to
total_bytes_loaded (incremented by load_wav).
Implement is_slot_loaded (slot 13) in Rust and patch it into WA's
DSSound vtable via patch_vtable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: pure Rust DSSound::new() and typed COM init via windows crate
Replace DSSound constructor FFI bridge (call_dssound_ctor) with pure
Rust DSSound::new() that initializes all fields: 8 channel descriptors,
500 channel slots, 64-entry buffer pool, volume, and status flags.
Replace DSSOUND_INIT_BUFFERS FFI bridge (call_dssound_init_buffers) with
typed windows crate COM calls: IDirectSound::SetCooperativeLevel,
CreateSoundBuffer(DSBCAPS_PRIMARYBUFFER), IDirectSoundBuffer::GetCaps.
Expand DSSound struct from opaque blob to named fields matching the
constructor analysis (channel_descs, channel_slots, buffer_pool, etc.).
Remove dead naked asm bridges and statics (DSSOUND_CTOR_ADDR,
DSSOUND_INIT_BUF_ADDR, DSSOUND_INIT_EAX, DSSOUND_INIT_SAVED_RET).
Trap DSSound__Constructor and DSSOUND_INIT_BUFFERS -- both had
GameEngine__InitHardware as their only caller (now Rust).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: use windows crate for DirectSound calls in create_dssound
Replace raw transmuted COM calls with typed windows crate APIs:
- DirectSoundCreate via windows::Win32::Media::Audio::DirectSound
- IDirectSoundBuffer::Play with typed method call and DSBPLAY_LOOPING
Eliminates manual vtable slot indexing and unsafe function pointer
transmutation for the DirectSound initialization path.
Add windows crate (0.62) dependency with Win32_Media_Audio_DirectSound
feature.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: reorganize openwa-core into subdirectory modules
Clear ERRORLOG.TXT before each test run, copy to testdata/logs/ when
present after a run, and report crash with first 5 lines before any
other summary output. Exit code 1 on crash detection.
Crash check runs before the log diff/validation summary to ensure it
is always visible even when the diff would also fail.
Update CLAUDE.md and replay-test skill docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: pure Rust DisplayBase::new_headless() with in-place vtable patching
Replace the FFI-based headless DisplayBase construction with pure Rust:
new_headless() allocates and initializes all fields (slot_table, sentinel,
sprite cache chain: 0x28 wrapper -> 0x3C buffer ctrl -> 0x80020 buffer)
without calling WA's constructor.
Must use WA's vtable pointer (not our own copy) because WA checks vtable
address identity. Patch WA's headless vtable destructor slot in-place to
a Rust version that correctly frees the Rust-allocated sprite cache.
Extract patch_vtable() utility to openwa-core::vtable for reuse.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename DDDisplayBase -> DisplayBase across codebase. Define DisplayBase
struct with named fields from constructor analysis, DisplayBaseVtable
with 32 typed slots, SpriteCollection sub-object, and static
HEADLESS_VTABLE with all no-op stubs.
Patch WA.exe's primary DisplayBase vtable (0x6645F8) at init time:
replace 22 _purecall slots with safe Rust no-ops, benefiting both
headless (DisplayBase) and normal (DisplayGfx) modes.
Add wa_free() to wa_alloc module alongside wa_malloc().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add -Headless flag to replay-test.ps1 that uses WA's /getlog mode for
pure-simulation replay testing. Compares output log byte-for-byte
against bots_expected.log to verify replay determinism.
Update replay-test skill docs to cover both modes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add headless mode with launcher↔DLL event synchronization
Add OPENWA_HEADLESS=1 mode that hooks user32!MessageBoxA/W via MinHook
to auto-dismiss all message boxes, enabling fully unattended /getlog
replay processing.
Add named event synchronization (OpenWA_HooksReady) between the launcher
and DLL: launcher creates the event before injection, DLL signals it
after install_all() completes, launcher waits before ResumeThread. This
eliminates the race between hook installation and main thread startup.
Add bots_expected.log as golden reference for headless replay log diff
testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GameStats is actually the base class of the display hierarchy —
DisplayGfx__Constructor calls it as its base ctor. Rename to
DDDisplayBase to reflect the inheritance relationship.
Rename GameSession.display_gfx → display since it holds a polymorphic
base class pointer (DisplayGfx in normal, DDDisplayBase in headless).
Update Ghidra: DDDisplayBase__Constructor, DDDisplayBase__Destructor,
DDDisplayBase__Vtable (0x6645F8), DDDisplayBase__HeadlessVtable (0x66A0F8).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: extract subsystem creation into construct() methods
Move transmute-heavy subsystem creation out of impl_init_hardware into
dedicated methods: DisplayGfx::construct(), GameStats::construct(),
DDNetGameWrapper::construct(), DDKeyboard::poll(), and a local
create_dssound() function for the 33-line COM init sequence.
impl_init_hardware now reads as a high-level orchestrator with only
the DDDisplay::Init retry loop and MapWindowPoints IAT call inline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: type GameInfo pointers, merge ctor_impl, fix warnings
- Type GAME_INFO static and construct_ddgame_wrapper param as *mut GameInfo
- Merge ctor_impl into construct_ddgame_wrapper (single function now)
- Fix dangerous_implicit_autorefs in config.rs (use let gi = &mut *)
- Remove unused GameInfo import in config.rs and MissileType in validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add install_trap! macro for fully-converted functions
Add install_trap! macro that replaces a WA function with a panic trap,
verifying that no WA.exe code calls it. Use for functions where all
callers have been replaced with Rust.
Convert DDGameWrapper__Constructor to trapped: only called from
impl_init_hardware (Rust). Remove dead naked trampoline and SAVED_RET
static.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: add WABox::from_value and struct constructors for DDKeyboard/Palette
Add WABox::from_value(val) to allocate on WA's heap and write a
fully-initialized value in one step. Add DDKeyboard::new() and
Palette::new() that return Self with all fields set, replacing
multi-line alloc+field-write sequences with one-liners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add vcall! macro and typed vtable structs for Palette and InputCtrl
- Extend GameInfo to 0xF91C with named fields (display_width/height,
display_flags, headless_mode, speech_enabled, etc.)
- Add 6 placeholder structs: DisplayGfx, InputCtrl, GameTimer,
StreamingAudio, DDNetGameWrapper, GameStats
- Expand DDKeyboard with construction fields and 0x33C size assert
- Fix DSSound size to 0xBE0 and rename COM fields (direct_sound,
primary_buffer, init_success)
- Type GameSession pointers: DDGameWrapper, DDKeyboard, DSSound, Palette
- Fix DDKeyboard+0x4: must store ADDRESS of GameInfo+0xF918, not value
- Fix DSSound init: check Play() result, not outer DirectSoundCreate hr
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace GameEngine__InitHardware with full Rust reimplementation
Port DDGameWrapper__Constructor (0x56DEF0) from passthrough hook to complete
Rust replacement. The original uses an implicit EDI=game_info register param
alongside 7 stdcall stack params; a naked trampoline captures EDI before any
Rust code can clobber it.
Key implementation details:
- Naked entry trampoline saves EDI→GAME_INFO, pops caller ret addr, calls cdecl
ctor_impl, then does stdcall callee-cleanup (addl $0x1C; jmpl *SAVED_RET)
- call_init_replay bridge: naked stdcall that loads EAX/ESI and calls the
usercall DDGameWrapper__InitReplay (EAX=game_info, ESI=this, plain RET)
- ddgame_constructor_call bridge: loads implicit ECX=network from static then
tail-jumps to DDGame__Constructor; its RET 0x24 returns directly to ctor_impl
- ctor_impl uses typed fields: DDGameWrapper, DDDisplay, DSSound, Palette,
GameSession (for timer_obj/net_game by name instead of raw offset arithmetic)
Also adds DDGAMEWRAPPER_INIT_REPLAY and DDGAME_INIT_GAME_STATE to address.rs.
Co-Authored-By: C…