docs: add sprite parser design spec
Browse filesCo-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
|