paavohtl Claude Opus 4.6 commited on
Commit
c84e63a
·
1 Parent(s): 095fbfc

docs: add sprite parser implementation plan

Browse files

6-task plan for porting WA's .spr sprite loading to Rust with
ParsedSprite parser in openwa-core and hook replacements in openwa-wormkit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

docs/superpowers/plans/2026-04-01-sprite-parser.md ADDED
@@ -0,0 +1,1041 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sprite Parser Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Port WA's `.spr` sprite parser to Rust — a clean `ParsedSprite` type for tooling, plus hook replacements for `ConstructSprite` and `ProcessSprite`.
6
+
7
+ **Architecture:** A shared `parse_spr_header()` function extracts metadata and data offsets from raw `.spr` bytes. `ParsedSprite::parse()` builds on it to produce an owned Rust struct with `Vec` data. The hook replacements use `parse_spr_header()` directly, operating on the raw buffer in-place for palette remapping and bitmap index translation.
8
+
9
+ **Tech Stack:** Rust, `openwa-core` (types/parser), `openwa-wormkit` (hooks), `minhook` (hooking), WA.exe runtime (PaletteContext__MapColor)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Responsibility |
16
+ |---|---|
17
+ | `crates/openwa-core/src/render/spr.rs` (create) | `SprHeader`, `SprError`, `parse_spr_header()`, `ParsedSprite`, `ParsedSprite::parse()` |
18
+ | `crates/openwa-core/src/render/mod.rs` (modify) | Add `pub mod spr;` and re-exports |
19
+ | `crates/openwa-core/src/address.rs` (modify) | Add `PALETTE_CONTEXT_MAP_COLOR`, `DISPLAYGFX_VTABLE` |
20
+ | `crates/openwa-wormkit/src/replacements/sprite.rs` (create) | Hook trampolines + `install()` for ConstructSprite, ProcessSprite |
21
+ | `crates/openwa-wormkit/src/replacements/mod.rs` (modify) | Wire `sprite::install()` |
22
+
23
+ ## Reference: .spr Binary Format
24
+
25
+ Derived from Ghidra disassembly of ProcessSprite (0x4FAB80). All offsets relative to start of raw data.
26
+
27
+ ```
28
+ +0x00 u32 (unused / magic — not validated by ProcessSprite)
29
+ +0x04 u32 data_size — total payload size, added to global counter
30
+ +0x08 u16 header_flags — stored at sprite+0x14
31
+ +0x0A u16 palette_entry_count
32
+ +0x0C 3*N palette RGB entries (3 bytes each: R, G, B)
33
+
34
+ [After palette, if header_flags & 0x4000:]
35
+ u16 secondary_frame_count
36
+ align cursor to 4-byte boundary (relative to data start)
37
+ N*12 secondary SpriteFrame entries (12 bytes each)
38
+
39
+ [Main frame header — 12 bytes:]
40
+ u16 unknown_08 → sprite+0x08
41
+ u16 fps → sprite+0x0A
42
+ u16 flags → sprite+0x10
43
+ u16 width → sprite+0x0C
44
+ u16 height → sprite+0x0E
45
+ u16 frame_count_raw → sprite+0x12/0x16 (negative = scaling)
46
+
47
+ align cursor to 4-byte boundary (relative to data start)
48
+ frame_count * 12 bytes of SpriteFrame metadata
49
+ remaining bytes: bitmap pixel data (8-bit palette indices)
50
+ ```
51
+
52
+ **Scaling:** If frame_count_raw has bit 15 set (negative as i16):
53
+ - `scale_x = ((frame_count_raw >> 8) & 0x7F) << 16 >> 5` (arithmetic shift)
54
+ - `scale_y = (frame_count_raw & 0x7F) << 16 >> 5`
55
+ - Actual frame_count = 1, is_scaled = true
56
+
57
+ **Palette remapping (WA runtime only):**
58
+ - `palette_data_ptr[0] = 0` (transparent index)
59
+ - For each entry 1..N: `palette_data_ptr[i] = PaletteContext__MapColor(this=ECX, rgb_u32)`
60
+ - Then ALL bitmap bytes remapped: `pixel = palette_data_ptr[pixel]`
61
+ - Bitmap remapping only happens when header_flags & 0x4000 is NOT set
62
+
63
+ ## Reference: Key WA Functions
64
+
65
+ | Address | Convention | Description |
66
+ |---|---|---|
67
+ | 0x4FAA30 | usercall(EAX=sprite, ECX=context), plain RET | ConstructSprite |
68
+ | 0x4FAB80 | usercall(EAX=sprite, ECX=palette_ctx) + 1 stack(raw_data), RET 0x4 | ProcessSprite |
69
+ | 0x5412B0 | thiscall(ECX=palette_ctx, stack=rgb_u32), RET 0x4, returns u8 | PaletteContext__MapColor |
70
+ | 0x66418C | data | Sprite vtable address |
71
+ | 0x664144 | data | DisplayGfx vtable address |
72
+
73
+ ---
74
+
75
+ ### Task 1: Add Address Constants
76
+
77
+ **Files:**
78
+ - Modify: `crates/openwa-core/src/address.rs`
79
+
80
+ - [ ] **Step 1: Add PALETTE_CONTEXT_MAP_COLOR and DISPLAYGFX_VTABLE**
81
+
82
+ In `address.rs`, find the `class "Sprite"` block and add the DisplayGfx vtable. Find the DDDisplay section and add the palette function:
83
+
84
+ ```rust
85
+ // In the class "Sprite" block, after PROCESS_SPRITE:
86
+ // (no change needed here, but add DisplayGfx vtable nearby)
87
+
88
+ // Add to the DDDisplay section (near PALETTE_CONTEXT_INIT):
89
+ /// PaletteContext__MapColor — thiscall(ECX=ctx, stack=rgb), RET 0x4, returns u8
90
+ fn/Thiscall PALETTE_CONTEXT_MAP_COLOR = 0x0054_12B0;
91
+
92
+ // Add DisplayGfx vtable as a standalone entry near SPRITE_VTABLE:
93
+ // In the class "Sprite" block:
94
+ /// DisplayGfx vtable (embedded in Sprite at +0x34)
95
+ vtable DISPLAYGFX_VTABLE = 0x0066_4144;
96
+ ```
97
+
98
+ Specifically, in the `class "Sprite"` block after `fn/Usercall PROCESS_SPRITE`, add:
99
+
100
+ ```rust
101
+ /// DisplayGfx vtable (embedded in Sprite at +0x34)
102
+ vtable DISPLAYGFX_VTABLE = 0x0066_4144;
103
+ ```
104
+
105
+ And near `PALETTE_CONTEXT_INIT` in the DDDisplay section, add:
106
+
107
+ ```rust
108
+ /// PaletteContext__MapColor — thiscall(palette_ctx, rgb_u32), returns nearest palette index
109
+ fn/Thiscall PALETTE_CONTEXT_MAP_COLOR = 0x0054_12B0;
110
+ ```
111
+
112
+ - [ ] **Step 2: Verify it compiles**
113
+
114
+ Run: `cargo build -p openwa-core 2>&1 | head -5`
115
+ Expected: Compiles without errors.
116
+
117
+ - [ ] **Step 3: Commit**
118
+
119
+ ```bash
120
+ git add crates/openwa-core/src/address.rs
121
+ git commit -m "feat: add PALETTE_CONTEXT_MAP_COLOR and DISPLAYGFX_VTABLE addresses"
122
+ ```
123
+
124
+ ---
125
+
126
+ ### Task 2: Create spr.rs — SprHeader and parse_spr_header()
127
+
128
+ **Files:**
129
+ - Create: `crates/openwa-core/src/render/spr.rs`
130
+ - Modify: `crates/openwa-core/src/render/mod.rs`
131
+
132
+ - [ ] **Step 1: Write the test for parse_spr_header**
133
+
134
+ Create `crates/openwa-core/src/render/spr.rs` with the error type, header struct, and a test:
135
+
136
+ ```rust
137
+ //! `.spr` sprite format parser.
138
+ //!
139
+ //! WA's sprite format stores paletted animated sprites with per-frame metadata.
140
+ //! This module provides both a clean Rust parser (`ParsedSprite`) for tooling
141
+ //! and shared metadata extraction (`parse_spr_header`) used by WormKit hook
142
+ //! replacements.
143
+
144
+ use crate::render::sprite::SpriteFrame;
145
+
146
+ /// Errors from `.spr` parsing.
147
+ #[derive(Debug)]
148
+ pub enum SprError {
149
+ /// Data too short to contain required fields.
150
+ TooShort {
151
+ expected: usize,
152
+ actual: usize,
153
+ },
154
+ /// Internal structure is inconsistent.
155
+ InvalidData(&'static str),
156
+ }
157
+
158
+ impl core::fmt::Display for SprError {
159
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160
+ match self {
161
+ SprError::TooShort { expected, actual } => {
162
+ write!(f, "spr data too short: need {} bytes, got {}", expected, actual)
163
+ }
164
+ SprError::InvalidData(msg) => write!(f, "invalid spr data: {}", msg),
165
+ }
166
+ }
167
+ }
168
+
169
+ /// Parsed metadata and byte offsets into raw `.spr` data.
170
+ ///
171
+ /// Produced by `parse_spr_header`. Contains all scalar fields and the byte
172
+ /// offsets needed to locate palette, frame, and bitmap data within the raw
173
+ /// buffer. No heap allocation — just arithmetic on the input slice.
174
+ #[derive(Debug, Clone)]
175
+ pub struct SprHeader {
176
+ /// Total payload size from `.spr` header (+0x04). Used for global counter.
177
+ pub data_size: u32,
178
+ /// Header flags from `.spr` (+0x08).
179
+ pub header_flags: u16,
180
+ /// Number of RGB palette entries.
181
+ pub palette_count: u16,
182
+ /// Unknown field stored at sprite+0x08.
183
+ pub unknown_08: u16,
184
+ /// Animation speed (frames per second).
185
+ pub fps: u16,
186
+ /// Sprite flags.
187
+ pub flags: u16,
188
+ /// Sprite width in pixels.
189
+ pub width: u16,
190
+ /// Sprite height in pixels.
191
+ pub height: u16,
192
+ /// Frame count (1 if scaled).
193
+ pub frame_count: u16,
194
+ /// Max frames (same as frame_count after processing).
195
+ pub max_frames: u16,
196
+ /// Scale X: `((raw >> 8 & 0x7F) << 16) >> 5`, or 0 if not scaled.
197
+ pub scale_x: u32,
198
+ /// Scale Y: `((raw & 0x7F) << 16) >> 5`, or 0 if not scaled.
199
+ pub scale_y: u32,
200
+ /// True if the frame_count word encoded scaling instead of a count.
201
+ pub is_scaled: bool,
202
+ /// Number of secondary frames (0 if header_flags & 0x4000 is clear).
203
+ pub secondary_frame_count: u16,
204
+ /// Byte offset of RGB palette data (3 bytes per entry).
205
+ pub palette_offset: usize,
206
+ /// Byte offset of secondary frame table (0 if none).
207
+ pub secondary_frame_offset: usize,
208
+ /// Byte offset of main SpriteFrame metadata array.
209
+ pub frame_meta_offset: usize,
210
+ /// Byte offset of bitmap pixel data.
211
+ pub bitmap_offset: usize,
212
+ /// Size of bitmap pixel data in bytes.
213
+ pub bitmap_size: usize,
214
+ }
215
+
216
+ /// Align `offset` up to the next 4-byte boundary.
217
+ fn align4(offset: usize) -> usize {
218
+ (offset + 3) & !3
219
+ }
220
+
221
+ /// Parse `.spr` header and compute data region offsets.
222
+ ///
223
+ /// This is a pure function with no WA dependencies. Both `ParsedSprite::parse`
224
+ /// and the WormKit ProcessSprite hook build on this.
225
+ pub fn parse_spr_header(data: &[u8]) -> Result<SprHeader, SprError> {
226
+ // Minimum: 4 (unused) + 4 (data_size) + 2 (header_flags) + 2 (palette_count) = 12
227
+ if data.len() < 12 {
228
+ return Err(SprError::TooShort { expected: 12, actual: data.len() });
229
+ }
230
+
231
+ let data_size = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
232
+ let header_flags = u16::from_le_bytes([data[8], data[9]]);
233
+ let palette_count = u16::from_le_bytes([data[10], data[11]]);
234
+
235
+ let palette_offset = 12; // 0x0C
236
+ let palette_end = palette_offset + palette_count as usize * 3;
237
+
238
+ if data.len() < palette_end {
239
+ return Err(SprError::TooShort { expected: palette_end, actual: data.len() });
240
+ }
241
+
242
+ let mut cursor = palette_end;
243
+
244
+ // Secondary frame table (if header_flags & 0x4000)
245
+ let mut secondary_frame_count: u16 = 0;
246
+ let mut secondary_frame_offset: usize = 0;
247
+
248
+ if header_flags & 0x4000 != 0 {
249
+ if data.len() < cursor + 2 {
250
+ return Err(SprError::TooShort { expected: cursor + 2, actual: data.len() });
251
+ }
252
+ secondary_frame_count = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
253
+ cursor = align4(cursor + 2);
254
+ secondary_frame_offset = cursor;
255
+ cursor += secondary_frame_count as usize * 12;
256
+ }
257
+
258
+ // Main frame header (12 bytes)
259
+ if data.len() < cursor + 12 {
260
+ return Err(SprError::TooShort { expected: cursor + 12, actual: data.len() });
261
+ }
262
+
263
+ let unknown_08 = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
264
+ let fps = u16::from_le_bytes([data[cursor + 2], data[cursor + 3]]);
265
+ let flags = u16::from_le_bytes([data[cursor + 4], data[cursor + 5]]);
266
+ let width = u16::from_le_bytes([data[cursor + 6], data[cursor + 7]]);
267
+ let height = u16::from_le_bytes([data[cursor + 8], data[cursor + 9]]);
268
+ let frame_count_raw = u16::from_le_bytes([data[cursor + 10], data[cursor + 11]]);
269
+
270
+ cursor += 12;
271
+
272
+ // Scale processing
273
+ let (frame_count, max_frames, scale_x, scale_y, is_scaled) =
274
+ if (frame_count_raw as i16) < 0 {
275
+ let sx = (((frame_count_raw >> 8) & 0x7F) as u32) << 16 >> 5;
276
+ let sy = ((frame_count_raw & 0x7F) as u32) << 16 >> 5;
277
+ (1u16, 1u16, sx, sy, true)
278
+ } else {
279
+ (frame_count_raw, frame_count_raw, 0u32, 0u32, false)
280
+ };
281
+
282
+ // Frame metadata (aligned to 4 bytes)
283
+ let frame_meta_offset = align4(cursor);
284
+ let bitmap_offset = frame_meta_offset + frame_count as usize * 12;
285
+
286
+ // bitmap_size: remaining bytes from data_size
287
+ // ProcessSprite computes: data_size - (bitmap_offset - data_start)
288
+ // But data_size is measured from offset +4, and data_start is offset 0.
289
+ // Actually WA computes: bitmap_dword_count = (data_size - bitmap_ptr + raw_data_ptr + 3) / 4
290
+ // which simplifies to: (data_size + 4 - bitmap_offset + 3) / 4 * 4
291
+ // For our purposes: bitmap extends from bitmap_offset to the end of known data.
292
+ let bitmap_size = if data.len() > bitmap_offset {
293
+ data.len() - bitmap_offset
294
+ } else {
295
+ 0
296
+ };
297
+
298
+ Ok(SprHeader {
299
+ data_size,
300
+ header_flags,
301
+ palette_count,
302
+ unknown_08,
303
+ fps,
304
+ flags,
305
+ width,
306
+ height,
307
+ frame_count,
308
+ max_frames,
309
+ scale_x,
310
+ scale_y,
311
+ is_scaled,
312
+ secondary_frame_count,
313
+ palette_offset,
314
+ secondary_frame_offset,
315
+ frame_meta_offset,
316
+ bitmap_offset,
317
+ bitmap_size,
318
+ })
319
+ }
320
+
321
+ #[cfg(test)]
322
+ mod tests {
323
+ use super::*;
324
+
325
+ /// Build a minimal single-frame .spr with given dimensions and palette.
326
+ fn make_spr(width: u16, height: u16, palette_count: u16, frame_count: u16) -> Vec<u8> {
327
+ let mut data = Vec::new();
328
+
329
+ // +0x00: unused magic
330
+ data.extend_from_slice(&0u32.to_le_bytes());
331
+ // +0x04: data_size (we'll fix up later)
332
+ let data_size_pos = data.len();
333
+ data.extend_from_slice(&0u32.to_le_bytes());
334
+ // +0x08: header_flags
335
+ data.extend_from_slice(&0u16.to_le_bytes());
336
+ // +0x0A: palette_entry_count
337
+ data.extend_from_slice(&palette_count.to_le_bytes());
338
+ // +0x0C: palette RGB entries (all zeros)
339
+ for _ in 0..palette_count {
340
+ data.extend_from_slice(&[0u8; 3]);
341
+ }
342
+
343
+ // Main frame header (12 bytes)
344
+ data.extend_from_slice(&0u16.to_le_bytes()); // unknown_08
345
+ data.extend_from_slice(&10u16.to_le_bytes()); // fps = 10
346
+ data.extend_from_slice(&0u16.to_le_bytes()); // flags
347
+ data.extend_from_slice(&width.to_le_bytes());
348
+ data.extend_from_slice(&height.to_le_bytes());
349
+ data.extend_from_slice(&frame_count.to_le_bytes());
350
+
351
+ // Align to 4 bytes
352
+ while data.len() % 4 != 0 {
353
+ data.push(0);
354
+ }
355
+
356
+ // Frame metadata (12 bytes per frame)
357
+ for i in 0..frame_count {
358
+ let offset = i as u32 * width as u32 * height as u32;
359
+ data.extend_from_slice(&offset.to_le_bytes()); // bitmap_offset
360
+ data.extend_from_slice(&0u16.to_le_bytes()); // start_x
361
+ data.extend_from_slice(&0u16.to_le_bytes()); // start_y
362
+ data.extend_from_slice(&width.to_le_bytes()); // end_x
363
+ data.extend_from_slice(&height.to_le_bytes()); // end_y
364
+ }
365
+
366
+ // Bitmap data (width * height * frame_count bytes)
367
+ let bitmap_bytes = width as usize * height as usize * frame_count as usize;
368
+ data.resize(data.len() + bitmap_bytes, 0);
369
+
370
+ // Fix up data_size (everything from +4 onward)
371
+ let total = (data.len() - 4) as u32;
372
+ data[data_size_pos..data_size_pos + 4].copy_from_slice(&total.to_le_bytes());
373
+
374
+ data
375
+ }
376
+
377
+ #[test]
378
+ fn parse_single_frame() {
379
+ let data = make_spr(32, 16, 4, 1);
380
+ let hdr = parse_spr_header(&data).unwrap();
381
+
382
+ assert_eq!(hdr.width, 32);
383
+ assert_eq!(hdr.height, 16);
384
+ assert_eq!(hdr.fps, 10);
385
+ assert_eq!(hdr.palette_count, 4);
386
+ assert_eq!(hdr.frame_count, 1);
387
+ assert!(!hdr.is_scaled);
388
+ assert_eq!(hdr.scale_x, 0);
389
+ assert_eq!(hdr.scale_y, 0);
390
+ assert_eq!(hdr.secondary_frame_count, 0);
391
+ assert_eq!(hdr.palette_offset, 12);
392
+ assert!(hdr.frame_meta_offset > 0);
393
+ assert!(hdr.bitmap_offset > hdr.frame_meta_offset);
394
+ }
395
+
396
+ #[test]
397
+ fn parse_multi_frame() {
398
+ let data = make_spr(8, 8, 2, 5);
399
+ let hdr = parse_spr_header(&data).unwrap();
400
+
401
+ assert_eq!(hdr.frame_count, 5);
402
+ assert_eq!(hdr.max_frames, 5);
403
+ // 5 frames * 12 bytes each = 60 bytes of frame metadata
404
+ assert_eq!(hdr.bitmap_offset - hdr.frame_meta_offset, 60);
405
+ }
406
+
407
+ #[test]
408
+ fn parse_scaled_sprite() {
409
+ // frame_count_raw = 0x8040: bit15 set, scale_x_raw = 0, scale_y_raw = 0x40
410
+ // Actually: high byte = 0x80, (0x80 >> 0) & 0x7F for scale from bits 8..14
411
+ // Let's use 0xA050: high byte 0xA0, low byte 0x50
412
+ // scale_x_raw = (0xA050 >> 8) & 0x7F = 0xA0 & 0x7F = 0x20
413
+ // scale_y_raw = 0xA050 & 0x7F = 0x50
414
+ let mut data = make_spr(32, 32, 0, 1);
415
+ // Overwrite frame_count_raw in the main frame header
416
+ // palette_count = 0, so palette_end = 12
417
+ // main frame header starts at 12, frame_count is at offset 10 within it = byte 22
418
+ data[22] = 0x50; // low byte
419
+ data[23] = 0xA0; // high byte (bit 15 set)
420
+
421
+ let hdr = parse_spr_header(&data).unwrap();
422
+
423
+ assert!(hdr.is_scaled);
424
+ assert_eq!(hdr.frame_count, 1);
425
+ assert_eq!(hdr.max_frames, 1);
426
+ // scale_x = (0x20 << 16) >> 5 = 0x200000 >> 5 = 0x10000
427
+ assert_eq!(hdr.scale_x, (0x20u32 << 16) >> 5);
428
+ // scale_y = (0x50 << 16) >> 5 = 0x500000 >> 5 = 0x28000
429
+ assert_eq!(hdr.scale_y, (0x50u32 << 16) >> 5);
430
+ }
431
+
432
+ #[test]
433
+ fn parse_too_short() {
434
+ assert!(parse_spr_header(&[0; 4]).is_err());
435
+ }
436
+ }
437
+ ```
438
+
439
+ - [ ] **Step 2: Add module to render/mod.rs**
440
+
441
+ In `crates/openwa-core/src/render/mod.rs`, add:
442
+
443
+ ```rust
444
+ pub mod spr;
445
+ ```
446
+
447
+ And add re-exports:
448
+
449
+ ```rust
450
+ pub use spr::{ParsedSprite, SprError, SprHeader};
451
+ ```
452
+
453
+ (The `ParsedSprite` re-export will fail initially since we haven't defined it yet — that's fine, comment it out or add it in Task 3.)
454
+
455
+ - [ ] **Step 3: Run tests**
456
+
457
+ Run: `cargo test -p openwa-core -- spr::tests`
458
+ Expected: All 4 tests pass.
459
+
460
+ - [ ] **Step 4: Commit**
461
+
462
+ ```bash
463
+ git add crates/openwa-core/src/render/spr.rs crates/openwa-core/src/render/mod.rs
464
+ git commit -m "feat: add .spr header parser with SprHeader and parse_spr_header()"
465
+ ```
466
+
467
+ ---
468
+
469
+ ### Task 3: Add ParsedSprite and ParsedSprite::parse()
470
+
471
+ **Files:**
472
+ - Modify: `crates/openwa-core/src/render/spr.rs`
473
+ - Modify: `crates/openwa-core/src/render/mod.rs`
474
+
475
+ - [ ] **Step 1: Write failing test for ParsedSprite::parse**
476
+
477
+ Add to the `tests` module in `spr.rs`:
478
+
479
+ ```rust
480
+ #[test]
481
+ fn parsed_sprite_single_frame() {
482
+ let mut data = make_spr(4, 4, 3, 1);
483
+ // Fill palette with known RGB values
484
+ data[12] = 0xFF; data[13] = 0x00; data[14] = 0x00; // entry 0: red
485
+ data[15] = 0x00; data[16] = 0xFF; data[17] = 0x00; // entry 1: green
486
+ data[18] = 0x00; data[19] = 0x00; data[20] = 0xFF; // entry 2: blue
487
+
488
+ let parsed = ParsedSprite::parse(&data).unwrap();
489
+ assert_eq!(parsed.width, 4);
490
+ assert_eq!(parsed.height, 4);
491
+ assert_eq!(parsed.palette.len(), 3);
492
+ assert_eq!(parsed.palette[0], [0xFF, 0x00, 0x00]);
493
+ assert_eq!(parsed.palette[1], [0x00, 0xFF, 0x00]);
494
+ assert_eq!(parsed.palette[2], [0x00, 0x00, 0xFF]);
495
+ assert_eq!(parsed.frames.len(), 1);
496
+ assert_eq!(parsed.bitmap.len(), 16); // 4*4 pixels
497
+ }
498
+
499
+ #[test]
500
+ fn parsed_sprite_secondary_frames() {
501
+ let mut data = Vec::new();
502
+
503
+ // Header
504
+ data.extend_from_slice(&0u32.to_le_bytes()); // unused
505
+ data.extend_from_slice(&0u32.to_le_bytes()); // data_size (fix later)
506
+ data.extend_from_slice(&0x4000u16.to_le_bytes()); // header_flags: secondary frames
507
+ data.extend_from_slice(&0u16.to_le_bytes()); // palette_count = 0
508
+
509
+ // Secondary frame table
510
+ data.extend_from_slice(&2u16.to_le_bytes()); // 2 secondary frames
511
+ // Align to 4 bytes
512
+ while data.len() % 4 != 0 { data.push(0); }
513
+ // 2 secondary SpriteFrame entries (12 bytes each)
514
+ for _ in 0..2 {
515
+ data.extend_from_slice(&[0u8; 12]);
516
+ }
517
+
518
+ // Main frame header
519
+ data.extend_from_slice(&0u16.to_le_bytes()); // unknown_08
520
+ data.extend_from_slice(&15u16.to_le_bytes()); // fps
521
+ data.extend_from_slice(&0u16.to_le_bytes()); // flags
522
+ data.extend_from_slice(&8u16.to_le_bytes()); // width
523
+ data.extend_from_slice(&8u16.to_le_bytes()); // height
524
+ data.extend_from_slice(&1u16.to_le_bytes()); // frame_count
525
+
526
+ while data.len() % 4 != 0 { data.push(0); }
527
+ // 1 main SpriteFrame
528
+ data.extend_from_slice(&0u32.to_le_bytes()); // bitmap_offset
529
+ data.extend_from_slice(&0u16.to_le_bytes()); // start_x
530
+ data.extend_from_slice(&0u16.to_le_bytes()); // start_y
531
+ data.extend_from_slice(&8u16.to_le_bytes()); // end_x
532
+ data.extend_from_slice(&8u16.to_le_bytes()); // end_y
533
+
534
+ // Bitmap (8*8 = 64 bytes)
535
+ data.resize(data.len() + 64, 0);
536
+
537
+ // Fix data_size
538
+ let total = (data.len() - 4) as u32;
539
+ data[4..8].copy_from_slice(&total.to_le_bytes());
540
+
541
+ let parsed = ParsedSprite::parse(&data).unwrap();
542
+ assert_eq!(parsed.secondary_frames.len(), 2);
543
+ assert_eq!(parsed.frames.len(), 1);
544
+ assert_eq!(parsed.header_flags, 0x4000);
545
+ }
546
+ ```
547
+
548
+ - [ ] **Step 2: Implement ParsedSprite**
549
+
550
+ Add above the `#[cfg(test)]` block in `spr.rs`:
551
+
552
+ ```rust
553
+ /// A fully parsed `.spr` sprite with owned data.
554
+ ///
555
+ /// Contains raw RGB palette entries (not remapped to WA's display palette),
556
+ /// raw bitmap pixel indices, and frame metadata. Suitable for standalone
557
+ /// tooling, tests, and debugging.
558
+ ///
559
+ /// For WormKit hook replacements, `parse_spr_header` is used directly
560
+ /// with in-place buffer operations instead.
561
+ #[derive(Debug, Clone)]
562
+ pub struct ParsedSprite {
563
+ /// Unknown field at sprite+0x08.
564
+ pub unknown_08: u16,
565
+ /// Animation frames per second.
566
+ pub fps: u16,
567
+ /// Sprite flags.
568
+ pub flags: u16,
569
+ /// Header flags from `.spr` file.
570
+ pub header_flags: u16,
571
+ /// Sprite width in pixels.
572
+ pub width: u16,
573
+ /// Sprite height in pixels.
574
+ pub height: u16,
575
+ /// Number of animation frames.
576
+ pub frame_count: u16,
577
+ /// Maximum frame count.
578
+ pub max_frames: u16,
579
+ /// Scale X (`(raw << 16) >> 5`), or 0 if not scaled.
580
+ pub scale_x: u32,
581
+ /// Scale Y (`(raw << 16) >> 5`), or 0 if not scaled.
582
+ pub scale_y: u32,
583
+ /// Whether the sprite uses scaling instead of animation.
584
+ pub is_scaled: bool,
585
+ /// RGB palette entries (3 bytes each). NOT remapped to display palette.
586
+ pub palette: Vec<[u8; 3]>,
587
+ /// Per-frame metadata (bounding box + bitmap offset).
588
+ pub frames: Vec<SpriteFrame>,
589
+ /// Secondary frame table (non-empty iff header_flags & 0x4000).
590
+ pub secondary_frames: Vec<SpriteFrame>,
591
+ /// Bitmap pixel data — 8-bit indices into the sprite's local palette.
592
+ pub bitmap: Vec<u8>,
593
+ /// Total data size from `.spr` header (for global counter updates).
594
+ pub data_size: u32,
595
+ }
596
+
597
+ impl ParsedSprite {
598
+ /// Parse a `.spr` file from raw bytes.
599
+ ///
600
+ /// Returns owned data with no WA runtime dependencies.
601
+ pub fn parse(data: &[u8]) -> Result<Self, SprError> {
602
+ let hdr = parse_spr_header(data)?;
603
+
604
+ // Extract palette RGB triples
605
+ let mut palette = Vec::with_capacity(hdr.palette_count as usize);
606
+ let mut offset = hdr.palette_offset;
607
+ for _ in 0..hdr.palette_count {
608
+ palette.push([data[offset], data[offset + 1], data[offset + 2]]);
609
+ offset += 3;
610
+ }
611
+
612
+ // Extract secondary frames
613
+ let mut secondary_frames = Vec::new();
614
+ if hdr.secondary_frame_count > 0 {
615
+ secondary_frames.reserve(hdr.secondary_frame_count as usize);
616
+ let mut off = hdr.secondary_frame_offset;
617
+ for _ in 0..hdr.secondary_frame_count {
618
+ secondary_frames.push(read_sprite_frame(data, off));
619
+ off += 12;
620
+ }
621
+ }
622
+
623
+ // Extract main frames
624
+ let mut frames = Vec::with_capacity(hdr.frame_count as usize);
625
+ let mut off = hdr.frame_meta_offset;
626
+ for _ in 0..hdr.frame_count {
627
+ frames.push(read_sprite_frame(data, off));
628
+ off += 12;
629
+ }
630
+
631
+ // Extract bitmap data
632
+ let bitmap_end = data.len().min(hdr.bitmap_offset + hdr.bitmap_size);
633
+ let bitmap = data[hdr.bitmap_offset..bitmap_end].to_vec();
634
+
635
+ Ok(ParsedSprite {
636
+ unknown_08: hdr.unknown_08,
637
+ fps: hdr.fps,
638
+ flags: hdr.flags,
639
+ header_flags: hdr.header_flags,
640
+ width: hdr.width,
641
+ height: hdr.height,
642
+ frame_count: hdr.frame_count,
643
+ max_frames: hdr.max_frames,
644
+ scale_x: hdr.scale_x,
645
+ scale_y: hdr.scale_y,
646
+ is_scaled: hdr.is_scaled,
647
+ palette,
648
+ frames,
649
+ secondary_frames,
650
+ bitmap,
651
+ data_size: hdr.data_size,
652
+ })
653
+ }
654
+ }
655
+
656
+ /// Read a SpriteFrame from 12 bytes at the given offset.
657
+ fn read_sprite_frame(data: &[u8], off: usize) -> SpriteFrame {
658
+ SpriteFrame {
659
+ bitmap_offset: u32::from_le_bytes([data[off], data[off+1], data[off+2], data[off+3]]),
660
+ start_x: u16::from_le_bytes([data[off+4], data[off+5]]),
661
+ start_y: u16::from_le_bytes([data[off+6], data[off+7]]),
662
+ end_x: u16::from_le_bytes([data[off+8], data[off+9]]),
663
+ end_y: u16::from_le_bytes([data[off+10], data[off+11]]),
664
+ }
665
+ }
666
+ ```
667
+
668
+ - [ ] **Step 3: Update render/mod.rs re-exports**
669
+
670
+ ```rust
671
+ pub use spr::{ParsedSprite, SprError, SprHeader};
672
+ ```
673
+
674
+ - [ ] **Step 4: Run tests**
675
+
676
+ Run: `cargo test -p openwa-core -- spr::tests`
677
+ Expected: All 6 tests pass (4 from Task 2 + 2 new).
678
+
679
+ - [ ] **Step 5: Commit**
680
+
681
+ ```bash
682
+ git add crates/openwa-core/src/render/spr.rs crates/openwa-core/src/render/mod.rs
683
+ git commit -m "feat: add ParsedSprite type with owned .spr parser"
684
+ ```
685
+
686
+ ---
687
+
688
+ ### Task 4: ConstructSprite Hook Replacement
689
+
690
+ **Files:**
691
+ - Create: `crates/openwa-wormkit/src/replacements/sprite.rs`
692
+
693
+ - [ ] **Step 1: Create sprite.rs with ConstructSprite hook**
694
+
695
+ Create `crates/openwa-wormkit/src/replacements/sprite.rs`:
696
+
697
+ ```rust
698
+ //! Sprite loading hook replacements.
699
+ //!
700
+ //! Replaces ConstructSprite (0x4FAA30) and ProcessSprite (0x4FAB80) with
701
+ //! Rust implementations. Uses `parse_spr_header` from openwa-core for
702
+ //! format parsing.
703
+
704
+ use openwa_core::address::va;
705
+ use openwa_core::rebase::rb;
706
+
707
+ use crate::hook::{self, usercall_trampoline};
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // ConstructSprite (0x4FAA30) — usercall(EAX=sprite, ECX=context), plain RET
711
+ // ---------------------------------------------------------------------------
712
+
713
+ usercall_trampoline!(fn trampoline_construct_sprite; impl_fn = construct_sprite_impl;
714
+ regs = [eax, ecx]);
715
+
716
+ unsafe extern "cdecl" fn construct_sprite_impl(sprite: u32, context: u32) {
717
+ let p = sprite as *mut u8;
718
+
719
+ // Zero the entire 0x70-byte struct first
720
+ core::ptr::write_bytes(p, 0, 0x70);
721
+
722
+ // Vtable
723
+ *(p as *mut u32) = rb(va::SPRITE_VTABLE);
724
+ // Context pointer (+0x04)
725
+ *(p.add(0x04) as *mut u32) = context;
726
+ // DisplayGfx vtable (+0x34)
727
+ *(p.add(0x34) as *mut u32) = rb(va::DISPLAYGFX_VTABLE);
728
+ // _unknown_38 = 1 (+0x38)
729
+ *(p.add(0x38) as *mut u32) = 1;
730
+ // _unknown_40 = 8 (+0x40)
731
+ *(p.add(0x40) as *mut u32) = 8;
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Installation
736
+ // ---------------------------------------------------------------------------
737
+
738
+ pub fn install() -> Result<(), String> {
739
+ unsafe {
740
+ let _ = hook::install(
741
+ "ConstructSprite",
742
+ va::CONSTRUCT_SPRITE,
743
+ trampoline_construct_sprite as *const (),
744
+ )?;
745
+ }
746
+ Ok(())
747
+ }
748
+ ```
749
+
750
+ - [ ] **Step 2: Verify it compiles**
751
+
752
+ Run: `cargo build -p openwa-wormkit --release 2>&1 | tail -5`
753
+ Expected: Compiles (sprite.rs is not yet in mod.rs, so it won't be included — this just checks syntax).
754
+
755
+ Actually, it won't compile unless added to mod.rs. Skip to step 3.
756
+
757
+ - [ ] **Step 3: Add to mod.rs and verify**
758
+
759
+ In `crates/openwa-wormkit/src/replacements/mod.rs`, add `mod sprite;` and add `sprite::install()?;` in `install_all()` after `render::install()?;`.
760
+
761
+ Run: `cargo build -p openwa-wormkit --release 2>&1 | tail -5`
762
+ Expected: Compiles without errors.
763
+
764
+ - [ ] **Step 4: Commit**
765
+
766
+ ```bash
767
+ git add crates/openwa-wormkit/src/replacements/sprite.rs crates/openwa-wormkit/src/replacements/mod.rs
768
+ git commit -m "feat: add ConstructSprite hook replacement"
769
+ ```
770
+
771
+ ---
772
+
773
+ ### Task 5: ProcessSprite Hook Replacement
774
+
775
+ **Files:**
776
+ - Modify: `crates/openwa-wormkit/src/replacements/sprite.rs`
777
+
778
+ This is the most complex task. ProcessSprite:
779
+ 1. Reads header metadata via `parse_spr_header`
780
+ 2. Builds a palette lookup table in-place (calling PaletteContext__MapColor)
781
+ 3. Sets all sprite struct fields + pointers into the raw buffer
782
+ 4. Remaps bitmap pixels through the lookup table (when no secondary frames)
783
+ 5. Computes and updates 4 global counters
784
+
785
+ - [ ] **Step 1: Add PaletteContext__MapColor bridge**
786
+
787
+ Add to `sprite.rs`, above the `install()` function:
788
+
789
+ ```rust
790
+ // ---------------------------------------------------------------------------
791
+ // PaletteContext__MapColor bridge (0x5412B0)
792
+ // thiscall(ECX=palette_ctx, stack=rgb_u32), RET 0x4, returns u8
793
+ // ---------------------------------------------------------------------------
794
+
795
+ static mut PALETTE_MAP_COLOR_ADDR: u32 = 0;
796
+
797
+ /// Call WA's PaletteContext__MapColor to find the nearest display palette index.
798
+ #[cfg(target_arch = "x86")]
799
+ unsafe fn palette_map_color(palette_ctx: u32, rgb: u32) -> u8 {
800
+ let f: unsafe extern "thiscall" fn(u32, u32) -> u32 =
801
+ core::mem::transmute(PALETTE_MAP_COLOR_ADDR as usize);
802
+ f(palette_ctx, rgb) as u8
803
+ }
804
+ ```
805
+
806
+ Note: we use `-> u32` for the transmuted function since WA returns the value in EAX (full register), and we mask to u8. This avoids potential ABI issues with `-> u8` on MSVC x86.
807
+
808
+ - [ ] **Step 2: Add ProcessSprite trampoline and implementation**
809
+
810
+ Add to `sprite.rs`:
811
+
812
+ ```rust
813
+ // ---------------------------------------------------------------------------
814
+ // ProcessSprite (0x4FAB80)
815
+ // usercall(EAX=sprite, ECX=palette_ctx) + 1 stack(raw_data), RET 0x4
816
+ // ---------------------------------------------------------------------------
817
+
818
+ usercall_trampoline!(fn trampoline_process_sprite; impl_fn = process_sprite_impl;
819
+ regs = [eax, ecx]; stack_params = 1; ret_bytes = "0x4");
820
+
821
+ unsafe extern "cdecl" fn process_sprite_impl(
822
+ sprite: u32,
823
+ palette_ctx: u32,
824
+ raw_data: u32,
825
+ ) -> u32 {
826
+ use openwa_core::render::spr::parse_spr_header;
827
+
828
+ let p = sprite as *mut u8;
829
+ let data_ptr = raw_data as *const u8;
830
+
831
+ // We need the data as a slice. Use data_size from header to determine length.
832
+ // data_size is at raw_data + 4.
833
+ let data_size = *(data_ptr.add(4) as *const u32);
834
+ // Total buffer: data_size covers from offset +4 onward, so total = data_size + 4.
835
+ // But we also need the initial 4 unused bytes, so slice length = data_size + 4.
836
+ // However, data_size might not include the bitmap — let's be safe and use
837
+ // data_size + 4 as the slice length (this matches what WA allocated).
838
+ let data_len = (data_size + 4) as usize;
839
+ let data = core::slice::from_raw_parts(data_ptr, data_len);
840
+
841
+ let hdr = match parse_spr_header(data) {
842
+ Ok(h) => h,
843
+ Err(e) => {
844
+ // This should never happen with valid WA game data
845
+ panic!("ProcessSprite: failed to parse .spr data: {}", e);
846
+ }
847
+ };
848
+
849
+ // --- Update global counter: data_size ---
850
+ let g_data_bytes = rb(va::G_SPRITE_DATA_BYTES) as *mut u32;
851
+ *g_data_bytes = (*g_data_bytes).wrapping_add(data_size);
852
+
853
+ // --- Store raw_frame_header_ptr (points to header_flags in raw buffer) ---
854
+ *(p.add(0x60) as *mut *const u8) = data_ptr.add(8);
855
+
856
+ // --- Store header_flags ---
857
+ *(p.add(0x14) as *mut u16) = hdr.header_flags;
858
+
859
+ // --- Build palette lookup table IN PLACE ---
860
+ // palette_data_ptr points to raw_data + 0x0A (the palette_entry_count field).
861
+ // WA overwrites this region with a 1-indexed palette index lookup table:
862
+ // [0] = 0 (transparent)
863
+ // [1..N] = PaletteContext__MapColor(rgb) for each entry
864
+ let palette_base = data_ptr.add(0x0A) as *mut u8;
865
+ *(p.add(0x68) as *mut *mut u8) = palette_base;
866
+
867
+ // Update global counter: palette bytes
868
+ let g_palette = rb(va::G_SPRITE_PALETTE_BYTES) as *mut u32;
869
+ *g_palette = (*g_palette).wrapping_add(hdr.palette_count as u32 * 3);
870
+
871
+ // Write transparent index
872
+ *palette_base = 0;
873
+
874
+ // Map each RGB entry to display palette index
875
+ let rgb_start = data_ptr.add(hdr.palette_offset);
876
+ for i in 0..hdr.palette_count as usize {
877
+ // Read 4 bytes (3 RGB + 1 from next entry, matching WA behavior)
878
+ let rgb_val = *(rgb_start.add(i * 3) as *const u32);
879
+ let mapped = palette_map_color(palette_ctx, rgb_val);
880
+ *palette_base.add(1 + i) = mapped;
881
+ }
882
+
883
+ // --- Secondary frame table (if header_flags & 0x4000) ---
884
+ let has_secondary = hdr.header_flags & 0x4000 != 0;
885
+ if has_secondary {
886
+ *(p.add(0x30) as *mut u16) = hdr.secondary_frame_count;
887
+ *(p.add(0x2C) as *mut *const u8) = data_ptr.add(hdr.secondary_frame_offset);
888
+ }
889
+
890
+ // --- Main frame header fields ---
891
+ // Copy unknown_08 + fps as a single u32 (matching WA's 4-byte copy)
892
+ let frame_header_ptr = if has_secondary {
893
+ // After secondary frames
894
+ data_ptr.add(hdr.secondary_frame_offset + hdr.secondary_frame_count as usize * 12)
895
+ } else {
896
+ data_ptr.add(hdr.palette_offset + hdr.palette_count as usize * 3)
897
+ };
898
+
899
+ // WA copies 4 bytes at once: *(u32*)(sprite+8) = *(u32*)(frame_header)
900
+ *(p.add(0x08) as *mut u32) = *(frame_header_ptr as *const u32);
901
+ *(p.add(0x10) as *mut u16) = hdr.flags;
902
+ *(p.add(0x0C) as *mut u16) = hdr.width;
903
+ *(p.add(0x0E) as *mut u16) = hdr.height;
904
+ *(p.add(0x12) as *mut u16) = hdr.frame_count;
905
+ *(p.add(0x16) as *mut u16) = hdr.max_frames;
906
+
907
+ // --- Scale fields ---
908
+ if hdr.is_scaled {
909
+ *(p.add(0x1C) as *mut u32) = hdr.scale_x;
910
+ *(p.add(0x20) as *mut u32) = hdr.scale_y;
911
+ *(p.add(0x24) as *mut u32) = 1; // is_scaled
912
+ } else {
913
+ *(p.add(0x24) as *mut u32) = 0;
914
+ }
915
+
916
+ // --- Frame metadata and bitmap pointers ---
917
+ let frame_meta_ptr = data_ptr.add(hdr.frame_meta_offset);
918
+ let bitmap_ptr = data_ptr.add(hdr.bitmap_offset);
919
+ *(p.add(0x28) as *mut *const u8) = frame_meta_ptr;
920
+ *(p.add(0x64) as *mut *const u8) = bitmap_ptr;
921
+
922
+ // --- Bitmap palette remapping (only when NO secondary frames) ---
923
+ if !has_secondary {
924
+ // Remap every bitmap byte: pixel = lookup_table[pixel]
925
+ // WA processes (data_size - bitmap_offset_relative + 3) / 4 dwords = that many * 4 bytes
926
+ // We compute the same count for exact WA behavior.
927
+ let bitmap_start_relative = hdr.bitmap_offset;
928
+ let bitmap_byte_count_raw = (data_size as usize + 4).saturating_sub(bitmap_start_relative);
929
+ let dword_count = (bitmap_byte_count_raw + 3) / 4;
930
+ let remap_byte_count = dword_count * 4;
931
+
932
+ let bmp = bitmap_ptr as *mut u8;
933
+ for i in 0..remap_byte_count {
934
+ let idx = *bmp.add(i) as usize;
935
+ *bmp.add(i) = *palette_base.add(idx);
936
+ }
937
+ }
938
+
939
+ // --- Update global counters: pixel area and frame count ---
940
+ if hdr.frame_count > 0 {
941
+ let g_pixel_area = rb(va::G_SPRITE_PIXEL_AREA) as *mut u32;
942
+ let frames_ptr = frame_meta_ptr as *const [u8; 12];
943
+ for i in 0..hdr.frame_count as usize {
944
+ let frame = &*frames_ptr.add(i);
945
+ let start_x = i16::from_le_bytes([frame[4], frame[5]]);
946
+ let start_y = i16::from_le_bytes([frame[6], frame[7]]);
947
+ let end_x = i16::from_le_bytes([frame[8], frame[9]]);
948
+ let end_y = i16::from_le_bytes([frame[10], frame[11]]);
949
+ let area = (end_x as i32 - start_x as i32) * (end_y as i32 - start_y as i32);
950
+ *g_pixel_area = (*g_pixel_area).wrapping_add(area as u32);
951
+ }
952
+ }
953
+
954
+ let g_frame_count = rb(va::G_SPRITE_FRAME_COUNT) as *mut u32;
955
+ *g_frame_count = (*g_frame_count).wrapping_add(hdr.frame_count as u32);
956
+
957
+ 1 // success
958
+ }
959
+ ```
960
+
961
+ - [ ] **Step 3: Update install() to include ProcessSprite hook**
962
+
963
+ Update the `install()` function:
964
+
965
+ ```rust
966
+ pub fn install() -> Result<(), String> {
967
+ unsafe {
968
+ PALETTE_MAP_COLOR_ADDR = rb(va::PALETTE_CONTEXT_MAP_COLOR);
969
+
970
+ let _ = hook::install(
971
+ "ConstructSprite",
972
+ va::CONSTRUCT_SPRITE,
973
+ trampoline_construct_sprite as *const (),
974
+ )?;
975
+
976
+ let _ = hook::install(
977
+ "ProcessSprite",
978
+ va::PROCESS_SPRITE,
979
+ trampoline_process_sprite as *const (),
980
+ )?;
981
+ }
982
+ Ok(())
983
+ }
984
+ ```
985
+
986
+ - [ ] **Step 4: Build and verify**
987
+
988
+ Run: `cargo build -p openwa-wormkit --release 2>&1 | tail -5`
989
+ Expected: Compiles without errors.
990
+
991
+ - [ ] **Step 5: Commit**
992
+
993
+ ```bash
994
+ git add crates/openwa-wormkit/src/replacements/sprite.rs
995
+ git commit -m "feat: add ProcessSprite hook replacement with palette remapping"
996
+ ```
997
+
998
+ ---
999
+
1000
+ ### Task 6: Run Replay Tests
1001
+
1002
+ **Files:** None (validation only)
1003
+
1004
+ - [ ] **Step 1: Run all replay tests**
1005
+
1006
+ Run: `powershell -ExecutionPolicy Bypass -File run-tests.ps1`
1007
+ Expected: All tests pass. If any fail, the sprite loading replacement has a bug.
1008
+
1009
+ - [ ] **Step 2: If tests fail, diagnose**
1010
+
1011
+ If a test shows `FAIL` (log mismatch), the sprite loading diverges from WA. Common causes:
1012
+ - Palette remapping producing different indices (check PaletteContext__MapColor bridge)
1013
+ - Global counter update order or values wrong
1014
+ - Bitmap remap byte count off by a few bytes
1015
+ - Alignment computation wrong for frame metadata offset
1016
+
1017
+ If a test shows `CRASH`, check for stack corruption (wrong calling convention).
1018
+
1019
+ Use `trace-desync` to find the exact divergent frame if needed:
1020
+ ```
1021
+ powershell -ExecutionPolicy Bypass -File trace-desync.ps1 testdata/replays/<failing_replay>.WAgame
1022
+ ```
1023
+
1024
+ - [ ] **Step 3: Commit if all pass (final)**
1025
+
1026
+ ```bash
1027
+ git add -A
1028
+ git commit -m "feat: sprite parser and hook replacements for ConstructSprite + ProcessSprite"
1029
+ ```
1030
+
1031
+ ---
1032
+
1033
+ ## Known Risks
1034
+
1035
+ 1. **Palette remapping byte count**: WA computes `(data_size - bitmap_relative_offset + 3) / 4` dwords, which may process up to 3 bytes past the actual bitmap data. The implementation matches this behavior to avoid any divergence.
1036
+
1037
+ 2. **ECX = palette context**: ProcessSprite receives the palette context implicitly in ECX from LoadSpriteFromVfs. The `usercall_trampoline` with `regs = [eax, ecx]` captures both registers correctly.
1038
+
1039
+ 3. **Reading 4 bytes for RGB**: PaletteContext__MapColor receives a u32 where only the low 3 bytes are RGB. WA reads 4 bytes at each 3-byte-strided position (the 4th byte is garbage from the next entry or padding). This is safe because the function only uses `& 0xFFFFFF`.
1040
+
1041
+ 4. **Frame header location**: The main frame header's position depends on whether secondary frames exist. The code computes this position from `parse_spr_header`'s offsets, but the WA field copy (`*(u32*)(sprite+8) = *(u32*)(frame_header)`) needs the raw pointer to the frame header in the data buffer. This is computed by walking past palette + optional secondary frames.