Port IMG_Decode (0x4F5F80) and DisplayGfx__Constructor (0x4F5E80) to
pure Rust, eliminating the last transmute bridges in the asset loading
pipeline. img_decode returns Option<DecodedBitGrid> with Display/Collision
discrimination; img_decode_cached always returns *mut DisplayBitGrid.
- Add asset/img.rs with img_decode and img_decode_cached
- Replace transmute bridges in gfx_dir.rs, ddgame_constructor.rs
- Hook IMG_DECODE and IMG_DECODE_CACHED for remaining WA callers
- Type DDGame fields: gradient_image, gradient_image_2, arrow_sprites
as *mut BitGrid instead of *mut u8
- Rename GfxResource__Create_Maybe → IMG__LoadFromDir (Ghidra + Rust)
- Rename DisplayGfx__Constructor_Maybe → IMG__DecodeCached (Ghidra + VA)
- Propagate Option<DecodedBitGrid> through call_gfx_find_and_load
and call_gfx_load_and_wrap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: port GfxDir resource loading system to Rust
Port the entire .dir archive I/O layer — GfxDir vtable (read, seek,
load_cached, release), GfxDirStream vtable (all 6 slots), LoadImage,
cache slot management, and cleanup — eliminating all WA.exe asm bridges.
- Move gfx_dir module from render/sprite/ to new asset/ module
- Type cache slots as GfxCacheSlot struct, rename _unknown fields
- Convert GfxDirStreamVtable to #[vtable] macro with bind_! wrappers
- Replace call_gfx_load_dir/call_gfx_load_image bridges with direct calls
- Add WA_FSEEK address, GFX_DIR_STREAM_VTABLE address
- Fix GfxDirStream.gfx_dir field type from *mut u8 to *mut GfxDir
- Rename GfxDirEntry._unknown_08 to data_size
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: port BufferObject ctor, replace more bridges with typed calls
- Port DualBufferObject__Constructor (0x545FD0) to Rust: the original
function was usercall with an implicit EDI register carrying the
second buffer's capacity. Traced the disassembly to find the capacity
is 0x2DC or 0x46C depending on game_info version fields.
- Replace PaletteContext__Init bridges in ddgame_constructor.rs with
the Rust palette_context_init() ported in the previous commit.
- Replace call_display_gfx_dirty_rect raw vtable dispatch with typed
DisplayBitGrid::fill_rect_raw(), using named struct field access
instead of raw pointer offset arithmetic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: typed vtables, port PaletteContext__Init and GameStateStream__Init
Eliminate ~120 raw pointer arithmetic expressions (`ddgame_raw.add(offset)`,
`w.add(offset)`) in favor of named struct field access through typed pointers.
- Split DDGameWrapper._unknown_0f4 into 7 named team scoring arrays
- Split DDGameWrapper._unknown_2b4 to expose team_score_array_6
- Retype DDGameWrapper._zeroed_054 to team_task_ptrs: [*mut u8; 13]
- Split DDGame._unknown_77ac to add _field_77b0
- Change all function signatures from *mut u8 to *mut DDGameWrapper/*mut DDGame
- Replace wa_malloc + write_bytes with wa_malloc_zeroed (~12 sites)
- Propagate typed pointers (DisplayGfx, DSSound, PCLandscape) to vtable calls
- Update DLL trampoline shims to cast to typed pointers
- Remove unused helper parameters (_ddgame_raw, _wrapper)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: eliminate replay test flakiness under high concurrency
Three independent concurrency bugs caused 10-15% failure rates when
running replay tests at -j 4+ (and ~13% at -j 16 for the worms2d suite):
1. Exclusive file locking: WA.exe opens data files (.img, .wav, .bmp)
with dwShareMode=0. Force FILE_SHARE_READ on all CreateFileA calls
in headless mode so concurrent instances can read shared game assets.
2. _findfirst directory contention: WA's file-existence check for
steam.dat uses _findfirst64i32 which does FindFirstFileA directory
enumeration. Under high concurrency, NTFS directory contention from
.openwa_tmp operations causes transient failures. Hook the check
function (0x4DFA30) to always return "exists" in headless mode.
3. thm.prv race condition: MAP_VIEW_LOAD creates, reads, and deletes
a temporary thm.prv file during terrain processing. Concurrent
instances fighting over this file caused heap corruption (crash in
ntdll RtlAllocateHeap). Extend file isolation to hook FindFirstFileA
and DeleteFileA in ad…
refactor: type CoordEntry fields as Fixed and eliminate pointer arithmetic in level_bounds
Retype CoordEntry fields from raw i32 to named Fixed center_x/y and
center_x_target/y_target. Extend viewport_coords array to 5 entries to
cover the init spill. Retype init_game_state_level_bounds param from
*mut u8 to *mut DDGame, removing all raw pointer arithmetic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: propagate types and named fields in game_state_init
Split _unknown blocks in DDGame (28 new fields) and GameInfo (17 new
fields) to replace raw pointer arithmetic with named struct access.
Retype game_speed, level bounds, and render_scale to Fixed. Change
game_info_sub parameters from *const u8 to *mut GameInfo across all
init sub-functions, eliminating the untyped pointer pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DDGame__InitGameState (0x526500) to Rust
Full Rust reimplementation of the ~600-line game session initialization
function. Allocates ~20 sub-objects, initializes display layers, team
configuration, weapon tables, turn logic, random bags, and computes the
initial state serialization/checksum.
Key changes:
- game_state_init.rs: init_game_state() + 13 helper functions (~1400 LOC)
- ddgame_wrapper.rs: split _unknown blobs into ~60 named fields
- game_session.rs: replaced transmute bridge, added trap on original
- game_state_hooks.rs: removed passthrough hooks for usercall functions
(CTaskTurnGame, DisplayGfx__ConstructFull, ConstructTextbox,
DisplayObject, WeaponPanel) that drop ECX/EDX/ESI register params
- address.rs: added VectorNormalize addresses, renamed INIT_WEAPON_PANEL
Sub-constructors are called via typed bridges (transmute/fastcall/naked
asm). Already-ported functions (sprite_gfx_table_init, ring_buffer_init,
init_team_scoring, init_alliance_data, init_turn_state, etc.) are called
directly. Display layers use cor…
fix: port shadow LUT remap to Rust, fixing Indian Nuclear Test crash
The SHADOW_CLEAR path in blit_sprite called BitGrid__RemapPixelsThroughLut
(0x4F6590) as a plain cdecl function, but it's actually a usercall that
expects EAX = BitGrid pointer with one stdcall stack param (RET 0x4).
EAX was never set, so the function read a garbage BitGrid pointer and
wrote to invalid memory.
Replaced the broken asm bridge with a pure Rust port of the function —
a simple double loop remapping 8bpp pixels through a 256-byte LUT.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: migrate all render queue commands to typed RenderMessage enum
Replace legacy DrawXxxCmd structs with RenderMessage variants for all
10 Rust-produced command types. Variable-length commands (LineStrip,
Polygon) use alloc_aux() for vertex data with explicit pointers in the
enum, avoiding layout coupling. Remove legacy structs and command_type
constants module.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: propagate types and trim comments in render hook shims
Replace u32 params with Fixed, SpriteOp, and typed pointers across
render_queue.rs and display_vtable.rs. Propagate to core callees
(blit_sprite, bungee_trail, crosshair_line). Trim comments to just
Ghidra name + address.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: split render.rs, move game logic to core, type command structs
Replace raw u32 sprite_flags with typed SpriteOp (packed sprite index +
transform flags) and SpriteFlags (bitflags for the 13 known blit flags).
Follows the same pattern as SoundId/KnownSoundId in the audio system.
- SpriteId enum renamed to KnownSpriteId (non-exhaustive catalog)
- SpriteOp: #[repr(transparent)] newtype with index()/flags() accessors
- SpriteFlags: bitflags! type replacing raw 0x0080_0000-style masks
- Updated through the full pipeline: DrawSpriteCmd, RenderMessage,
vtable signature, dispatcher, and blit_sprite implementation
- Added bitflags 2 as direct dep (already transitive via windows crate)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor(cloud): rename pos_y/vel_y to anim_phase/phase_speed, use RenderMessage
CTaskCloud.pos_y was never a position — it's a per-frame animation phase
counter passed as the palette arg to blit_sprite. Rename to anim_phase
(and vel_y to phase_speed) to match actual semantics. Migrate the cloud
producer from DrawSpriteCmd to RenderMessage::Sprite as the first typed
render queue user.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: introduce RenderMessage enum for typed render queue commands
Add a strongly-typed RenderMessage enum that can be stored inline in the
existing RenderQueue buffer arena (zero allocation). TypedRenderCmd wraps
it with the standard command_type/layer prefix so sorting works unchanged.
The dispatcher handles both legacy byte-format and typed commands.
Also rename DrawSpriteCmd fields sprite_id->sprite_flags, frame->palette
to match the vtable signature used by the dispatcher.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix(cloud): use field 0x44 as render Y, not field 0x34
CTaskCloud::HandleMessage RenderScene was sending `pos_y` (field 0x34)
as the sprite Y, which the constructor masks to `(pos_x + render_y) &
0xFFFF` — always 0 — pinning every cloud to the very top of the level.
The original at 0x548527..0x54852f loads `[ESI+0x44]` into EAX (the
usercall Y register for RQ_DrawSpriteLocal) and pushes `[ESI+0x34]` as
the trailing stack arg, which lands in the queue command's `palette`
slot and acts as a per-cloud animation/palette index incremented each
frame by `vel_y`.
Rename the misnamed `y_seed: u32` to `render_y: Fixed`, swap the two
values in the DrawSpriteCmd write, and correct the field docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: dispatch via DisplayGfx::*_raw methods in queue_dispatch
Replaces the per-case `((*vtable).method)(display, ...)` open-coding
with the auto-generated `DisplayGfx::*_raw` methods produced by
`bind_DisplayGfxVtable!(DisplayGfx, base.vtable)`. The `_raw` methods
do the same `((*(*this).base.vtable).method)(this, args...)` dispatch
under the hood while keeping the noalias-safe raw-pointer signature.
Drops the explicit `vtable: *const DisplayGfxVtable` parameter from
all 14 per-case dispatcher functions and removes the `let vtable =
(*display).base.vtable` setup in the main loop. Net −34 lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port RenderDrawingQueue dispatcher to pure Rust
Ports the per-frame render-queue dispatcher (0x542350, ~789k dispatches
per minute of gameplay) and its four leaf helpers — RQ_ClipCoordinates
(0x542BA0), RQ_ClipCoordinatesWithRef (0x542C70), RQ_TranslateCoordinates
(0x542B10, formerly mis-labelled RQ_GetCameraOffset_Maybe), and the
inline FixedDiv16_16 — to native Rust. The dispatcher's 14 cmd-type
cases dispatch through the DisplayGfx vtable to slots 11..22 via raw
vtable pointers, eliminating the last per-frame WA→DisplayGfx bridge.
Lesson #1 from the project memory bit again: the Ghidra decompile of
RQ_TranslateCoordinates lost the ECX register input entirely (only
showed in_EAX and unaff_EDI). Verified ECX = x_in from the prologue
disasm `AND ECX, 0xFFFF_0000` at 0x542B1A and from the dispatcher's
`MOV ECX, [ESI+0x8]` setup at every call site. Recorded as failure
mode #3 in feedback_ghidra_caller_frame_reads.md.
ClipContext, helpers, and all 14 case bodies use the existing Fixed
type (Fixed::floor / Fixed::div / Fixed::mul_raw)…
refactor: type set_active_layer return + propagate PaletteContext
Cascades a typed `*mut PaletteContext` return through the chain that
starts at `DisplayGfx::set_active_layer` (slot 5) and feeds the GfxDir
loaders.
- Slot 5 vtable signature retyped from `*mut u8` to
`*mut PaletteContext`. Doc-comment promoted from one line to
explain what the result is used for downstream.
- `gfx_resource_create` (`FUN_004F6300` port), `call_gfx_find_and_load`,
and `call_gfx_load_and_wrap` now take `display_ctx: *mut PaletteContext`
instead of `*mut u8`. The single internal `IMG_Decode` call still
casts back to `*mut u8` since `IMG_Decode` is WA-side.
- `compute_complex_gradient` (`render/display/gradient.rs`) takes
`layer3_ctx: *mut PaletteContext` and passes it through to
`call_gfx_find_and_load` without any casts.
- `wa_load_sprites` naked-asm bridge's `_layer_ctx` parameter retyped
to `*mut PaletteContext` to match what its only caller passes after
`set_active_layer`. The naked asm body is unchanged — pointer types
…
refactor: propagate type info through display vtable.rs and friends
A series of small type-safety improvements that compound to delete a
lot of raw byte arithmetic and ad-hoc transmutes:
- `LayerSpriteFrame.bitmap_vtable` retyped from `u32` to
`*const c_void`, and `LayerSpriteFrame.surface` from `u32` to
`*mut Surface`. The two existing consumers (the slot 0 destructor's
`layer_sprite_frame_destructor` and the `load_sprite_by_name` /
`free_layer_sprite` pair) now access these via named fields
instead of raw `elem.add(0x08)` / `elem.add(0x0C)` arithmetic.
- `SurfaceVtable::set_color_key` (slot 7) added as a typed binding,
matching the existing `lock_surface` / `unlock_surface` /
`init_surface` / `release` slots. `load_sprite_by_name`'s per-frame
surface init loop now uses the typed binding for slots 3, 4, 5, 6,
7 instead of hand-rolled raw transmutes — drops ~50 lines.
- `RenderContextVtable::lock_surface_write`'s `out_data` retyped
from `*mut u32` to `*mut *mut u8`, and `unlock_surface_write`'s
`p2: …
docs: trim obsolete algorithm narration from display vtable.rs
The vtable methods all have native Rust implementations now, so the
verbose docstrings that re-narrated each function's algorithm are
redundant — the Rust code itself is the source of truth.
Removes:
- The obsolete "GetSpriteFrameForBlit is NOT ported" pointer to a
porting blueprint that's been irrelevant since slot 33 landed.
- The full three-phase blueprint on the slot 11 vtable struct
docstring (duplicated in `draw_tiled_bitmap_impl` below).
- Most "Step N: do X" inline narration that just describes the next
line of Rust below it.
- Boilerplate `# Safety` sections that only said "must be a valid
pointer" — `unsafe fn` already implies that contract.
- Long algorithm walkthroughs on the simple ports
(`fill_rect`, `set_clip_rect`, `flush_render`, etc.).
- The 25-line panic explanation on the SpriteBank stub.
Kept (these add information the Rust code can't express):
- Original WA function name + Ghidra address on every port.
- Calling-convention quirks (usercall input regist…
docs: refresh DisplayGfx slot 0 docstring after the destructor port
Updates the slot 0 entry in `DisplayGfxVtable` to point at the new
Rust impl `display::destructor::display_gfx_destructor`, document
the trapped helpers, and switch the typed `flags` parameter from
`u8` to `u32` so it matches the actual Rust impl signature (the
underlying x86 stack slot is 4 bytes either way, but `u32` is more
honest about what the function reads).
The project memory (`MEMORY.md` + `project_display_porting.md`) is
updated separately to reflect that the display *frontend* is now
100% Rust including shutdown teardown, and to record three new
lessons from the port:
35. Ghidra plate comments can fabricate semantics the disasm
contradicts (the "rebind to DDDisplay parent vtable"
annotation was actually rebinding to the most-derived
DisplayGfx vtable).
36. Vestigial vtable slots in .rdata are normal — DisplayGfx
slots 38/39 exist but are never dispatched, which is what
let us trap TileBitmapSet::Destructor.
37. The startup-check VALID_PR…
feat: port DisplayGfx::Destructor (slot 0) to pure Rust
Replaces vtable[0] of DisplayGfx (0x66A218) with a native Rust impl
that mirrors the original DestructorImpl (0x56A010) one-to-one.
The cleanup body lives in the new `display::destructor` module and
ports both helpers along with it:
- `free_layer_sprite_table` — Rust port of 0x56A280: iterates
`sprite_table[1..0x3FF]`, runs `LayerSpriteFrame::Destructor` on
every entry of each sprite's count-prefixed `frame_array`, then
frees the frame array and the LayerSprite itself.
- `layer_sprite_frame_destructor` — Rust port of 0x5732E0: rebinds
the embedded CBitmap vtable and dispatches the surface's
scalar deleting destructor (`vtable[0](1)`) via raw thiscall.
- `tile_bitmap_set_destructor` — Rust port of 0x569BC0 (originally
a usercall on EDI): iterates `bitmap_ptrs[0..count]`, calls
`vtable[0](1)` on each non-null `CBitmap*`, frees `bitmap_ptrs`.
- `free_cbitmap_vec` — the inline CBitmap vec teardown loop:
releases each non-null `CBitmap.surface` via …
feat: define FramePostProcessHook abstract class + ScreenshotHook vtable
Adds the typed Rust definitions for the per-frame post-processing hook
interface used by `DisplayGfx::DispatchFramePostProcessHooks` (0x56CDB0)
and the slot 0 destructor's hook-vec teardown step:
- `FramePostProcessHook` — 4-byte abstract polymorphic class (just a
vtable pointer). Concrete subclasses store their state in globals.
- `FramePostProcessHookVtable` (0x66A2B8, 3 slots): scalar deleting
destructor + `get_capture_request` (returns non-zero if the hook
wants to run this frame) + `capture` (does the per-frame work,
receives `layer_0` as a stack arg).
- `ScreenshotHook` (`type` alias) and `SCREENSHOT_HOOK_VTABLE`
(0x66A2C4) — the only concrete subclass shipped in WA, writes the
rendered surface as a numbered PNG.
Calling conventions verified directly from
`DispatchFramePostProcessHooks` disasm:
- vtable[1] is pure thiscall (no stack args, returns i32 in EAX),
dispatched at 0x56CE01-0x56CE08.
- vtable[2] is thiscall + 1 stack arg (`layer_0: …
refactor: type DisplayGfx hook_vec fields + add destructor addresses
Renames the four `object_vector_*` fields at +0x24DF8..+0x24E07 to
`hook_vec_proxy/first/last/end` and documents the underlying MSVC 2005
`std::vector<FramePostProcessHook*>` layout. The proxy field at offset
0 (the iterator-debug `_Container_proxy*`) was previously labelled as
the start pointer, a latent off-by-one. The pointee type is left as
`*mut u8` until the FramePostProcessHook struct lands.
Adds new `define_addresses!` entries to `display/gfx.rs` for the
destructor port:
- DISPLAY_GFX_DESTRUCTOR (0x569CE0, slot 0 thunk)
- DISPLAY_GFX_DESTRUCTOR_IMPL (0x56A010, body)
- DISPLAY_GFX_FREE_LAYER_SPRITE_TABLE (0x56A280)
- DISPLAY_GFX_DISPATCH_FRAME_POST_PROCESS_HOOKS (0x56CDB0)
- TILE_BITMAP_SET_DESTRUCTOR (0x569BC0)
- DISPLAY_BASE_DESTRUCTOR_IMPL (0x522F60, parent dtor)
Re-exported via `mod va`.
Pre-work for porting DisplayGfx::Destructor (slot 0). Note: the plan's
`DD_DISPLAY_VTABLE` constant turned out to be bogus — the destructor's
self-rebind at 0x56A030 writes t…
refactor: type DisplayGfx +0x3DA8..0x3DD4 as embedded BitGrid
Replaces the scattered bitgrid_vtable / layer_active / _unknown_3db0 /
bit_depth / _unknown_3db8 fields with a single `BitGrid` sub-object.
Confirmed by `DisplayGfx::DestructorImpl` (0x56A010) which rebinds
+0x3DA8 to &BitGrid__vtable and conditionally frees +0x3DB0 (data)
based on +0x3DAC (external_buffer) — the textbook BitGrid destructor
pattern. The constructor's writes (`*+0x3DAC = 1`, `*+0x3DB4 = 8`)
also match BitGrid's external_buffer / cells_per_unit fields.
This is the fourth BitGrid in DisplayGfx, distinct from the three
heap-allocated layer pointers at +0x3D9C/+0x3DA0/+0x3DA4.
Pre-work for porting DisplayGfx::Destructor (slot 0).
refactor: fix DDGameWrapper size to 0x6F10 and remove WABox
DDGameWrapper had an off-by-16 size assertion (0x6F00 vs actual allocation
0x6F10); the 0x20-byte tail is unzeroed unknown padding. Fixed struct field
and size assertion.
WABox never had a Drop implementation so it was purely a leak-by-design
wrapper with no safety value. Remove it entirely and replace all uses:
- WABox::alloc(...).leak() → wa_malloc_struct_zeroed::<T>()
- WABox::from_value(val).leak() → wa_malloc_struct::<T>() + ptr::write(p, val)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor: replace inline thiscall transmutes with typed GfxDirVtable pattern
Add GfxDirVtable via #[vtable] macro (4 slots: read/seek/load_cached/release)
and GfxDirStream::total_size_raw (slot 4, formerly _slot_4). Replace all
inline transmute vtable calls across gfx_dir.rs, ddgame_constructor.rs, and
font.rs with typed _raw methods generated by bind_GfxDirVtable!. Change
call_gfx_load_image return type to *mut GfxDirStream. Rename GfxHandler and
GfxDirStream vtable functions in Ghidra accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor: replace untested SpriteBank slot-33 port with panic, trap leaves
Two related changes addressing the SpriteBank-side concern raised after
the slot 33 port:
1. **Replace `sprite_bank_get_frame_for_blit` body with a panic.** The
port shipped in 973f234 was disassembly-derived but never executed
in any test (a `panic!()` planted there during headful play was
never triggered). Per the project rule against keeping unverified
code, the body has been removed; the function is now a stub that
panics with a clear "should never be reached" message pointing
back at 973f234 for the unverified Ghidra translation if banks
ever become live.
Why we believe the path is structurally dead in shipping WA:
`SpriteBank` objects are constructed only by `SpriteBank__Constructor`
(0x4F9450, verified by xrefs to vtable 0x664180), whose only caller
is `DisplayGfx::LoadSpriteEx` (slot 30, 0x523310), which has zero
callers in WA.exe and zero in Rust and is itself trapped via
`install_trap!("DisplayGfx__LoadSpriteEx", ...)`. Therefore
`D…
feat: port DisplayGfx::GetSpriteFrameForBlit (slot 33) to pure Rust
This is the LAST in-game render bridge. With this slot ported, the
entire render pipeline of a normal Worms match runs on Rust code with
zero bridges into WA's render functions; only the renderer backend
itself (CompatRenderer / DDraw / OpenGL) and the shutdown destructor
(slot 0) remain.
Three function ports + two new leaf modules:
- `render::sprite::lzss::sprite_lzss_decode` — port of
`Sprite_LZSS_Decode` (0x5B29E0). LZSS-style decompressor with
palette LUT remapping. Key detail verified from the disassembly:
short backrefs encode `real_distance - 1` (the asm's `DEC ECX`
between the JZ and the read), but long backrefs encode the real
distance directly (the JZ jumps OVER the DEC). 6 unit tests cover
literal runs, both backref forms, length boundaries, the
terminator, and self-overlap RLE-style cases.
- `render::sprite::frame_cache::frame_cache_allocate` — port of
`FrameCache__Allocate` (0x4FA950). LRU ring-buffer allocator with
eviction notification via `vtable[1]…
refactor: type FrameCache + Sprite/SpriteBank subframe-cache layouts
Pre-work for porting slot 33 (get_sprite_frame_for_blit) to pure Rust.
All changes are type-only — no logic differences. Verified by all 26
unit tests + 15 headless replay tests passing.
- Rename `SpriteBufferCtrl` -> `FrameCache` with the LRU bookkeeping
fields typed (write_head/wrap_marker/tail_entry/head_entry/entry_count).
Add `FrameCacheEntry` (16-byte header). Note: `frame_idx@+8`,
`owner@+0xc` — the prep-session docstring blueprint had these swapped;
verified from the 0x4FA950 epilog and the eviction call's `MOV ECX,
[EAX+4]` (which loads the owner from `entry+0xc` for the thiscall).
- Rename `SpriteCache::buffer_ctrl` -> `frame_cache: *mut FrameCache`.
- Define `SpriteSubframeCache` (Sprite-side, decoded_ptr@+4) and
`SpriteBankSubframeCache` (bank-side, decoded_ptr@+8) as separate
structs with asserted offsets — sharing one type would corrupt one
of the two paths.
- Define `SpriteBankBboxEntry` (subframe_idx + decoded_offset + bbox).
- `Sprite::secondary_frame_…
feat: port slot 19 bitmap-sprite path to pure Rust
The slot-19 (BlitSprite) bitmap-sprite branch was the last in-game
caller of three usercall bridges to WA: GetBitmapSpriteInfo (0x573C50),
BlitBitmapClipped (0x56A700), and BlitBitmapTiled (0x56A7D0). After
this change, the only remaining in-game render bridge is slot 33
(GetSpriteFrameForBlit).
Type cleanups (lessons 14, 18: small typing wins are always welcome):
- LayerSprite renames `field_64`/`field_68`/`field_6a` to `flags`,
`cell_width: i16`, and `cell_height: i16`. Field semantics verified
against the GetBitmapSpriteInfo disassembly's reads of [EAX+0x64..0x6A].
- DisplayGfx::sprite_table retyped from `[u32; 0x400]` to
`[*mut LayerSprite; 0x400]`. Each non-null entry is the bitmap-sprite
object allocated by `load_sprite_by_layer` (slot 37).
- LayerSpriteFrame::bitmap_ptr() helper returns the trailing 12-byte
CBitmap block as `*mut CBitmap`. Lets us avoid duplicating the
vtable+surface+pad triple as a nested struct while keeping the
call-site type-safe.
- blit_bitm…
refactor: switch slot 22 (DrawTiledTerrain) to native BlitBitmapClipped
`draw_tiled_terrain` was the last in-game caller of the asm bridge to
WA's `DisplayGfx::BlitBitmapClipped` (0x56A700). Slot 11's port already
had `blit_bitmap_clipped_native` doing the same clipping + dispatch
through `cbitmap_blit_via_wrapper`, so the bridge was redundant.
Verified the call site at 0x56C6A1: `EAX=this, EDX=col_width, stack
=(dst_x, dst_y, row_height, bitmap_ptr, flags)` maps cleanly to
`blit_bitmap_clipped_native(this, dst_x, dst_y, width, height,
surface, flags)`. Verified `TileBitmapSet::bitmap_ptrs[]` entries
share the CBitmap layout used by slot 11 by tracing FUN_00403c60's
`[ESI+0x4]` surface read against `CBitmap.surface`.
- Retypes `TileBitmapSet::bitmap_ptrs` from `*const u32` to
`*const *mut CBitmap`.
- Slot 22 now calls `blit_bitmap_clipped_native` directly.
- Deletes the `blit_bitmap_clipped` Rust wrapper and the
`blit_bitmap_clipped_bridge` naked-asm trampoline (~50 lines).
- Keeps `va::DISPLAY_GFX_BLIT_BITMAP_CLIPPED` because the slot-19
bitmap-sp…
refactor: introduce Layer newtype, type layer_contexts and palette_ctx
Adds `Layer(NonZeroU8)` covering the 1..=3 display-layer index used by
`set_layer_color`, `set_active_layer`, `set_layer_visibility`,
`load_sprite`, `load_font`, etc. Vtable signatures still take i32/u32
to match WA's binary contract; conversion happens at function entry,
which both deletes the open-coded `(layer as u32).wrapping_sub(1) >= 3`
checks and statically guarantees array indices are in range.
Bundled with the matching pointer-typing pass:
- `DisplayBase::layer_contexts: [u32; 4]` -> `[*mut PaletteContext; 4]`
- `LayerSprite::palette_ctx: u32` -> `*mut PaletteContext`
- `wa_load_sprite_from_vfs` and the `load_sprite_from_vfs` callback
param retyped from `u32` to `*mut PaletteContext`
Both types are 4 bytes on i686-pc-windows-msvc, so the struct layouts
and bridge calling conventions are unchanged. Replaces every
`if layer_ctx == 0` with `if layer_ctx.is_null()` and deletes the dead
`is_valid_layer` helper.
Verification: cargo test (4 new Layer tests pass), 15/15 headless…
refactor: extract font module, type GfxDir pointers, rename DisplayVtable
Three concurrent cleanups bundled together:
- Extract the font implementation (~830 lines) from display/vtable.rs
into a new display/font.rs module: Font struct (renamed from
FontObject), font_extend, font_set_palette_impl, font_get_info_impl,
font_get_metric_impl, font_set_param_impl, font_load_from_gfx, and
GlyphEntry. vtable.rs is now 870 lines lighter and only contains
vtable definitions and slot wrappers.
- Replace `*mut u8` with typed `*mut GfxDir` across the GfxDir call
graph: LayerSprite.gfx_dir, PCLandscape.{level,water}_gfx_dir,
DDGameWrapper.{primary,secondary}_gfx_dir, slot 31 load_sprite,
slot 34 load_font, slot 37 load_sprite_by_layer,
gfx_dir_find_entry, gfx_resource_create, call_gfx_find_and_load,
call_gfx_load_and_wrap, call_gfx_load_image, plus the matching DLL
trampolines and the inner transmuted vtable[2] cached-load function
pointer in three places. Also renames the misleading local names
`land_layer`/`water_layer` to `land_dir`/`water_d…
feat: port DisplayGfx::DrawTiledBitmap (slot 11) to pure Rust
Last hot-path slot in the in-game render pipeline. The entire active
rendering pipeline of a normal Worms match now runs on Rust code; only
GetSpriteFrameForBlit (slot 33, called from blit_sprite) and the WA
wrapper backend (DDraw/D3D/OpenGL) remain as bridges.
Three phases per the existing slot docstring: lazy tile-cache
allocation, lazy populate, per-frame clipped tile blits. Bundles in
the leaf primitives blit_64byte_row_pattern (port of FUN_005B2A5E)
and blit_color_table_forward (port of BlitColorTable_Forward), plus
blit_bitmap_clipped_native (port of DisplayGfx::BlitBitmapClipped at
0x56A700) and cbitmap_blit_via_wrapper (port of FUN_00403c60).
Adds a 12-byte CBitmap struct (vtable 0x643F64) to sprite.rs, distinct
from but isomorphic to the trailing 12 bytes of LayerSpriteFrame, and
the SurfaceVtable / Surface stub in context.rs covering the lock /
unlock / init slots used by the populate phase. Renames
DisplayGfx._unknown_358c -> tile_cache_populated and retypes the
bitmap_vec…
feat: port DisplayGfx::DrawTextOnBitmap (slot 7) to pure Rust
The slot 7 wrapper used to bridge to BitmapFont__DrawText (0x4FA4E0)
via a 5-stack-arg ESI usercall trampoline. Replace with a native Rust
port that folds in the dispatch helper at FUN_004FA490 + the 9
unrolled blit helpers (table at 0x6A9594) as a single nested loop.
What slot 7 actually does (verified by tracing call sites): walks a
null-terminated msg, looks up each char in `font.char_to_glyph_idx`,
copies that glyph's pixel rows directly into the destination BitGrid.
No transparency, no blending — every byte is overwritten verbatim.
Two modes selected by `font_id_high & 2`: forward (left-to-right) and
right-aligned (right-to-left starting from pen_x as the right anchor).
Returns char count drawn / -1 / truncation index per the original.
Important findings while porting:
- WA has TWO unrelated font systems. The bitmap-font path
(BitmapFont__DrawText at 0x4FA4E0, .fnt files via DisplayGfx slots)
renders ONLY in-game text. The frontend menus use a completely
separate Font cla…
feat: port DisplayGfx::SetFontPalette (slot 36) to pure Rust
Slot 36 was bridging via a naked-asm trampoline to Font__SetPalette
(0x4F9F20). Replace it with a native Rust port and delete the trampoline.
The original function name is wildly misleading. Despite "SetPalette",
it actually extends the font with two new derived glyphs: it generates
'.' (0x2E) and ';' (0x3B) at runtime by copying the existing ':' (0x3A)
glyph, fills the top half of '.' with `palette_value as u8`, and copies
':' into ';' substituting any pixel matching the '8' glyph's foreground
color with the '-' glyph's foreground color. Called exactly once during
DDGame::LoadFonts for `digiwht.fnt` (slot 28, the digital seven-segment
font), which doesn't ship with those two codepoints. The full step list
is in the new docstring on `font_set_palette_impl`.
While in the area, rename `FontObject::_unknown_18` to `aux_alloc` —
both `font_extend` (the .fex loader) and the new `font_set_palette_impl`
write the address of an auxiliary glyph+pixel buffer there for the font
destructor to fr…
refactor: rename mis-labeled DisplayGfx slot 33 to GetSpriteFrameForBlit
Slot 33 (0x5237C0) was historically called LoadSpriteComplex everywhere —
in the vtable definition, the address registry, the inner-helper names
(SPRITE_COMPLEX_SPRITE_PATH / SPRITE_COMPLEX_BANK_PATH), the doc strings
on SpriteBank/DisplayBase, and the "NOT ported because complex usercall"
rationale. It is not a load function. It's the per-blit frame lookup that
BlitSprite (slot 19) calls on every sprite render: clamps an animation
value, finds the matching frame entry in the sprite's frame table,
lazily decompresses the frame surface, and returns *DisplayBitGrid +
bounding box.
Cascade renames:
vtable slot load_sprite_complex → get_sprite_frame_for_blit
Ghidra fn LoadSpriteComplex → DisplayGfx__GetSpriteFrameForBlit
Ghidra fn FUN_004FAD30 (alias → Sprite__GetFrameForBlit
SPRITE_COMPLEX_SPRITE_PATH)
Ghidra fn FUN_004F9710 (alias → SpriteBank__GetFrameForBlit
SPRITE_COMPLEX_BANK_PATH)
Replace render.rs's …
feat: port DisplayGfx::{GetFontInfo,GetFontMetric,SetFontParam} to pure Rust
Slots 8/9/10 of the DisplayGfx vtable used to live in openwa-dll as
thiscall wrappers that bridged via naked asm to the original
Font__GetInfo / Font__GetMetric / Font__SetParam helpers (each a
~10-line pure read of FontObject). Replace the bridges with native
Rust ports in openwa-core::render::display::vtable, point the
vtable_replace! installer at the new core impls, and delete the four
usercall trampolines they relied on.
Also rename FontObject::palette_end -> char_to_glyph_idx with a doc
that matches its actual role (256-byte char->1-based-glyph-index
lookup table sitting after the RGB palette triplets), and fix the
"char_widths" local in font_extend that was writing glyph indices.
All 15 headless replay tests pass. The slot wrappers are read-only
metric queries, so headful visual verification is recommended before
trusting font layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: rename mislabeled tile-bitmap queue command and slot 11
Trying to trap DisplayGfx vtable slot 11 as dead code revealed it is in
fact reachable: RenderDrawingQueue case 0xD dispatches into it, and the
queue entries come from our own Rust trampoline for what was labelled
RQ_DrawPixel. Both names were wrong end-to-end — the function enqueues a
tile-cached bitmap draw (only known producer: CTaskLand::RenderLandscape),
not a single pixel.
Renames, byte-format unchanged:
WA 0x541D60: RQ_DrawPixel → RQ_EnqueueTiledBitmap
WA 0x56B8C0: DisplayGfx__CreateBitmap → DisplayGfx__DrawTiledBitmap
Rust DrawPixelCmd → DrawTiledBitmapCmd, with corrected field names
(x_pos/y_pos/color → y_fixed16/source_descriptor/x_fixed16)
command_type::DRAW_PIXEL → DRAW_TILED_BITMAP
trampoline_draw_pixel → trampoline_enqueue_tiled_bitmap
vtable slot 11 create_bitmap → draw_tiled_bitmap
The slot 11 docstring now captures the three-phase semantics (lazy
allocate tile surfaces, populate from source, clipped blit) so a future
port has a blueprint.
Co-Authored…
feat: port DisplayGfx::LoadFontExtension (slot 35) to pure Rust
Adds palette_context_lookup_entry (FUN_00541200) and
palette_find_nearest_cached (FUN_00541340) helpers, ports the .fex loader
chain (FUN_004f9ad0 fopen/fread/fclose + bounding-box scan + LUT remap),
and corrects GlyphEntry layout so the +4 field is recognised as `width`
to match how Font__GetMetric reads it. DisplayGfx vtable patches now
cover 31 of 38 slots.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DisplayGfx::LoadFont (slot 34) to pure Rust
Adds FontObject (0x1C), GlyphEntry (0xC) and FntHeader (0xC) structs and
ports the .fnt loader chain (FUN_004f9940 + FUN_004f99d0) and the slot 34
wrapper. Replaces the last bridged font-loading path so DisplayGfx vtable
patches now cover 30 of 38 slots.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port LoadSpriteByLayer (slot 37) and LoadSpriteByName to pure Rust
Port the layer sprite loading pipeline to Rust, eliminating all usercall
bridges. This enables load_sprite_by_layer (vtable slot 37, 0x56A4C0) to
be fully hooked via vtable_replace.
Key functions ported:
- LoadSpriteByName (FUN_005733b0): reads .spr header, palette, frame pixel
data from GfxDir stream; creates software surfaces via RenderContext
- FreeLayerSprite (FUN_0056a2f0): destroys surfaces and frees the sprite
- RemapPixelsThroughLut (FUN_005b2beb): palette index remapping on pixel buffers
New types:
- LayerSprite (0x70 bytes): background/water layer sprite object
- LayerSpriteFrame (0x14 bytes): per-frame surface with bounding box
- GfxDirStream / GfxDirStreamVtable: typed wrapper for .dir archive reader
Notable findings during porting:
- Ghidra's unaff_ESI/unaff_EBP were decompiler artifacts, not caller-frame
reads — both registers are set within the function
- RenderContext::alloc_surface returns the pointer in EAX, not via the
FastcallResult buffer
- .spr palette en…
feat: port DisplayGfx::LoadSprite (slot 31) and improve Sprite struct
- Port load_sprite vtable method: Rust validation/bookkeeping with
ConstructSprite in pure Rust, delegating VFS loading to WA bridge
- Embed DisplayBitGrid as a proper struct field in Sprite instead of
raw _unknown_38 bytes, with offset assertions
- Type Sprite::context_ptr as *mut SpriteCache instead of *mut u8
- Add construct_sprite() in openwa-core (pure Rust port of 0x4FAA30)
- Update ConstructSprite hook in DLL to use typed bitgrid fields
- Re-export SpriteVtable from sprite module
- Add address constants for sprite loading helper functions
DisplayGfx now at 27/34 non-stub vtable slots ported.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DisplayGfx font vtable methods (slots 7-10, 36) to Rust
Merge the display/ and render/ sibling modules into a single render/
module with display/, sprite/, and ddraw/ sub-modules. Pure mechanical
refactor — file moves and import rewrites only, zero logic changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port non-8bpp blit and introduce PixelGridMut for sound FFI
Port all remaining paths in BitGrid__BlitSpriteRect (0x4F6910) to Rust,
eliminating the fallthrough to the original WA function:
- 1-bit byte-aligned fast path: memcpy/OR/nop for modes 0-3 (collision masks)
- Generic per-pixel fallback (port of FUN_004f80c0): modes 0-5 for any
cells_per_unit, handling unaligned 1-bit, erase, and collision test
- Orientation adjustment logic ported from the original function
Also introduce PixelGridMut<'a> (data: &mut [u8]) to replace the unsound
Vec::from_raw_parts hack used to wrap WA-allocated memory in PixelGrid.
All blit functions now accept PixelGridMut, and the three DLL bridge sites
use slice::from_raw_parts_mut instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DisplayGfx slot 22 (draw_tiled_terrain)
Port the terrain tile rendering method (21/38 slots). Renders bitmap
tiles in a row-major grid pattern with camera offset, delegating to
BlitBitmapClipped (0x56A700) via a naked asm bridge.
- Define TileBitmapSet struct (count + bitmap_ptrs array pointer)
- Name DisplayGfx tile grid fields: tile_bitmap_sets, tile_total_width/
height, tile_col_width/row_height (previously _unknown_4ddc gap)
- Use Fixed type for x/y coordinates (was raw i32 with >> 16)
- Rename stream_data → draw_tiled_terrain (matches actual purpose)
- Add offset assertions for all tile config fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>