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

docs: add sprite parser design spec

Browse files

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

docs/superpowers/specs/2026-04-01-sprite-parser-design.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sprite Parser Design
2
+
3
+ Port WA's `.spr` sprite loading to a clean Rust parser with WormKit hook replacements.
4
+
5
+ ## Goal
6
+
7
+ 1. A pure Rust `.spr` parser (`ParsedSprite`) in `openwa-core` β€” no WA dependencies, usable in tests/tooling
8
+ 2. Hook replacements for `ConstructSprite` (0x4FAA30) and `ProcessSprite` (0x4FAB80) β€” WA calls our code instead of its own
9
+
10
+ ## .spr File Format
11
+
12
+ Binary format parsed by ProcessSprite (0x4FAB80):
13
+
14
+ ```
15
+ Offset Size Field
16
+ +0x00 4 (unused / magic)
17
+ +0x04 4 (u32) data_size β€” accumulated in global counter at 0x7A0864
18
+ +0x08 2 (u16) header_flags β†’ sprite+0x14
19
+ +0x0A 2 (u16) palette_entry_count
20
+ +0x0C 3 * N palette entries (3 bytes each, RGB)
21
+ ... varies frame metadata (SpriteFrame, 0x0C bytes each)
22
+ ... varies bitmap pixel data (8-bit indexed)
23
+ ```
24
+
25
+ Special flags:
26
+ - **0x4000** in header_flags: secondary frame table present before primary frames.
27
+ First word after palette = secondary frame count, followed by 0x0C-byte entries.
28
+ - **Negative frame count** (bit 15 set): scaling mode.
29
+ High 7 bits = scale_x, low 7 bits = scale_y. Actual frame_count set to 1.
30
+ Scale values stored as `(raw_value << 16) >> 5`.
31
+
32
+ ## ParsedSprite Type
33
+
34
+ New file `crates/openwa-core/src/render/spr.rs`:
35
+
36
+ ```rust
37
+ pub enum SprError {
38
+ TooShort,
39
+ InvalidData(&'static str),
40
+ }
41
+
42
+ pub struct ParsedSprite {
43
+ pub fps: u16,
44
+ pub width: u16,
45
+ pub height: u16,
46
+ pub flags: u16,
47
+ pub header_flags: u16,
48
+ pub frame_count: u16,
49
+ pub max_frames: u16,
50
+ pub scale_x: u32, // raw 7-bit value; 0 if not scaled
51
+ pub scale_y: u32, // raw 7-bit value; 0 if not scaled
52
+ pub is_scaled: bool,
53
+ pub frames: Vec<SpriteFrame>,
54
+ pub secondary_frames: Vec<SpriteFrame>, // non-empty iff header_flags & 0x4000
55
+ pub bitmap: Vec<u8>, // 8-bit indexed pixels, same layout as WA
56
+ pub palette: Vec<[u8; 3]>, // RGB triples
57
+ pub data_size: u32, // from .spr +0x04, for global counter updates
58
+ }
59
+ ```
60
+
61
+ `ParsedSprite::parse(data: &[u8]) -> Result<Self, SprError>` β€” pure function, no WA dependencies, no `#[cfg(target_arch)]`.
62
+
63
+ ## Hook Adapter
64
+
65
+ In `spr.rs`, gated by `#[cfg(target_arch = "x86")]`:
66
+
67
+ ```rust
68
+ pub unsafe fn populate_wa_sprite(sprite: *mut Sprite, parsed: &ParsedSprite) { ... }
69
+ ```
70
+
71
+ - Copies scalar fields (fps, width, height, flags, etc.) into the WA Sprite struct
72
+ - `wa_malloc` + `memcpy` for frame metadata, bitmap data, palette data
73
+ - Applies scale transform: `(raw_value << 16) >> 5` for scale_x/scale_y
74
+ - Updates 4 global counters: `G_SPRITE_DATA_BYTES`, `G_SPRITE_FRAME_COUNT`, `G_SPRITE_PIXEL_AREA`, `G_SPRITE_PALETTE_BYTES`
75
+
76
+ Pixel data is copied verbatim β€” no format conversion needed.
77
+
78
+ ## Hook Replacements
79
+
80
+ New file `crates/openwa-wormkit/src/replacements/sprite.rs`:
81
+
82
+ ### ConstructSprite (0x4FAA30)
83
+
84
+ Convention: `__usercall` β€” EAX=sprite, ECX=context.
85
+
86
+ Replacement:
87
+ - Set vtable to `rb(0x66418C)`
88
+ - Set DisplayGfx vtable at +0x34 to `rb(0x664144)`
89
+ - Zero all other fields
90
+
91
+ Trampoline: `usercall_trampoline!(regs = [eax, ecx])`.
92
+
93
+ ### ProcessSprite (0x4FAB80)
94
+
95
+ Convention: `__usercall` β€” EAX=sprite, 1 stack param (raw data pointer).
96
+
97
+ Replacement:
98
+ 1. Determine raw data extent (bitmap data extends to end of allocation β€” length comes from context)
99
+ 2. Call `ParsedSprite::parse(data)`
100
+ 3. Call `populate_wa_sprite(sprite, &parsed)`
101
+ 4. On parse error: log and panic (corrupt game data, should never happen)
102
+
103
+ Trampoline: `usercall_trampoline!(reg = eax; stack_params = 1; ret_bytes = "0x4")`.
104
+
105
+ ## Open Question: Data Length
106
+
107
+ ProcessSprite receives a raw data pointer but no explicit length. The caller (`LoadSpriteFromVfs`) allocates the buffer and knows its size. Options:
108
+ - Parse defensively using `data_size` from the header (+0x04) to bound reads
109
+ - Hook `LoadSpriteFromVfs` to pass length through (out of scope for now)
110
+ - The parser can derive bounds from internal fields (palette count, frame count, bitmap offsets)
111
+
112
+ Recommended: use internal fields to derive bounds. The `data_size` header field gives total size. Validate reads against it.
113
+
114
+ ## File Changes
115
+
116
+ | File | Change |
117
+ |---|---|
118
+ | `crates/openwa-core/src/render/spr.rs` | New: `ParsedSprite`, `SprError`, `parse()`, `populate_wa_sprite()` |
119
+ | `crates/openwa-core/src/render/mod.rs` | Add `pub mod spr;` |
120
+ | `crates/openwa-wormkit/src/replacements/sprite.rs` | New: hook trampolines + `install()` |
121
+ | `crates/openwa-wormkit/src/replacements/mod.rs` | Wire `sprite::install()` |
122
+
123
+ ## Verification
124
+
125
+ - **Unit tests** in `openwa-core`: synthetic `.spr` payloads (single frame, multi-frame, scaled, secondary frames)
126
+ - **Replay tests**: `.\run-tests.ps1` β€” all existing replays must pass byte-for-byte (global counters and sprite state must match exactly)
127
+ - If replays show desyncs, use `trace-desync` to identify the divergent frame