The "MFC result-return convention" (ECX=this, EDX=result_buf_ptr) is
actually __fastcall with an explicit FastcallResult parameter. This
eliminates all inline asm (call_method/call_method_1) and lets the
compiler generate correct calling code via the bind macro.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port FlushRender (vtable slot 26) with two simplifications:
- unlock_surface_write call eliminated (it's a no-op that writes a
success code to a discarded result buffer)
- get_renderer_surface called via DDDisplayWrapper::call_method()
Add call_method/call_method_1 helpers on DDDisplayWrapper for calling
its vtable methods which use an MFC-style result-return convention
(ECX=this, EDX=result_buf_ptr). These are temporary bridges — the
proper fix is to investigate whether this is MSVC's hidden return
pointer for struct-returning thiscall methods and type accordingly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: enable crash detection for headful replay tests
Two fixes for headful crash reporting:
1. Pass /silentcrash flag in replay test mode (not just headless).
This prevents WA from spawning a separate error dialog process,
allowing the crashing process to write ERRORLOG.TXT and exit.
2. Enable file isolation (CreateFileA hook) for replay tests, not
just headless. This redirects ERRORLOG.TXT to the per-test run
directory via OPENWA_ERRORLOG_PATH, and also isolates temp files.
Previously headful crashes showed as FAIL (gameplay markers missing)
with no crash details. Now they correctly show as CRASH with full
ERRORLOG.TXT content and exception info.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DDDisplay::GetDimensions to Rust, hook via vtable_replace
First DDDisplay vtable method ported to Rust. Reads display_width/height
from DisplayBase and writes to output pointers. Hooked via vtable_replace
on slot 1 of the DDDisplay vtable (0x66A218).
FlushRender (slot 26) attempted but reverted — DDDisplayWrapper methods
use a non-standard fastcall convention (EDX = result pointer) that the
thiscall bind wrappers can't handle. Documented for future work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: typed BitGrid variants with alloc constructors
Move BitGrid from task::bit_grid to display::bitgrid as its canonical
home. Delete the old module. Rename fields to match known semantics:
_unused_04 → external_buffer, _unused_1c/20 → clip_left/clip_top,
width_dup/height_dup → clip_right/clip_bottom.
Convert bit_grid_init to BitGrid::init with *mut BitGrid parameter.
Replace magic vtable address with va::BIT_GRID_VTABLE constant.
Remove redundant double memset. Remove dead TaskStateMachine class.
Rename DDGame::display_gfx → display_bitgrid (it's a BitGrid, not
DisplayGfx). Rename Sprite::display_gfx → bitgrid_vtable. Clean up
tsm variable names and stale re-exports throughout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: type BitGrid display-layer vtable (0x664144, 8 pixel ops)
Add BitGridDisplayVtable with 8 typed slots: fill_rect, fill_hline,
fill_vline, destructor, get/put_pixel (clipped and unchecked variants).
Consolidate with existing task::bit_grid::BitGrid — the display module
re-exports the same struct and adds the vtable definition. Replace old
DISPLAYGFX_VTABLE / DISPLAY_GFX_VTABLE constants with the macro-generated
BIT_GRID_DISPLAY_VTABLE.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: map DisplayGfx internal fields and expose DisplayBase dimensions
Map ~30 named fields across the 0x24E28-byte DisplayGfx struct, replacing
the single opaque blob. Key regions: camera offset, display dimensions,
clip rect, HWND, 3 layer object pointers, palette entries (256x4),
sprite table (1024 entries), two 64KB color mixing LUTs (additive and
gamma-corrected blend), and blend mode flag.
Also expose display_width/height and clip rect fields in DisplayBase
(previously hidden in _pad_3540).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: type DDDisplayWrapper and CompatRenderer structs and vtables
Type all non-stub DDDisplay vtable slots (35/38, up from 8). Slots use
Fixed for 16.16 fixed-point coordinates where the function does camera
offset multiplication. Rename 24 functions in Ghidra with signatures and
DDDisplay struct type. Create DDDisplay struct in Ghidra (0x24E28 bytes).
Key findings from slot 19 (blit_sprite) investigation:
- Called from LoadingProgressTick via intermediate vtable dispatch
- EBX/EDI "register params" are LTCG artifacts: the compiler proved
display width/height survive in callee-saved registers from a prior
get_dimensions call and reads them directly as clip bounds.
Add log_stack_trace() to hook.rs and wire it into install_trap! macro
so traps log a symbolicated EBP-chain call stack before panicking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: rename openwa-wormkit crate to openwa-dll, output openwa.dll
The project hasn't been a WormKit DLL for a long time — it requires a
custom launcher. Rename the crate to openwa-dll with output openwa.dll
to match the rest of the naming convention (openwa-core, openwa-launcher).
Updates all references across Cargo.toml, Rust sources, PowerShell scripts,
CLAUDE.md, skill files, and docs. Replaces stale "wormkit hook/shim"
comments with accurate descriptions. Three remaining "WormKit" references
are legitimate (refer to the actual WA modding framework).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the usercall bridge to 0x570F30 with pure Rust that loads 28
bitmap fonts, 23 font extensions, and sets the font palette via typed
DDDisplay vtable calls. Adds three new DDDisplay vtable slots (load_font,
load_font_extension, set_font_palette) and expands gfx_color_table from
9 to 11 entries, absorbing former _unknown_7330 as font palette data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port CTaskTeam__CreateWeatherFilter to Rust, extract wa_lcg
Port the cloud spawning factory (0x552960) that creates a CTaskFilter
and spawns CTaskCloud children with deterministic LCG randomization.
- Add rng.rs with shared wa_lcg() used by advance_rng, advance_effect_rng,
and cloud spawning (eliminates duplicated LCG constants)
- Port create_weather_filter in task/team.rs with naked asm bridge for
CTaskFilter constructor (usercall ECX=0x1B)
- Add Subscribe (slot 8) and SubscribeAll (slot 7) to CTaskFilterVTable
- Add CTASK_TEAM_CREATE_WEATHER_FILTER and CTASK_FILTER_SUBSCRIBE addresses
- Hook via MinHook in wormkit cloud.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: fix CTaskCloud vtable (12→7 slots), add constructor and addresses
The vtable was incorrectly sized at 12 slots — verified as 7 by gap to
CTaskCPU vtable (0x669D54 - 0x669D38 = 0x1C). CTaskCloud does not
override ProcessFrame; all logic runs in HandleMessage via FrameFinish.
- Fix vtable size 12→7, remove phantom process_frame slot 7
- Add write_replay_state (slot 0, 0x548430) to vtable definition
- Add ReadReplayState (0x548370) and WriteReplayState address constants
- Rename _unknown_44 → y_seed (EDI from constructor, seeds initial pos_y)
- Document _unknown_54 as unused padding (never touched by any code path)
- Add CloudType enum and CTaskCloud::init() Rust constructor
- Export new addresses and CloudType
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: call load_wav directly instead of through vtable
Both load_effect_wavs and load_speech_wav were calling DSSound::load_wav
through the vtable indirection. Since load_wav is already a Rust function,
call it directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DSSound_LoadEffectWAVs to Rust, rename DDGame__LoadFonts
Port DSSound_LoadEffectWAVs (0x5714B0) to pure Rust — iterates the SFX
name table at 0x6AF378, builds "data\wav\Effects\{name}.wav" paths, and
calls the already-ported DSSound::load_wav for each entry. Removes the
last WA bridge call from the audio init path in ddgame_constructor.
Rename DDGAME_INIT_SOUND_PATHS to DDGAME_LOAD_FONTS — the function at
0x570F30 loads .fnt font resources, not sound paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port Music streaming audio system to pure Rust
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…
feat: add headful replay testing to Rust test runner
Merge replay-test.ps1's headful mode into openwa-test as a new
`headful` subcommand. Runs with graphics+sound, checks for crashes,
panics, and [GAMEPLAY PASS/FAIL] markers. Supports filter by name
or direct .WAgame path, with configurable --timeout (default 150s).
replay-test.ps1 is now a thin wrapper like run-tests.ps1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: replace raw pointer arithmetic with typed struct access
- weapon_fire.rs: use DDGame.level_width/level_height instead of
.add(0x77C0/0x77C4); use new CTaskWorm accessors instead of
.add(0x34/0x48) on raw worm pointer
- sound_ops.rs: use CGameTask.pos_y instead of .add(0x88)
- sound_ops.rs + active_sound.rs: use SoundEmitter.local_ref_count
instead of .add(8) on raw emitter pointer
- game_task.rs: split SoundEmitter._unknown_04 to expose ref count
field at offset 0x08
- worm.rs: add fire_subtype_1/set_fire_subtype_1_raw/set_action_field_raw
accessors; rewrite state() to use subclass_data slice indexing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: use openwa-config registry lookup in replay-test.ps1
The script had a hardcoded fallback path (I:\games\...) that broke on
machines with WA installed elsewhere. Now resolves the WA directory
using the same OPENWA_WA_PATH env var / HKCU registry key priority
as the openwa-config crate, matching run-tests.ps1 behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: add openwa-config crate with WA installation locator
Centralizes game path discovery into a new openwa-config crate.
find_wa_dir() checks OPENWA_WA_PATH env var then falls back to
HKCU\Software\Team17SoftwareLTD\WormsArmageddon (PATH value).
Removes all hardcoded Steam library paths from launcher, test runner,
and scheme_parse integration test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat: port WavPlayer and speech/fanfare functions to pure Rust
Replace all 3 WavPlayer asm bridges (Stop, Play, LoadAndPlay) with Rust
implementations using hound for WAV parsing and the windows crate for
DirectSound. Hook them globally to avoid CRT heap mismatch between WA's
msvcr80 and our ucrt.
Port PlayFanfare_CurrentTeam from passthrough to full replacement
(team index lookup + fanfare path building). Eliminates all remaining
WA.exe function calls from the speech/fanfare system.
Move game logic from wormkit speech.rs into openwa-core:
- audio/wav_player.rs: WavPlayer struct, stop/play/load_and_play
- audio/speech_ops.rs: PlayFeSfx, PlayFanfare, speech bank loading,
team config name lookup table
Wormkit speech.rs now contains only hook trampolines, logging shims,
and install().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: replace GetTeamConfigName asm bridge with Rust lookup table
Port the 49-entry jump table at 0x4A62A0 as a const array of team config
names (country names used for fanfare WAV paths). Eliminates the inline
asm call to WA.exe from hook_play_fanfare_default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chore: replace multiple shotgun / longbow replays with much longer combined replay
Move KnownSoundId to its own module (known_sound_id.rs), rename sound.rs
to sound_id.rs. Widen load_and_play_streaming to take *mut CGameTask
(needs sound_emitter) and load_and_play_streaming_positional to *mut CTask
(only reads ddgame at +0x2C). Add trap on original 0x546BB0 since its
sole caller (PlayWormSound2) is fully ported.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: split CLAUDE.md into focused per-crate files and skills
Replace raw u32 params and pointer arithmetic with typed struct pointers
in construct_sprite_impl and process_sprite_impl. Type Sprite's
frame_meta_ptr and secondary_frame_ptr as *mut SpriteFrame.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: document ECX preservation requirement for thiscall hook trampolines
WA's ProcessSprite loops calling PaletteContext__MapColor without
re-setting ECX between iterations, relying on the original function
preserving ECX (it only reads [ECX+offset]). Our trampoline's cdecl
call clobbered ECX, causing garbage reads on 2nd+ iteration and
rendering all sprites/text white.
Add a `preserve_ecx` variant to usercall_trampoline! that saves and
restores ECX across the cdecl impl call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: bounds-check palette cache instead of relying on overflow
Skip the cache write when cache_count >= 256 rather than overflowing
into the dirty field. The dirty field is set to 1 immediately after,
so the overflow had no observable effect.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: port PaletteContext__MapColor to Rust with global hook
Pure Rust reimplementation of the palette color mapping function
(0x5412B0). All 10 callers now use the Rust version via MinHook detour.
Uses raw pointer ops to match WA's cache overflow behavior (cache_count
can reach 256, writing past the cache array into the dirty field) and
to avoid noalias miscompilation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Validate that secondary frame table and main frame metadata fit within
the input slice before attempting to read them, preventing panics on
truncated .spr data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: add ConstructSprite and ProcessSprite hook replacements
Replaces ConstructSprite (0x4FAA30) and ProcessSprite (0x4FAB80) with
Rust implementations using parse_spr_header from openwa-core.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat: add ParsedSprite type with owned .spr parser
6-task plan for porting WA's .spr sprite loading to Rust with
ParsedSprite parser in openwa-core and hook replacements in openwa-wormkit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update crate descriptions and Key Files to reflect game logic now
living in openwa-core (weapon_fire, weapon_release, sound_ops,
team_ops) with wormkit as thin hook shims. Update SoundId description
to document packed format (slot + flags).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port streaming sound chain to Rust, unify SoundId/SoundSlot
Fully port play_worm_sound/play_worm_sound_2 — the streaming sound
playback chain no longer calls into WA.exe via naked asm bridges.
New Rust implementations (sound_ops.rs):
- distance_3d_attenuation: stereo panning with i64 fixed-point math
- compute_distance_params: reads listener pos + attenuation from DDGame
- record_active_sound: ring buffer insert into ActiveSoundTable
- dispatch_local_sound: 3D audio + play_sound_pooled + record
- load_and_play_streaming / load_and_play_streaming_positional
Removed: naked asm bridges (call_load_and_play_streaming,
call_load_and_play_streaming_positional, LOAD_AND_PLAY_STREAMING_ADDR).
Unify SoundId and SoundSlot into a single SoundId type — they were
the same packed u32 format (low 16 = slot index, bit 16 = loop,
bit 17 = raw volume). Flag accessors moved to SoundId.
Other changes:
- Add Fixed Div/Div<i32> operators
- Name DDGame+0x77B8 (level_width_sound), GameInfo+0xF38C
(sound_attenuation), DDGame listener_pos() accessor
Co-Authored-By…
refactor: extract game logic from wormkit/replacements to openwa-core
Delete the 2100-line validation/ module (stale checks, wrong vtable slot,
no-op hooks, sleep-based dumps) and replace with ~120-line startup_checks.rs
that auto-generates all checks from define_addresses! and #[vtable(...)]
metadata. Checks run unconditionally at DLL load — no OPENWA_VALIDATE env
var needed. Test runner scans OpenWA.log for [CHECK FAIL] and reports once.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: update CLAUDE.md with trace-desync, module split, env vars
Add trace-desync section, three new env vars, updated test isolation
to reflect file_isolation handling playback.thm, new key file entries
for frame_hook/replay_test/trace_desync modules, and trace-desync as
step 0 in desync debugging methodology.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The offline harness (LoadLibraryExA-based WA.exe loading) was an early
experiment superseded by DLL injection + replay testing. It hasn't been
used since initial development and adds maintenance overhead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>