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: 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: suppress loading dialog and focus stealing in headless modeβ
Skip the "Loading, please wait..." splash dialog entirely by patching
a JNZβJMP at 0x4E9F20 in Frontend__MainNavigationLoop, so the game
calls ReplayLoader() directly without creating windows or threads.
Hook SetForegroundWindow and SetFocus in user32 as no-ops to prevent
headless WA.exe instances from stealing keyboard focus from other
applications during parallel test runs.
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: 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: detect match completion for Surrender-ending replays in headful testsβ
Surrender doesn't zero TeamArena worm health β it sets worm state to
Dead (0x86) via a different mechanism. The per-frame alive-team poll
never saw alive<=1, and DDGame is already null at DLL_PROCESS_DETACH,
so the final check couldn't run either. Now treat DDGame-null-at-detach
(meaning DDGameWrapper::EndGame ran normally) as match completion when
the match had started.
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β
The code already implements the full paletteΓ4 + orientation quadrant
logic from the original ASM at 0x56B145. The "for now, handle the
simple case" comment was left over from an earlier draft. Updated
comments to accurately describe the quadrantβorientation mapping and
annotated each match arm with the corresponding orientation name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: introduce SpriteOp/SpriteFlags, rename SpriteId to KnownSpriteIdβ
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>
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>
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: β¦
chore: trap WA-side originals of ported DisplayGfx::Destructorβ
Installs panic traps on the three WA-side functions whose only live
callers were the ported destructor body:
- DisplayGfx::DestructorImpl (0x56A010)
- DisplayGfx::FreeLayerSpriteTable (0x56A280)
- TileBitmapSet::Destructor (0x569BC0)
If any of these fire, the slot 0 port forgot to handle a step or a
caller path we didn't account for.
`TileBitmapSet::Destructor` has a *second* xref chain through
`FUN_0056C2E0` β `CBitmap__Constructor_Maybe` (0x56C060), which sits
in the DisplayGfx vtable .rdata block at slot index 38 (offset 0x98
past the vtable base 0x66A218). Verified that chain is structurally
dead by byte-pattern searching WA.exe for `FF 90 98 00 00 00`
(CALL [reg+0x98]) and `8B 4? 98` (MOV reg,[reg+0x98]) β neither
matches any DisplayGfx-related code. Slots 38 and 39 of the
DisplayGfx vtable were emitted into .rdata by the compiler but have
no live caller in shipping WA β they're vestigial vtable entries
from a removed feature. So the trap is safe.
Verified hβ¦
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: 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 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: 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β¦
chore: trap DisplayGfx::LoadSpriteEx (slot 30) as confirmed-deadβ
Static analysis (no `call [reg+0x78]` reaches a DisplayGfx in WA.exe, no
Rust caller in the workspace) plus headless+headful replay tests and
manual gameplay testing all show the slot is never invoked. Trap it so
any future caller β Rust or WA β produces an immediate panic with stack
trace pointing at the unexpected entry, signalling the moment a real
port becomes necessary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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β
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>
refactor: use typed pointers throughout DisplayBase and sprite cacheβ
- Make DisplayBase<V: Vtable> generic over vtable type (like CTask<V>, BitGrid<V>)
- Eliminate DDDisplay wrapper type β DisplayGfx is now the direct this type
for all display vtable methods, removing constant as_gfx() casts
- DisplayGfx.base is now DisplayBase<*const DisplayVtable> (38-slot vtable)
- bind_DisplayVtable!(DisplayGfx, base.vtable) uses nested field path
- Rename DDDisplayVtable β DisplayVtable, dd_display.rs β display_vtable.rs
- Rename DDDisplayWrapper β RenderContext (manages framebuffer + dispatches
to renderer backends β the DD prefix was misleading since WA supports
multiple graphics backends), display_wrapper.rs β render_context.rs
- Update all address constants (DDISPLAY_* β DISPLAY_GFX_*, etc.)
- Rename 51 functions in Ghidra to match
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: add 0x0F (two-byte opcode prefix) to valid prologue bytesβ
Functions starting with MOVZX/MOVSX (0x0F B6/0x0F BE) were flagged as
unexpected prologues. This is valid for MSVC-generated code.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port bitmap sprite path β slot 19 has no fallthroughβ
Replace the last fallthrough in DDDisplay::BlitSprite (slot 19) with
direct calls to WA's bitmap rendering functions via naked asm bridges:
- GetBitmapSpriteInfo (usercall EAX=obj, EDX=palette, RET 0x18)
- BlitBitmapClipped (usercall EAX=this, EDX=width, RET 0x14)
- BlitBitmapTiled (usercall EAX=x, EDI=width, RET 0x10)
Slot 19 no longer saves or calls the original vtable function pointer.
Both slots 19 and 20 are now fully dispatched through Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port DDDisplay slots 5, 23, 24 β layer management and palette updateβ
Port set_active_layer (trivial field read), update_palette (iterates
PaletteContext cache to copy RGBX entries into DisplayGfx palette, calls
PaletteCommit via usercall bridge), and set_layer_visibility (dispatches
to set_active_layer + update_palette).
Improve type safety: use typed PaletteContext struct instead of raw pointer
arithmetic, rename DisplayBase fields (font_table, layer_contexts,
layer_visibility) and DisplayGfx palette dirty range fields.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port stippled and tiled blit β remove fallthrough in slots 19/20β
Port DDDisplay__BlitStippled (per-pixel checkerboard XOR pattern) and
DDDisplay__BlitTiled (horizontal sprite tiling) to pure Rust, eliminating
fallthrough to the original WA code for these modes.
Slot 19 (BlitSprite) now only falls through for bitmap sprites.
Slot 20 (DrawScaledSprite) has no fallthrough at all.
Adds 6 snapshot tests (4 stippled + 2 tiled) validated against WA ground
truth, and consolidates snapshot capture into a single OPENWA_CAPTURE_SNAPSHOTS
env var.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>