Replace raw message ID constants (2, 3, 0x54) with TaskMessage enum
variants (FrameFinish, RenderScene, SetWind) for readability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First vtable method hook using the new typed vtable infrastructure.
Replaces CTaskCloud::HandleMessage (vtable slot 2, 0x5484C0) via
vtable_replace! macro.
Rust handles:
- msg 2: position update — Y scroll, X wind drift with landscape
wrapping, wind acceleration convergence toward target (±0x147 step)
- msg 0x54: wind target update from message data
Render path (msg 3) delegates to original WA function (uses usercall
for RQ_DrawSpriteLocal). Base CTask::HandleMessage called for all
non-render messages.
Adds replacements/task/ module directory mirroring the core task
structure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add Vtable marker trait for CTask<V> type safety
Add unsafe trait Vtable as a bound on CTask<V> and CGameTask<V>,
preventing arbitrary types from being used as vtable parameters.
- *const c_void implements Vtable (the default/untyped case)
- #[vtable] proc macro auto-generates `impl Vtable for *const MyVTable`
- CTask<V: Vtable> and CGameTask<V: Vtable> enforce the bound
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add Task trait for ergonomic CTask base access
Add unsafe trait Task with default methods:
- task() / task_mut() — reference to CTask base
- as_task_ptr() / as_task_ptr_mut() — raw pointers
- ddgame() — direct DDGame pointer access
Implemented for all CTask subclasses (CTaskWorm, CTaskTeam,
CTaskMissile, CTaskArrow, CTaskMine, etc.) via unsafe impls.
Replace all .base.base chains in weapon.rs with trait methods:
- (*worm).base.base.ddgame → (*worm).ddgame()
- &(*worm).base.base as *const _ as *const CTask → (*worm).as_task_ptr()
- SharedDataTable::from_task(task as *const _ as ...) → from_task((*worm).as_task_ptr())
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make CTask generic over its vtable pointer type:
CTask<V: 'static = *const c_void>
This allows subclasses to specify their typed vtable:
CTaskTeam { base: CTask<*const CTaskTeamVTable>, ... }
Changes:
FieldRegistry derive macro (openwa-derive):
- Handle generic type params by substituting defaults in size_of
expressions. offset_of uses the bare struct name (Rust applies
defaults). Builds a generic_defaults map from TypeParam::default.
bind macro (openwa-derive):
- Accept dotted field paths ($($vtable_field:ident).+) instead of
single idents, enabling bind_VTable!(Struct, base.vtable) for
inherited vtables.
CTask (openwa-core):
- Add generic parameter V with default *const c_void. All existing
code using bare CTask continues to work unchanged.
CTaskTeam (openwa-core):
- Use CTask<*const CTaskTeamVTable> as base type
- Replace manual vtable() and handle_message() with bind macro
- bind_CTaskTeamVTable!(CTaskTeam, base.vtable) generates typed
method wrappers automati…
feat: add CTaskTeamVTable and typed handle_message dispatch
Define CTaskTeamVTable (12 slots, vtable at 0x669EE4) via #[vtable] macro
with typed signatures for handle_message (slot 2), write_replay_state,
free, get_entity_data, and process_frame.
Add CTaskTeam::vtable() accessor and CTaskTeam::handle_message() method
that replaces raw vtable pointer arithmetic in weapon fire handlers.
Extract lookup_team_task() helper for SharedData CTaskTeam lookup.
Note: bind_CTaskTeamVTable! not used because CTask.vtable is *mut u8
(FieldRegistry derive doesn't support generic structs, so CTask<V>
would require manual impls across the entire task hierarchy).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: split ddgame.rs (2240 lines) into 3 modules
Port the core weapon firing functions from WA.exe to Rust:
- CreateWeaponProjectile (0x51E0F0): full replacement — allocates CTaskMissile
(0x40C bytes), SharedData lookup for parent CTaskTurnGame, pool capacity
check, calls original constructor. All projectile weapons flow through this.
- ProjectileFire (0x51DFB0): full replacement — advances game RNG, computes
random spread angle via Fixed16.16 trig table interpolation, applies 2D
rotation matrix to template velocity, calls ProjectileFire_Single per shot.
Used by Uzi, Handgun, Minigun.
- CreateArrow (0x51ED90): full replacement — allocates CTaskArrow (0x168 bytes),
same pool/SharedData pattern as CWP. Used by Shotgun and Longbow (which fire
physically simulated arrow entities, NOT hitscan).
Also adds:
- WeaponSpawnData struct (11 DWORDs) replacing raw [u32; 11] in CTaskMissile
- CTaskArrow struct (0x168 bytes) with vtable at 0x664198
- CTaskMissile size corrected from 0x41C to 0x40C (matching actual allocation)
- Pas…
docs: update CLAUDE.md and debug-cli skill for new debug tools
Document inspect/objects commands, symbolic address expressions,
ValueKind enum, FieldFormatter trait, #[field(kind)] attribute,
and field_format.rs in both CLAUDE.md and the debug-cli skill.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: upgrade debug tools with registry, typed reflection, and symbolic addresses
Wire the address registry, field registries, typed vtables, and live
object tracker into all debug tools:
- Watchpoint stack traces now show symbolicated function names via
format_va() instead of raw hex addresses
- Validation auto-discovers vtables from the registry and validates
all named slots from #[vtable(...)] structs
- New ValueKind enum + FieldFormatter trait for typed field formatting:
scalars as decimal, Fixed as float, pointers resolved via registry,
CString as quoted strings. Extensible via inventory::submit!
- Snapshot fallback uses FieldRegistry for entity types without
hand-coded Snapshot impls (CTaskTeam, CTaskMine, etc.)
- New debug CLI commands: inspect (typed struct read), objects (list
tracked live objects)
- Symbolic address expressions: ddgame, ddgame+field_name,
ddgame->field_name work in all CLI commands
- New protocol messages: Inspect, ListObjects, ResolveAlias, ResolveField
- #[field(kind = "CString")] attribute for string fields in FieldRegist…
fix: trim trailing default bytes in V3 scheme serialization
V3 .wsc files only include extended options bytes that differ from
defaults. Parsing padded short files to full size, but to_bytes() wrote
the full payload back, breaking roundtrip equality.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: update CLAUDE.md for #[vtable(...)] macro and vtable_replace!
Add documentation for the vtable attribute macro, bind_! companion
macros, name-based vtable_replace!, and updated design conventions.
Remove outdated vtable guidance that predates the macro system.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add name-based slot resolution to vtable_replace! macro
vtable_replace! now accepts method names instead of slot indices.
Slot indices are resolved at compile time via offset_of!() on the
vtable struct, eliminating manual slot index lookups.
Before:
*vt.add(12) = load_wav as *const () as u32;
*vt.add(3) = play_sound as *const () as u32;
After:
vtable_replace!(DSSoundVtable, va::DS_SOUND_VTABLE, {
load_wav => load_wav,
play_sound => play_sound,
})?;
Index-based syntax (slot N => ...) still supported for edge cases.
Converted DSSound's 24-slot vtable patch as proof of concept.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add #[vtable(...)] attribute macro for typed vtable definitions
Add Address Registry & Pointer Identification section documenting
define_addresses!, #[derive(FieldRegistry)], and the query API.
Update crate architecture to include openwa-derive. Update key files
list with registry.rs, macros.rs, and mem.rs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: distribute task class addresses to their home modules
Move all CTask* class blocks from address.rs into their respective
task/ module files. address.rs re-exports via pub use so all existing
va::* call sites are unchanged.
Moved to task/base.rs: CTask, CTaskLand, CTaskDirt, CTaskSpriteAnim,
CTaskCPU, CTaskSeaBubble, + 10 ctor-only entities
Moved to task/game_task.rs: CGameTask
Moved to task/worm.rs: CTaskWorm
Moved to task/turn_game.rs: CTaskTurnGame
Moved to task/missile.rs: CTaskMissile
Moved to task/mine_oil_drum.rs: CTaskMine, CTaskOilDrum
Moved to task/supply_crate.rs: CTaskCrate
Moved to task/cloud.rs: CTaskCloud
Moved to task/filter.rs: CTaskFilter
Moved to task/fire.rs: CTaskFire
Moved to task/team.rs: CTaskTeam
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chore: remove diagnostic line from debug UI inspector
The raw fields grid now has a "Points to" column that uses
identify_pointer() to annotate each DWORD value:
- Static addresses: "→ CTASK_WORM_VTABLE"
- Heap objects with known vtable: "→ DSSound*"
- Fields inside tracked live objects: "→ DDGame.sound"
Pointer labels shown in light blue for visual distinction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add #[derive(FieldRegistry)] to all task entity structs
Apply FieldRegistry to every #[repr(C)] struct under task/:
CTaskMine, CTaskOilDrum, CTaskCrate, CTaskCloud, CTaskFilter,
CTaskFire, CTaskTeam, CTaskTurnGame, TurnGameCtx, BitGrid,
BungeeTrailTask, WeaponAimTask.
All entity types now have auto-generated field maps available in the
global registry, so the debug UI's raw memory view labels every known
field automatically.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: replace hardcoded debug UI field labels with registry lookups
Remove CGAMETASK_FIELD_LABELS and all per-entity extra_labels arrays
from the debug UI inspector. Field names are now resolved via
registry::field_at_inherited() which walks the CTask inheritance chain
(entity → CGameTask → CTask).
Add #[derive(FieldRegistry)] to CGameTask and CTaskMissile. Add
field_at_inherited() helper for inheritance-aware offset → name lookup.
Entity types without Rust struct definitions (CTaskMine, CTaskOilDrum,
CTaskCrate) will show base class fields only until their structs are
defined — but any new struct fields are automatically picked up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: rename detect_pointer/KnownPointer to identify_pointer/PointerIdentity
Live object tracker in registry.rs enables detect_pointer() to resolve
heap pointers into "DDGame.rng_state" style names:
- register_live_object() / unregister_live_object() for tracking
- identify_live_pointer() checks if a pointer falls inside any tracked
object and resolves the field name via StructFields
- detect_pointer() now checks live objects before vtable fallback
DDGameWrapper, DDGame, and GameSession are registered during
construct_ddgame_wrapper() in the wormkit DLL.
Also fixes proc macro to submit StructRegistration via inventory,
enabling struct_fields_for("DDGame") global lookups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: register struct field maps in global registry via inventory
The #[derive(FieldRegistry)] proc macro now also submits a
StructRegistration to the global inventory, enabling runtime lookup
by struct name or vtable address:
- struct_fields_for("DDGame") -> Option<&StructFields>
- struct_fields_for_vtable(0x0066_A30C) -> Option<&StructFields>
Uses a lazy HashMap<&str, &StructFields> built on first query.
This connects the field registry to detect_pointer() for future
"DDGame+0x45EC = rng_state" style output.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: correct misleading comment in FieldRegistry derive macro
New openwa-derive crate with FieldRegistry proc macro that generates
HasFieldRegistry impls for #[repr(C)] structs. Uses offset_of!() and
size_of() for compiler-verified offsets. Fields prefixed _unknown/_pad
are automatically excluded.
Applied to 5 key structs: DDGame, DDGameWrapper, GameSession, CTask,
CTaskWorm. Each now has a field_registry() -> &'static StructFields
method for runtime offset-to-name lookups.
This lays groundwork for typed vtable derive macros using the same
proc macro infrastructure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add structured address registry with runtime pointer identification
Replace flat pub const address definitions with define_addresses! macro
that generates both compile-time constants and a global queryable registry
via the inventory crate. ~200 addresses now have structured metadata
(kind, calling convention, class name).
Key additions:
- registry.rs: AddrEntry types, sorted lookup table, query API
(lookup_va, vtable_class_name, format_va, detect_pointer)
- define_addresses! macro: class blocks group vtable+ctor+vmethods,
standalone entries for functions/globals/data
- detect_pointer() in mem.rs: identifies static addresses by name,
heap objects by vtable class name
- classify_pointer() detail strings now include registry names
Eliminates three duplicated vtable→name mappings in snapshot.rs,
debugui/app.rs, and validation/mod.rs. All 488 existing va::* call
sites compile unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: resolve all clippy warnings across core, wormkit, and test-runner
- Remove unused DETACHED_PROCESS constant and unused `output` variable
- Remove unused DDGame import in fire_surrender
- Rename LPSECURITY_ATTRIBUTES to LpSecurityAttributes (camel case)
- Remove unused BOOL type alias in file_isolation
- Prefix unused `esp` with underscore in debug_watchpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
refactor: use chrono for timestamp in test runner
- Launcher: pass CREATE_NO_WINDOW | CREATE_SUSPENDED when creating
WA.exe in headless mode, plus SW_HIDE in STARTUPINFO
- Test runner: use CREATE_NO_WINDOW when spawning launcher
- run-tests.ps1: build test runner then run binary directly
Console/terminal windows no longer appear during headless tests.
WA's own "Loading" GUI dialog still flashes briefly (suppressing it
via ShowWindow hook causes WA to hang or throttle).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: update replay testing docs for new test runner
- CLAUDE.md: rewrite Replay Testing section with test runner as primary,
add openwa-test-runner to crate architecture, document env vars and
instructions for adding new replay tests
- run-tests.ps1: use cargo run to ensure binary is up-to-date
- SKILL.md: rewrite with test runner quick start, updated paths and options
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
add run-tests.ps1 convenience script for integration tests
- Launcher: use SW_HIDE for WA.exe window when OPENWA_HEADLESS=1 is set
- Test runner: use CREATE_NO_WINDOW when spawning launcher processes
Eliminates console windows and most WA windows during test runs.
The brief "Loading" dialog still flashes — hooking ShowWindow causes
WA to throttle (minimized) or hang (hidden), so it's left as-is.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: hook CreateFileA for per-instance landscape file isolation
Hook kernel32!CreateFileA in headless mode to redirect landscape
generation temp files (mono.tmp, land.dat, landgen.svg) to per-PID
paths. This prevents concurrent WA.exe instances from clobbering
each other's landscape data during parallel replay testing.
Also: update test runner to default j=4, clean up per-PID temp files
after test runs, remove unnecessary launch stagger comment.
Concurrent test results: 7 tests in ~12s (j=4) vs ~36s (serial).
Occasional flakiness at j=7 from unidentified remaining shared resource.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add Rust replay test runner with concurrent execution support
New crate: openwa-test-runner — discovers and runs all headless replay
tests, compares output logs byte-for-byte, prints pass/fail summary.
Concurrency infrastructure:
- Per-instance event names (OpenWA_HooksReady_{pid}) in launcher + DLL
- Configurable OpenWA.log path via OPENWA_LOG_PATH env var
- Per-instance playback.thm paths to avoid replay map data races
- 1s launch stagger for concurrent mode stability
Usage: openwa-test [-j N] [--no-build] [filter]
Default is serial (j=1). Concurrent mode (-j 4) gives ~3x speedup
but has a known flaky race in WA.exe startup (needs procmon investigation).
Also normalizes CRLF in log comparison for cross-platform robustness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: pad team names in replay log to match original WA formatting
The original WA pads quoted team names to the longest name width so
that [CPU X.XX] brackets align across all teams. Our replay loader
was missing this padding, causing log comparison failures with 6-team
replays where team names have different lengths.
Also adds bots2 replay test (6-team CPU torture test with custom scheme).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: port Surrender and Mail/Mine/Mole weapon handlers to pure Rust
Port two FireWeapon sub-handlers from WA bridge calls to pure Rust:
- Surrender (0x51E600): SharedData lookup + HandleMessage(0x29) + WormEntry counter
- Mail/Mine/Mole (0x51E670): version-conditional vtable call + HandleMessage(0x28) + counter
Fix replay loader V2 scheme bug: our code was clearing super weapon data
(memset 0x4C) for both V1 and V2 schemes when header < 0, but the original
WA only clears for V1. V2 schemes already contain valid super weapon data.
Other changes:
- Name WormEntry fields: effect_counter_04_Maybe (+0x04), turn_action_counter_Maybe (+0x08)
- Add TeamArenaRef::team_worm_mut() for mutable worm entry access
- Add mail_mine_mole replay test (Mail Strike, Mine Strike, Mole Squadron, Surrender)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs: update desync investigation with full resolution journal
Expands the document from "partial fix, investigation continues" to the
complete seven-bug resolution story. Adds phases 2-4 covering RNG tracing,
sub-object hashing breakthrough, hook binary search, and all root causes.
New lessons on Ghidra element indexing, buffer layout verification, and
the value of multiple test replays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The fire_send_team_message function wrote team_index to buf[8..12] but
the original writes it to buf[0..4]. The HandleMessage handler reads
from data[0], so it received 0 instead of the correct team index.
This caused Napalm Strike (and any weapon using the team message
pattern) to target the wrong team, leading to gameplay desync visible
in the longbow_water replay where the CPU fires Napalm Strike.
Also removes the passthrough workaround and debug bridge, restoring
the full Rust FireWeapon dispatch with logging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
test: add longbow_water and longbow_alt_theme replay tests
Two new replay files for regression testing:
- longbow_water: longbow fired at water (no terrain collision)
- longbow_alt_theme: longbow on a different theme (non-zero map_seed)
Expected logs generated from unmodded WA.exe 3.8.1 (Steam).
These replays exposed hook-level bugs (FireWeapon, ReplayLoader)
that the original longbow replay didn't trigger.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add hash_region_canonical() and hash_pointer_targets() to the snapshot
module. These follow every heap pointer in a struct, hash the first N
bytes of each target with pointer canonicalization, and produce
deterministic fingerprints for cross-run comparison.
Integrated into the snapshot capture() output as [SubObjectHashes].
This is the technique that found the arrow collision region desync —
sub-object content differences invisible to flat struct comparisons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: make FireWeapon hook a passthrough to fix gameplay desync
The Rust FireWeapon dispatch logic has an undiagnosed bug that causes
replay desyncs on weapon use (exposed by longbow_water replay). Made
the trampoline a jmp-to-original passthrough until the dispatch bug
is found. Dispatch functions kept with #![allow(dead_code)] for future
debugging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: correct ReplayLoader player array strides and map_seed handling
Two replay parsing bugs:
1. Player array stride: Ghidra decompiler shows element indices for typed
arrays (i*0x3C for u16, i*0x1E for u32) but code used them as byte
offsets. Correct byte stride is i*0x78 for all player fields. Caused
data corruption for player index 1+, leading to wrong team order.
2. Non-zero map_seed path: per-team weapon config reads from stream were
unimplemented. For map_seed != 0, fall back to original WA parser.
This fixed the longbow_alt_theme Frame 0 checksum mismatch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three constructor bugs found and fixed:
1. Allocation size 0x98B8 → 0x98D8: original malloc(0x98D8) but memset(0x98B8).
Our code allocated only 0x98B8, causing 32-byte heap overrun.
2. Wrong function after InitPaletteGradientSprites: called LoadingProgressTick
(0x5717A0) instead of DisplayGfx__InitTeamPaletteDisplayObjects (0x5703E0).
3. Arrow collision region SpriteRegion params were all wrong:
- ECX should be sprite_w/2 (was 0)
- EDX should be sprite_h - margin_h (was margin_h)
- p2/p3 should be margin values (were 0)
- p4 should be sprite_w - margin_w (was margin_w)
- p5 should be sprite_h/2 (was margin_h)
- p6 should be arrow sprite ptr (was landscape gfx_resource)
This was the root cause of the longbow replay desync — wrong collision
boxes caused different terrain collision detection and RNG divergence.
Found via sub-object hashing: comparing pointer-canonicalized hashes of
objects pointed to by DDGame between Rust and original constructors.
Co-…
Constructor correctness (match original WA behavior):
- Load debris.spr and back.spr unconditionally, not gfx_mode-gated
- Initialize PaletteContext for masks.img (word=1, word=0xFE, Init)
- Document PCLandscape param_5 as game_info+0xDAAC
Debug tooling improvements:
- Hardware watchpoints: EBP-based stack trace on every hit
- Watchpoint base selection: OPENWA_WATCH_WRAPPER, OPENWA_WATCH_DISPLAY
- Display watchpoint arming during constructor for field tracing
- Snapshot: walk parent chain to CTaskTurnGame (true entity root)
Add docs/re-notes/desync-investigation.md — writeup of the longbow
desync investigation methodology and findings.
NOTE: Longbow desync (4 checksum mismatches) is NOT yet resolved.
These changes are correctness fixes and tooling for continued work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents the debug server, CLI commands, frame breakpoints,
snapshot system, and key env vars (OPENWA_DEBUG_SERVER,
OPENWA_BREAK_FRAME, OPENWA_USE_ORIG_CTOR, OPENWA_WATCH_FRAME).
Also adds openwa-debug-cli and openwa-debug-proto to crate list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: pass ESI (layer_ctx) to LoadSprites usercall in DDGame constructor
GfxHandler__LoadSprites (0x570B50) is a usercall that reads ESI as the
display layer context. Our wa_load_sprites wrapper was declared as plain
stdcall, leaving ESI unset. This caused LoadSpriteFromVfs and
GfxResource__Create_Maybe to operate with garbage ESI, producing wrong
colour values (0xFFFFFFFF instead of palette indices 1-9).
Fixed by converting wa_load_sprites to a naked asm bridge that sets ESI
from a 5th parameter (layer_ctx) before calling the original function.
The gfx_color_table now matches the original constructor output.
Checksum mismatches persist — likely caused by other uncanonicalized
pointer-as-u32 fields in DDGame (like landscape_property). The snapshot
system confirms all gameplay state is identical.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: canonicalize landscape_property as pointer, remove arrow dump
landscape_property (DDGame+0x468) is a heap address stored as u32.
Show as <ptr>/null instead of raw value to avoid false diff positives.
Removed arrow_sprites/arrow_gfxdirs from snapshot — confirmed identical
between constructors, not involved in the desync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>