paavohtl Claude Opus 4.6 (1M context) commited on
Commit
802630e
Β·
1 Parent(s): 5c447e4

feat: port DisplayGfx::GetSpriteFrameForBlit (slot 33) to pure Rust

Browse files

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]` (`on_subframe_evicted`).
Notable correction to the prep-session blueprint: the entry header
has `frame_idx@+8` and `owner@+0xc` (not the other way around) β€”
verified from the function epilog where `[ESP+0x1c]β†’[EAX]` and
`[ESP+0x18]β†’[EAX+4]`. 3 unit tests cover single alloc, LRU
chaining, and eviction-with-wrap (using a mock owner vtable).

- `display::vtable::sprite_get_frame_for_blit` β€” port of
`Sprite__GetFrameForBlit` (0x4FAD30). All three frame-resolution
paths (scaled / round-to-nearest / truncating) plus the cached and
flat surface dispatch. Inlines the bitgrid update writes to match
the original asm's order at 0x4FAEA2..0x4FAEB7.

- `display::vtable::sprite_bank_get_frame_for_blit` β€” port of
`SpriteBank__GetFrameForBlit` (0x4F9710). The bank-side counterpart
with the 7-bit signed nibble extraction for scaled mode
(`(low7 << 16) >> 5`) and the bbox-table / subframe-cache lookups
using the bank-specific layouts from the pre-work commit. Critical
detail caught from disasm: `EAX = low - high` at 0x4F97AD (not
high - low). Note: this path is suspected dead in normal gameplay
β€” adding a `panic!()` and playing several turns of a regular
match did not trigger it. The path is still ported as a defensive
measure for any obscure game state we haven't sampled.

- `display::vtable::bitgrid_set_external_buffer` β€” port of
`DisplayBitGrid::SetExternalBuffer` (0x4F6470). Used by the bank
path to update the embedded `frame_bitgrid` at `bank + 0x130`.

- `display::vtable::get_sprite_frame_for_blit` β€” slot 33 dispatcher.
Validates `sprite_id` (1..=0x3FE), then forwards to the sprite
helper for `sprite_ptrs[id]` or the bank helper for
`sprite_banks[id]`. Wired via `vtable_replace!` in
`install_display`.

Verification:
- 135/135 unit tests pass (incl. 6 lzss + 3 frame_cache).
- 15/15 headless replay tests pass.
- Headful verification: user played a normal Worms match β€” sprite
animations, weapons, particles, fire/smoke/water all render
correctly. SpriteBank path was not triggered (see panic! note
above).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-core/src/render/display/vtable.rs CHANGED
@@ -317,12 +317,20 @@ pub struct DisplayGfxVtable {
317
  /// | `out_left`/`top`/`right`/`bot` | frame bounding box within the cell |
318
  /// | `out_anim_frac` | sub-frame interpolation value (Fixed16) or 0 |
319
  ///
320
- /// Currently still bridged: `BlitSprite` (slot 19) calls this through
321
- /// the typed `get_sprite_frame_for_blit_raw` wrapper from
322
- /// `replacements/render.rs::blit_sprite`. This is the **last** in-game
323
- /// render bridge; everything else in the pipeline runs as native Rust.
 
 
324
  ///
325
- /// # Porting blueprint
 
 
 
 
 
 
326
  ///
327
  /// ## Dispatcher (slot 33 itself, 0x5237C0, RET 0x24)
328
  ///
@@ -847,7 +855,9 @@ use super::gfx::DisplayGfx;
847
  use super::line_draw;
848
  use crate::bitgrid::{BitGrid, DisplayBitGrid};
849
  use crate::render::palette::PaletteContext;
850
- use crate::render::sprite::{Sprite, SpriteBank, SpriteVtable};
 
 
851
 
852
  /// Port of DisplayGfx::GetDimensions (vtable slot 1, 0x56A460).
853
  ///
@@ -1487,6 +1497,424 @@ unsafe fn sprite_info_from_bank(
1487
  crate::rebase::rb(SPRITE_STRING)
1488
  }
1489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1490
  /// Port of DisplayGfx::DrawViaCallback (vtable slot 21, 0x56B7C0).
1491
  ///
1492
  /// Acquires the render lock, applies camera offset to fixed-point coordinates,
 
317
  /// | `out_left`/`top`/`right`/`bot` | frame bounding box within the cell |
318
  /// | `out_anim_frac` | sub-frame interpolation value (Fixed16) or 0 |
319
  ///
320
+ /// **Ported.** Native Rust impl is [`get_sprite_frame_for_blit`]
321
+ /// in this file (Sprite path β†’ [`sprite_get_frame_for_blit`],
322
+ /// SpriteBank path β†’ [`sprite_bank_get_frame_for_blit`]). Wired via
323
+ /// `vtable_replace!` in `install_display`. With this slot ported,
324
+ /// the entire in-game render pipeline of a normal Worms match runs
325
+ /// on Rust code with zero bridges into WA's render functions.
326
  ///
327
+ /// **SpriteBank path is suspected dead in normal gameplay** β€” adding
328
+ /// a `panic!()` to `sprite_bank_get_frame_for_blit` and playing
329
+ /// several turns of a normal match did not trigger it. The path is
330
+ /// still ported (per the disassembly) as a defensive measure for any
331
+ /// obscure game state we haven't sampled.
332
+ ///
333
+ /// # Porting blueprint (kept for archival reference)
334
  ///
335
  /// ## Dispatcher (slot 33 itself, 0x5237C0, RET 0x24)
336
  ///
 
855
  use super::line_draw;
856
  use crate::bitgrid::{BitGrid, DisplayBitGrid};
857
  use crate::render::palette::PaletteContext;
858
+ use crate::render::sprite::{
859
+ frame_cache::frame_cache_allocate, lzss::sprite_lzss_decode, Sprite, SpriteBank, SpriteVtable,
860
+ };
861
 
862
  /// Port of DisplayGfx::GetDimensions (vtable slot 1, 0x56A460).
863
  ///
 
1497
  crate::rebase::rb(SPRITE_STRING)
1498
  }
1499
 
1500
+ /// Pure-Rust port of `Sprite__GetFrameForBlit` (0x4FAD30).
1501
+ ///
1502
+ /// Resolves an animation value to a frame within `sprite`, lazily
1503
+ /// decompresses its surface via [`frame_cache_allocate`] +
1504
+ /// [`sprite_lzss_decode`] if the sprite uses the cached path
1505
+ /// (`header_flags & 0x4000`), updates `sprite.bitgrid` to point at the
1506
+ /// resulting pixel data, and returns a pointer to that embedded bitgrid.
1507
+ ///
1508
+ /// Mirrors the original WA usercall (`ESI=this, EAX=out_anim_frac,
1509
+ /// EDX=anim_value, stack=(out_w, out_h, out_left, out_top, out_right,
1510
+ /// out_bottom)`) as a regular Rust function β€” the slot 33 dispatcher
1511
+ /// shuffles its caller's args into this signature.
1512
+ ///
1513
+ /// # Safety
1514
+ ///
1515
+ /// All output pointers must be valid for writes. `sprite` must point at a
1516
+ /// fully-constructed [`Sprite`] (i.e. `frame_meta_ptr` populated and, for
1517
+ /// the cached path, `subframe_cache_table` and `bitmap_data_ptr` set).
1518
+ unsafe fn sprite_get_frame_for_blit(
1519
+ sprite: *mut Sprite,
1520
+ mut anim_value: u32,
1521
+ out_anim_frac: *mut u32,
1522
+ out_w: *mut i32,
1523
+ out_h: *mut i32,
1524
+ out_left: *mut i32,
1525
+ out_top: *mut i32,
1526
+ out_right: *mut i32,
1527
+ out_bottom: *mut i32,
1528
+ ) -> *mut DisplayBitGrid {
1529
+ let flags = (*sprite).flags;
1530
+
1531
+ // ── Step 1: clamp `anim_value` ───────────────────────────────────
1532
+ if flags & 1 != 0 {
1533
+ // Use-as-is mode: just truncate to 16 bits.
1534
+ anim_value &= 0xFFFF;
1535
+ } else {
1536
+ // Signed clamp to [0, 0xFFFF].
1537
+ let signed = anim_value as i32;
1538
+ if signed < 0 {
1539
+ anim_value = 0;
1540
+ } else if signed >= 0x10000 {
1541
+ anim_value = 0xFFFF;
1542
+ }
1543
+ }
1544
+
1545
+ // ── Step 2: ping-pong ────────────────────────────────────────────
1546
+ if flags & 2 != 0 {
1547
+ anim_value &= 0xFFFF;
1548
+ anim_value = anim_value.wrapping_mul(2);
1549
+ if anim_value >= 0x10000 {
1550
+ anim_value = 0x1FFFE - anim_value;
1551
+ }
1552
+ }
1553
+
1554
+ // ── Step 3: frame index resolution ───────────────────────────────
1555
+ let frame_idx: u32 = if (*sprite).is_scaled != 0 {
1556
+ // Scaled mode: linearly interpolate between scale_x and scale_y
1557
+ // by the (Fixed16) anim_value, write the interpolated result to
1558
+ // *out_anim_frac. Always use frame 0 in this mode.
1559
+ let scale_x = (*sprite).scale_x as i32;
1560
+ let scale_y = (*sprite).scale_y as i32;
1561
+ let diff = scale_y.wrapping_sub(scale_x);
1562
+ // Original asm: `IMUL EDX; SHRD EAX, EDX, 0x10` β€” 32Γ—32 β†’ 64
1563
+ // signed multiply with `>> 16` (Fixed16). The multiplicand is the
1564
+ // raw `anim_value` interpreted as a signed 32-bit integer (after
1565
+ // the clamp/ping-pong above it's always in [0, 0xFFFF], so the
1566
+ // sign bit is clear).
1567
+ let prod = (diff as i64).wrapping_mul((anim_value as i32) as i64);
1568
+ let interp = ((prod >> 16) as i32).wrapping_add(scale_x);
1569
+ *out_anim_frac = interp as u32;
1570
+ 0
1571
+ } else if (*sprite).frame_round_mode & 1 != 0 {
1572
+ // Round-to-nearest mode: `((max_frames * anim_value) + 0x8000) >> 16`,
1573
+ // with the special case that if rounding produces `frame_idx ==
1574
+ // max_frames` we wrap back to 0 (avoids reading past the table).
1575
+ let max_frames = (*sprite).max_frames as i32;
1576
+ let prod = max_frames.wrapping_mul(anim_value as i32);
1577
+ let f = (prod.wrapping_add(0x8000) >> 16) as u32;
1578
+ *out_anim_frac = 0;
1579
+ if f == max_frames as u32 {
1580
+ 0
1581
+ } else {
1582
+ f
1583
+ }
1584
+ } else {
1585
+ // Truncating mode.
1586
+ let max_frames = (*sprite).max_frames as i32;
1587
+ let prod = max_frames.wrapping_mul(anim_value as i32);
1588
+ *out_anim_frac = 0;
1589
+ (prod >> 16) as u32
1590
+ };
1591
+
1592
+ // ── Step 4: read frame metadata + sprite cell dimensions ─────────
1593
+ let frame_meta = (*sprite).frame_meta_ptr.add(frame_idx as usize);
1594
+ let start_x = (*frame_meta).start_x as i16 as i32;
1595
+ let start_y = (*frame_meta).start_y as i16 as i32;
1596
+ let end_x = (*frame_meta).end_x as i16 as i32;
1597
+ let end_y = (*frame_meta).end_y as i16 as i32;
1598
+ *out_left = start_x;
1599
+ *out_top = start_y;
1600
+ *out_right = end_x;
1601
+ *out_bottom = end_y;
1602
+ *out_w = (*sprite).width as i32;
1603
+ *out_h = (*sprite).height as i32;
1604
+ let frame_w = end_x - start_x;
1605
+ let frame_h = end_y - start_y;
1606
+
1607
+ // ── Step 5: surface address (flat or cached/decompressed) ────────
1608
+ let surface_addr: *mut u8 = if (*sprite).header_flags & 0x4000 == 0 {
1609
+ // Flat path: bytes are already-decoded raw pixels in the load
1610
+ // buffer.
1611
+ let bitmap_offset = (*frame_meta).bitmap_offset;
1612
+ (*sprite).bitmap_data_ptr.add(bitmap_offset as usize)
1613
+ } else {
1614
+ // Cached path: bitmap_offset is split into a signed-byte
1615
+ // subframe index (high byte) and a pixel offset (low 24 bits).
1616
+ let bitmap_offset = (*frame_meta).bitmap_offset;
1617
+ let subframe_idx_signed = ((bitmap_offset >> 24) as i8) as i32;
1618
+
1619
+ // The original asm uses MOVSX byte ptr [EAX+EDI+3] then a *12
1620
+ // lea β€” i.e. negative subframe indices index *backward* from the
1621
+ // table base. Mirror via `offset(isize)`.
1622
+ let entry = (*sprite)
1623
+ .subframe_cache_table
1624
+ .offset(subframe_idx_signed as isize);
1625
+
1626
+ if (*entry).decoded_ptr.is_null() {
1627
+ // Allocate from the per-SpriteCache FrameCache and decompress.
1628
+ let context_ptr = (*sprite).context_ptr;
1629
+ let decoded_size = (*entry).decoded_size;
1630
+ let decoded = frame_cache_allocate(
1631
+ decoded_size,
1632
+ context_ptr,
1633
+ sprite as *mut core::ffi::c_void,
1634
+ subframe_idx_signed as u32,
1635
+ );
1636
+ (*entry).decoded_ptr = decoded;
1637
+ let src = (*sprite)
1638
+ .bitmap_data_ptr
1639
+ .add((*entry).compressed_offset as usize);
1640
+ sprite_lzss_decode(decoded, src, (*sprite).palette_data_ptr);
1641
+ }
1642
+
1643
+ let pixel_offset = (bitmap_offset & 0xFF_FFFF) as usize;
1644
+ (*entry).decoded_ptr.add(pixel_offset)
1645
+ };
1646
+
1647
+ // ── Step 6: update embedded bitgrid (only if external_buffer != 0)
1648
+ let bitgrid = &raw mut (*sprite).bitgrid;
1649
+ if (*bitgrid).external_buffer != 0 {
1650
+ // Order copied from the asm at 0x4FAEA2..0x4FAEB7. Equivalent
1651
+ // to `bitgrid_set_external_buffer(bitgrid, surface, frame_w,
1652
+ // frame_w, frame_h)` β€” see SpriteBank path which calls the
1653
+ // shared `DisplayBitGrid::SetExternalBuffer` helper.
1654
+ (*bitgrid).clip_bottom = frame_h as u32;
1655
+ (*bitgrid).clip_right = frame_w as u32;
1656
+ (*bitgrid).clip_top = 0;
1657
+ (*bitgrid).clip_left = 0;
1658
+ (*bitgrid).row_stride = frame_w as u32;
1659
+ (*bitgrid).data = surface_addr;
1660
+ (*bitgrid).height = frame_h as u32;
1661
+ (*bitgrid).width = frame_w as u32;
1662
+ }
1663
+
1664
+ bitgrid
1665
+ }
1666
+
1667
+ /// Pure-Rust port of `DisplayBitGrid::SetExternalBuffer` (0x4F6470).
1668
+ ///
1669
+ /// Sets a `DisplayBitGrid`'s data pointer, dimensions, row stride, and
1670
+ /// clip rect (full-rect: `(0, 0, width, height)`). Returns `false` if the
1671
+ /// bitgrid's `external_buffer` flag is currently zero (i.e. it owns its
1672
+ /// own buffer and the caller has no business swapping it out).
1673
+ ///
1674
+ /// Used by the SpriteBank path of slot 33 to point the bank's embedded
1675
+ /// `frame_bitgrid` (at `bank + 0x130`) at the freshly-decompressed
1676
+ /// subframe pixels. Inlined manually in the Sprite path because that one
1677
+ /// uses `frame_w` for both `width` and `row_stride`.
1678
+ #[inline]
1679
+ unsafe fn bitgrid_set_external_buffer(
1680
+ bitgrid: *mut DisplayBitGrid,
1681
+ data: *mut u8,
1682
+ row_stride: u32,
1683
+ width: u32,
1684
+ height: u32,
1685
+ ) -> bool {
1686
+ if (*bitgrid).external_buffer == 0 {
1687
+ return false;
1688
+ }
1689
+ (*bitgrid).data = data;
1690
+ (*bitgrid).row_stride = row_stride;
1691
+ (*bitgrid).width = width;
1692
+ (*bitgrid).height = height;
1693
+ (*bitgrid).clip_left = 0;
1694
+ (*bitgrid).clip_top = 0;
1695
+ (*bitgrid).clip_right = width;
1696
+ (*bitgrid).clip_bottom = height;
1697
+ true
1698
+ }
1699
+
1700
+ /// Pure-Rust port of `SpriteBank__GetFrameForBlit` (0x4F9710).
1701
+ ///
1702
+ /// SpriteBank-side counterpart of [`sprite_get_frame_for_blit`]: walks
1703
+ /// the bank's `index_table` to map `sprite_id` to a frame in
1704
+ /// `frame_table`, resolves the animation value to a sub-frame index in
1705
+ /// `bbox_table`, lazily decompresses the corresponding subframe via
1706
+ /// [`frame_cache_allocate`] + [`sprite_lzss_decode`], updates the
1707
+ /// embedded `frame_bitgrid` (at `bank + 0x130`), and returns a pointer
1708
+ /// to it. Returns null on any validation failure (null `frame_table` or
1709
+ /// out-of-range `entry_idx`).
1710
+ ///
1711
+ /// Note the structural differences from the Sprite path:
1712
+ /// - Animation metadata lives in [`SpriteBankFrame`]
1713
+ /// (`flags/width/height/base_frame_idx/scale_or_count`) instead of
1714
+ /// `Sprite::flags`/`max_frames`/`scale_*`.
1715
+ /// - Out-`w`/`h` come from the per-frame `width`/`height`, NOT from a
1716
+ /// sprite-wide cell dimension.
1717
+ /// - The bbox table is a separate array of [`SpriteBankBboxEntry`] at
1718
+ /// `bank + 0x18`, with `subframe_idx` at `+0` (low u16 of what would
1719
+ /// be `SpriteFrame::bitmap_offset`) and `decoded_offset` at `+2`.
1720
+ /// - The subframe cache uses [`SpriteBankSubframeCache`] with
1721
+ /// `decoded_ptr` at `+8` (vs `+4` in `SpriteSubframeCache`).
1722
+ /// - The LUT for LZSS is the inline 256-byte `palette_lut` at
1723
+ /// `bank + 0x30`, not a separately-allocated palette pointer.
1724
+ /// - The bitgrid update goes through `bitgrid_set_external_buffer`
1725
+ /// instead of inline writes.
1726
+ ///
1727
+ /// # Safety
1728
+ ///
1729
+ /// `bank` must point at a valid, fully-parsed [`SpriteBank`]. Output
1730
+ /// pointers must be writable.
1731
+ #[allow(clippy::too_many_arguments)]
1732
+ unsafe fn sprite_bank_get_frame_for_blit(
1733
+ bank: *mut SpriteBank,
1734
+ sprite_id: u32,
1735
+ mut anim_value: u32,
1736
+ out_anim_frac: *mut u32,
1737
+ out_w: *mut i32,
1738
+ out_h: *mut i32,
1739
+ out_left: *mut i32,
1740
+ out_top: *mut i32,
1741
+ out_right: *mut i32,
1742
+ out_bottom: *mut i32,
1743
+ ) -> *mut DisplayBitGrid {
1744
+ // ── Index lookup + bounds check ──────────────────────────────────
1745
+ let entry_idx = *(*bank)
1746
+ .index_table
1747
+ .offset((sprite_id as i32 - (*bank).base_id) as isize);
1748
+ if (*bank).frame_table.is_null() || entry_idx < 0 || entry_idx >= (*bank).frame_count {
1749
+ return core::ptr::null_mut();
1750
+ }
1751
+ let frame = (*bank).frame_table.add(entry_idx as usize);
1752
+ let frame_flags = (*frame).flags;
1753
+
1754
+ // ── Clamp anim_value ─────────────────────────────────────────────
1755
+ if frame_flags & 1 != 0 {
1756
+ anim_value &= 0xFFFF;
1757
+ } else {
1758
+ let signed = anim_value as i32;
1759
+ if signed < 0 {
1760
+ anim_value = 0;
1761
+ } else if signed >= 0x10000 {
1762
+ anim_value = 0xFFFF;
1763
+ }
1764
+ }
1765
+
1766
+ // ── Ping-pong ────────────────────────────────────────────────────
1767
+ if frame_flags & 2 != 0 {
1768
+ anim_value &= 0xFFFF;
1769
+ anim_value = anim_value.wrapping_mul(2);
1770
+ if anim_value >= 0x10000 {
1771
+ anim_value = 0x1FFFE - anim_value;
1772
+ }
1773
+ }
1774
+
1775
+ // ── Frame index resolution ───────────────────────────────────────
1776
+ let scale_or_count = (*frame).scale_or_count;
1777
+ let base_frame_idx = (*frame).base_frame_idx;
1778
+
1779
+ let resolved_idx: u32 = if scale_or_count & 0x8000 != 0 {
1780
+ // Scaled mode: low/high 7-bit nibbles of `scale_or_count` form a
1781
+ // signed Fixed16 pair. The asm extracts both via:
1782
+ // `value = (low_or_high_7 << 16) >> 5` (signed)
1783
+ // i.e. shift the 7-bit value into the high half, sign-extend
1784
+ // from bit 22, then shift right by 5 β†’ effective `<< 11` with
1785
+ // sign extension from bit 6 of the original 7-bit value.
1786
+ let low7 = scale_or_count & 0x7F;
1787
+ let high7 = (scale_or_count >> 8) & 0x7F;
1788
+ let low_val = (((low7 as u32) << 16) as i32) >> 5;
1789
+ let high_val = (((high7 as u32) << 16) as i32) >> 5;
1790
+ // Note: the asm computes `EAX = low - high` (NOT high - low) β€”
1791
+ // verified at 0x4F97AD `SUB EAX, EDI` where EAX = low_val and
1792
+ // EDI = high_val.
1793
+ let diff = low_val.wrapping_sub(high_val);
1794
+ let prod = (diff as i64).wrapping_mul((anim_value as i32) as i64);
1795
+ let interp = ((prod >> 16) as i32).wrapping_add(high_val);
1796
+ *out_anim_frac = interp as u32;
1797
+ // Scaled mode uses `base_frame_idx` directly as the bbox index.
1798
+ base_frame_idx as u32
1799
+ } else {
1800
+ // Non-scaled mode: subframe_idx = ((scale_or_count * anim_value) >> 16)
1801
+ // + base_frame_idx. *out_anim_frac = 0.
1802
+ let prod = (scale_or_count as i32).wrapping_mul(anim_value as i32);
1803
+ *out_anim_frac = 0;
1804
+ ((prod >> 16) as u32).wrapping_add(base_frame_idx as u32)
1805
+ };
1806
+
1807
+ // ── Read bbox metadata ───────────────────────────────────────────
1808
+ let bbox = (*bank).bbox_table.add(resolved_idx as usize);
1809
+ *out_left = (*bbox).start_x as i32;
1810
+ *out_top = (*bbox).start_y as i32;
1811
+ *out_right = (*bbox).end_x as i32;
1812
+ *out_bottom = (*bbox).end_y as i32;
1813
+ // out_w/out_h come from the per-frame width/height, NOT the bbox.
1814
+ *out_w = (*frame).width as i32;
1815
+ *out_h = (*frame).height as i32;
1816
+ let frame_w = (*out_right - *out_left) as u32;
1817
+ let frame_h = (*out_bottom - *out_top) as u32;
1818
+
1819
+ // ── Subframe cache lookup + lazy decompress ──────────────────────
1820
+ let subframe_idx = (*bbox).subframe_idx as u32;
1821
+ let entry = (*bank).subframe_cache_table.add(subframe_idx as usize);
1822
+
1823
+ if (*entry).decoded_ptr.is_null() {
1824
+ let context_ptr = (*bank).context_ptr;
1825
+ let decoded_size = (*entry).decoded_size;
1826
+ let decoded = frame_cache_allocate(
1827
+ decoded_size,
1828
+ context_ptr,
1829
+ bank as *mut core::ffi::c_void,
1830
+ subframe_idx,
1831
+ );
1832
+ (*entry).decoded_ptr = decoded;
1833
+ let src = (*bank)
1834
+ .bitmap_data_ptr
1835
+ .add((*entry).compressed_offset as usize) as *mut u8;
1836
+ // Bank LUT is the inline 256-byte palette_lut at bank+0x30.
1837
+ let lut = (&raw const (*bank).palette_lut) as *const u8;
1838
+ sprite_lzss_decode(decoded, src, lut);
1839
+ }
1840
+
1841
+ // ── Update embedded bitgrid via SetExternalBuffer ────────────────
1842
+ //
1843
+ // Surface address = bbox.decoded_offset + entry.decoded_ptr.
1844
+ let surface = (*entry).decoded_ptr.add((*bbox).decoded_offset as usize);
1845
+ let bitgrid = &raw mut (*bank).frame_bitgrid;
1846
+ bitgrid_set_external_buffer(bitgrid, surface, frame_w, frame_w, frame_h);
1847
+ bitgrid
1848
+ }
1849
+
1850
+ /// Port of `DisplayGfx::GetSpriteFrameForBlit` (vtable slot 33, 0x5237C0).
1851
+ ///
1852
+ /// Thin dispatcher: validates `sprite_id`, then forwards to either
1853
+ /// [`sprite_get_frame_for_blit`] (for `sprite_ptrs`-backed IDs) or
1854
+ /// [`sprite_bank_get_frame_for_blit`] (for `sprite_banks`-backed IDs).
1855
+ /// Returns null if neither table has an entry for the ID.
1856
+ ///
1857
+ /// See the slot 33 docstring on
1858
+ /// [`DisplayGfxVtable::get_sprite_frame_for_blit`] for the original
1859
+ /// usercall convention and the full body breakdown.
1860
+ ///
1861
+ /// # Safety
1862
+ ///
1863
+ /// `this` must be a valid `*mut DisplayGfx`. All output pointers must
1864
+ /// be writable.
1865
+ #[allow(clippy::too_many_arguments)]
1866
+ pub unsafe extern "thiscall" fn get_sprite_frame_for_blit(
1867
+ this: *mut DisplayGfx,
1868
+ sprite_id: u32,
1869
+ anim_value: u32,
1870
+ out_w: *mut i32,
1871
+ out_h: *mut i32,
1872
+ out_left: *mut i32,
1873
+ out_top: *mut i32,
1874
+ out_right: *mut i32,
1875
+ out_bottom: *mut i32,
1876
+ out_anim_frac: *mut u32,
1877
+ ) -> *mut DisplayBitGrid {
1878
+ // Validity check: sprite_id - 1 must be < 0x3FE (so 1..=0x3FE).
1879
+ if sprite_id.wrapping_sub(1) >= 0x3FE {
1880
+ return core::ptr::null_mut();
1881
+ }
1882
+
1883
+ let base = &mut (*this).base;
1884
+ let sprite = base.sprite_ptrs[sprite_id as usize];
1885
+ if !sprite.is_null() {
1886
+ return sprite_get_frame_for_blit(
1887
+ sprite,
1888
+ anim_value,
1889
+ out_anim_frac,
1890
+ out_w,
1891
+ out_h,
1892
+ out_left,
1893
+ out_top,
1894
+ out_right,
1895
+ out_bottom,
1896
+ );
1897
+ }
1898
+
1899
+ let bank = base.sprite_banks[sprite_id as usize];
1900
+ if !bank.is_null() {
1901
+ return sprite_bank_get_frame_for_blit(
1902
+ bank,
1903
+ sprite_id,
1904
+ anim_value,
1905
+ out_anim_frac,
1906
+ out_w,
1907
+ out_h,
1908
+ out_left,
1909
+ out_top,
1910
+ out_right,
1911
+ out_bottom,
1912
+ );
1913
+ }
1914
+
1915
+ core::ptr::null_mut()
1916
+ }
1917
+
1918
  /// Port of DisplayGfx::DrawViaCallback (vtable slot 21, 0x56B7C0).
1919
  ///
1920
  /// Acquires the render lock, applies camera offset to fixed-point coordinates,
crates/openwa-core/src/render/sprite/frame_cache.rs ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! LRU ring-buffer allocator for sprite subframe payloads.
2
+ //!
3
+ //! Pure-Rust port of `FrameCache__Allocate` (0x4FA950). The shared
4
+ //! [`FrameCache`] holds decompressed pixel data for the most recently
5
+ //! requested sprite/sprite-bank subframes. When the buffer fills, the
6
+ //! oldest entry is evicted and its owner is notified via
7
+ //! `vtable[1]` (`on_subframe_evicted`) so it can clear its
8
+ //! `decoded_ptr` and re-request decompression on the next render.
9
+ //!
10
+ //! Each allocated entry has a 16-byte header ([`FrameCacheEntry`]) followed
11
+ //! inline by the payload bytes. The returned pointer is the payload start
12
+ //! (`entry + 0x10`).
13
+ //!
14
+ //! See the slot 33 docstring in [`crate::render::display::vtable`] for the
15
+ //! full reverse-engineering notes and the `Sprite__GetFrameForBlit` /
16
+ //! `SpriteBank__GetFrameForBlit` callers.
17
+
18
+ use core::ffi::c_void;
19
+ use core::ptr::null_mut;
20
+
21
+ #[cfg(test)]
22
+ use crate::render::display::base::FrameCache;
23
+ use crate::render::display::base::FrameCacheEntry;
24
+ use crate::render::SpriteCache;
25
+
26
+ /// Eviction callback signature β€” `vtable[1]` of both `Sprite` and
27
+ /// `SpriteBank`. Called when the oldest entry is being dropped from the
28
+ /// LRU ring buffer; the callback clears the owner's per-subframe
29
+ /// `decoded_ptr` so the next render re-decompresses.
30
+ ///
31
+ /// `this` is `victim.owner`. `subframe_idx` is `victim.frame_idx` (the
32
+ /// caller's third stack arg from when the entry was allocated). `payload`
33
+ /// is `victim + 0x10` β€” the payload start, which the original asm pushes
34
+ /// but neither callback uses.
35
+ type EvictCallback =
36
+ unsafe extern "thiscall" fn(this: *mut c_void, subframe_idx: u32, payload: *mut u8);
37
+
38
+ /// Pure-Rust port of `FrameCache__Allocate` (0x4FA950).
39
+ ///
40
+ /// Allocates `entry_size` bytes of payload from the [`FrameCache`] reached
41
+ /// via `(*context_ptr).frame_cache`. On success, returns a pointer to the
42
+ /// payload start. On contention, evicts the oldest live entry (notifying
43
+ /// its owner via `vtable[1]`) and retries.
44
+ ///
45
+ /// # Layout details
46
+ ///
47
+ /// - The header allocation occupies `pad = ((entry_size + 8 + 0xb) & ~3)`
48
+ /// bytes total (= `entry_size + 8` rounded up to a 4-byte stride after
49
+ /// adding 11 bytes of slack).
50
+ /// - The `payload_size` field stored in the entry header is
51
+ /// `entry_size + 8` (NOT padded β€” the original asm computes the padded
52
+ /// stride only for advancing `write_head`, never stores it).
53
+ /// - On the long-backref `entry_count` cleanup branch where the new head
54
+ /// becomes null, the original asm clears `wrap_marker` and `write_head`
55
+ /// to 0 β€” the buffer is now empty and the next alloc starts from the
56
+ /// beginning.
57
+ ///
58
+ /// # Calling convention
59
+ ///
60
+ /// The original is a usercall: `EAX = entry_size`, stack args
61
+ /// `(context_ptr, owner, frame_idx)`. The Rust port takes these as
62
+ /// regular params; callers should pass the same values.
63
+ ///
64
+ /// # Safety
65
+ ///
66
+ /// - `context_ptr` must point at a valid `SpriteCache` whose `frame_cache`
67
+ /// field references a `FrameCache` initialized via `FUN_004fa860`
68
+ /// (with a non-null `buffer` of at least `entry_size + 0x13` bytes).
69
+ /// - `owner` must point at an object whose first field is a vtable
70
+ /// pointer with `vtable[1]` matching [`EvictCallback`]. In practice
71
+ /// this is `*mut Sprite` or `*mut SpriteBank`, both of whose
72
+ /// `on_subframe_evicted` callbacks share the signature.
73
+ /// - `entry_size` must be small enough to fit in the buffer
74
+ /// (`entry_size + 0x13 <= capacity`); otherwise this loops forever.
75
+ pub unsafe fn frame_cache_allocate(
76
+ entry_size: u32,
77
+ context_ptr: *mut SpriteCache,
78
+ owner: *mut c_void,
79
+ frame_idx: u32,
80
+ ) -> *mut u8 {
81
+ let payload_size = entry_size + 8;
82
+ let pad = (entry_size + 0x13) & !3;
83
+
84
+ loop {
85
+ let fc = (*context_ptr).frame_cache;
86
+ let write = (*fc).write_head;
87
+ let wrap = (*fc).wrap_marker;
88
+ let end = write + pad;
89
+
90
+ // Decide where (if anywhere) the new entry can go in this pass.
91
+ let placed_offset: Option<u32> = if write < wrap {
92
+ // Case A: write_head sits behind wrap_marker (a previously
93
+ // wrapped buffer). Allocation succeeds iff it doesn't reach
94
+ // wrap_marker.
95
+ if end <= wrap {
96
+ Some(write)
97
+ } else {
98
+ None
99
+ }
100
+ } else {
101
+ // Case B: write_head ahead of wrap_marker. Allocation
102
+ // succeeds iff it fits before the end of the buffer; if it
103
+ // doesn't, try wrapping to offset 0 β€” that succeeds iff
104
+ // `pad <= wrap_marker` (head_entry isn't sitting in the
105
+ // [0, pad) range).
106
+ if end <= (*fc).capacity {
107
+ Some(write)
108
+ } else if pad <= wrap {
109
+ Some(0)
110
+ } else {
111
+ None
112
+ }
113
+ };
114
+
115
+ if let Some(offset) = placed_offset {
116
+ let entry_addr = (*fc).buffer.add(offset as usize);
117
+ let entry = entry_addr as *mut FrameCacheEntry;
118
+
119
+ (*entry).payload_size = payload_size;
120
+ (*entry).next = null_mut();
121
+
122
+ if !(*fc).tail_entry.is_null() {
123
+ (*(*fc).tail_entry).next = entry;
124
+ }
125
+ (*fc).tail_entry = entry;
126
+ if (*fc).head_entry.is_null() {
127
+ (*fc).head_entry = entry;
128
+ }
129
+ (*fc).entry_count += 1;
130
+ (*fc).write_head = offset + pad;
131
+
132
+ // Note: original asm writes frame_idx and owner AFTER the
133
+ // POPAD/POP sequence β€” this is functionally equivalent.
134
+ (*entry).frame_idx = frame_idx;
135
+ (*entry).owner = owner;
136
+
137
+ return entry_addr.add(0x10);
138
+ }
139
+
140
+ // ── Out of room: evict the oldest entry and retry ───────────
141
+ //
142
+ // The original asm reloads `fc` from `*(context_ptr + 4)` here
143
+ // (in case the eviction callback somehow rebinds it). In practice
144
+ // both real callbacks just clear a `decoded_ptr` field, but we
145
+ // mirror the reload exactly.
146
+ let fc = (*context_ptr).frame_cache;
147
+ let head = (*fc).head_entry;
148
+ if head.is_null() {
149
+ // Theoretically unreachable: an empty cache has wrap=write=0
150
+ // and thus must always fit; if it doesn't, `pad > capacity`
151
+ // and the original asm spins forever. We mirror that.
152
+ continue;
153
+ }
154
+
155
+ // Notify the owner that its subframe payload is being dropped.
156
+ // Both Sprite and SpriteBank vtable[1] callbacks share the same
157
+ // shape: thiscall(this, frame_idx, payload). The asm pushes
158
+ // `payload` first then `frame_idx` (so `frame_idx` is the first
159
+ // stack arg), with ECX = owner.
160
+ let victim_owner = (*head).owner;
161
+ let victim_frame_idx = (*head).frame_idx;
162
+ let victim_payload = (head as *mut u8).add(0x10);
163
+
164
+ // Read vtable[1] manually so we don't need to commit to either
165
+ // SpriteVtable or SpriteBankVtable as the callback's `this` type.
166
+ let vtable = *(victim_owner as *const *const u32);
167
+ let slot1 = *vtable.add(1);
168
+ let callback: EvictCallback = core::mem::transmute(slot1);
169
+ callback(victim_owner, victim_frame_idx, victim_payload);
170
+
171
+ // Reload fc and unlink the (just-notified) head entry.
172
+ let fc = (*context_ptr).frame_cache;
173
+ if (*fc).head_entry.is_null() {
174
+ continue;
175
+ }
176
+ let new_head = (*(*fc).head_entry).next;
177
+ (*fc).head_entry = new_head;
178
+ if new_head.is_null() {
179
+ // Last entry just removed β€” clear the bookkeeping back to
180
+ // the empty state.
181
+ (*fc).entry_count -= 1;
182
+ (*fc).tail_entry = null_mut();
183
+ (*fc).wrap_marker = 0;
184
+ (*fc).write_head = 0;
185
+ } else {
186
+ // wrap_marker tracks where the head entry now sits within
187
+ // the buffer (as a byte offset from `buffer`).
188
+ (*fc).entry_count -= 1;
189
+ (*fc).wrap_marker = (new_head as u32).wrapping_sub((*fc).buffer as u32);
190
+ }
191
+ }
192
+ }
193
+
194
+ #[cfg(test)]
195
+ mod tests {
196
+ use super::*;
197
+ use core::mem::MaybeUninit;
198
+
199
+ /// A minimal owner whose vtable[1] increments a counter when invoked.
200
+ #[repr(C)]
201
+ struct MockOwner {
202
+ vtable: *const MockVtable,
203
+ evict_count: u32,
204
+ last_idx: u32,
205
+ }
206
+
207
+ #[repr(C)]
208
+ struct MockVtable {
209
+ slot_0: usize,
210
+ slot_1: unsafe extern "thiscall" fn(*mut c_void, u32, *mut u8),
211
+ }
212
+
213
+ unsafe extern "thiscall" fn mock_evict(this: *mut c_void, idx: u32, _payload: *mut u8) {
214
+ let owner = this as *mut MockOwner;
215
+ (*owner).evict_count += 1;
216
+ (*owner).last_idx = idx;
217
+ }
218
+
219
+ static MOCK_VTABLE: MockVtable = MockVtable {
220
+ slot_0: 0,
221
+ slot_1: mock_evict,
222
+ };
223
+
224
+ /// Build a freshly-zeroed FrameCache with a heap-allocated buffer.
225
+ /// Returns the cache (heap-allocated, must be freed by the caller).
226
+ fn make_cache(capacity: u32) -> (*mut SpriteCache, *mut FrameCache, *mut u8) {
227
+ let buf = unsafe {
228
+ let layout = core::alloc::Layout::from_size_align(capacity as usize, 4).unwrap();
229
+ std::alloc::alloc_zeroed(layout)
230
+ };
231
+ let fc = Box::leak(Box::new(unsafe {
232
+ MaybeUninit::<FrameCache>::zeroed().assume_init()
233
+ })) as *mut FrameCache;
234
+ unsafe {
235
+ (*fc).buffer = buf;
236
+ (*fc).capacity = capacity;
237
+ }
238
+ let sc = Box::leak(Box::new(unsafe {
239
+ MaybeUninit::<SpriteCache>::zeroed().assume_init()
240
+ })) as *mut SpriteCache;
241
+ unsafe {
242
+ (*sc).frame_cache = fc;
243
+ }
244
+ (sc, fc, buf)
245
+ }
246
+
247
+ fn free_cache(sc: *mut SpriteCache, fc: *mut FrameCache, buf: *mut u8, capacity: u32) {
248
+ unsafe {
249
+ let layout = core::alloc::Layout::from_size_align(capacity as usize, 4).unwrap();
250
+ std::alloc::dealloc(buf, layout);
251
+ drop(Box::from_raw(fc));
252
+ drop(Box::from_raw(sc));
253
+ }
254
+ }
255
+
256
+ /// Single allocation: header + payload, returns payload start.
257
+ #[test]
258
+ fn alloc_single() {
259
+ let (sc, fc, buf) = make_cache(0x1000);
260
+ let mut owner = MockOwner {
261
+ vtable: &MOCK_VTABLE,
262
+ evict_count: 0,
263
+ last_idx: 0,
264
+ };
265
+ unsafe {
266
+ let p = frame_cache_allocate(0x100, sc, &mut owner as *mut _ as *mut c_void, 7);
267
+
268
+ // Returned pointer is buffer + 0x10
269
+ assert_eq!(p, buf.add(0x10));
270
+
271
+ // Header fields written
272
+ let entry = buf as *mut FrameCacheEntry;
273
+ assert_eq!((*entry).payload_size, 0x100 + 8);
274
+ assert!((*entry).next.is_null());
275
+ assert_eq!((*entry).frame_idx, 7);
276
+ assert_eq!((*entry).owner, &mut owner as *mut _ as *mut c_void);
277
+
278
+ // Cache bookkeeping
279
+ assert_eq!((*fc).head_entry, entry);
280
+ assert_eq!((*fc).tail_entry, entry);
281
+ assert_eq!((*fc).entry_count, 1);
282
+ // pad = ((entry_size + 0x13) & ~3) = ((0x100 + 0x13) & ~3) = 0x110
283
+ assert_eq!((*fc).write_head, 0x110);
284
+ }
285
+ free_cache(sc, fc, buf, 0x1000);
286
+ }
287
+
288
+ /// Two allocations chain into the LRU list.
289
+ #[test]
290
+ fn alloc_two_chain() {
291
+ let (sc, fc, buf) = make_cache(0x1000);
292
+ let mut owner = MockOwner {
293
+ vtable: &MOCK_VTABLE,
294
+ evict_count: 0,
295
+ last_idx: 0,
296
+ };
297
+ unsafe {
298
+ let owner_ptr = &mut owner as *mut _ as *mut c_void;
299
+ let _p1 = frame_cache_allocate(0x100, sc, owner_ptr, 1);
300
+ let _p2 = frame_cache_allocate(0x100, sc, owner_ptr, 2);
301
+
302
+ let e1 = buf as *mut FrameCacheEntry;
303
+ let e2 = buf.add(0x110) as *mut FrameCacheEntry;
304
+ assert_eq!((*e1).next, e2);
305
+ assert!((*e2).next.is_null());
306
+ assert_eq!((*fc).head_entry, e1);
307
+ assert_eq!((*fc).tail_entry, e2);
308
+ assert_eq!((*fc).entry_count, 2);
309
+ assert_eq!((*fc).write_head, 0x220);
310
+ }
311
+ free_cache(sc, fc, buf, 0x1000);
312
+ }
313
+
314
+ /// Eviction: fill the buffer, then a third alloc that won't fit
315
+ /// triggers eviction of the oldest entry and re-uses its space at
316
+ /// the start of the buffer (after wrap).
317
+ #[test]
318
+ fn alloc_triggers_eviction_and_wrap() {
319
+ // pad = 0x110 per entry. Capacity 0x300 β†’ two entries fit at
320
+ // [0..0x110, 0x110..0x220]. A third 0x100 alloc would need
321
+ // [0x220..0x330] which exceeds capacity β†’ must evict + wrap.
322
+ let (sc, fc, buf) = make_cache(0x300);
323
+ let mut owner = MockOwner {
324
+ vtable: &MOCK_VTABLE,
325
+ evict_count: 0,
326
+ last_idx: 0,
327
+ };
328
+ unsafe {
329
+ let owner_ptr = &mut owner as *mut _ as *mut c_void;
330
+ let _p1 = frame_cache_allocate(0x100, sc, owner_ptr, 1);
331
+ let _p2 = frame_cache_allocate(0x100, sc, owner_ptr, 2);
332
+
333
+ // Sanity: wrap_marker is still 0 (no eviction yet).
334
+ assert_eq!((*fc).wrap_marker, 0);
335
+ assert_eq!((*fc).write_head, 0x220);
336
+
337
+ let p3 = frame_cache_allocate(0x100, sc, owner_ptr, 3);
338
+
339
+ // Eviction was called once with the first entry's frame_idx.
340
+ assert_eq!(owner.evict_count, 1);
341
+ assert_eq!(owner.last_idx, 1);
342
+
343
+ // After evicting entry 1, head_entry advanced to entry 2;
344
+ // wrap_marker = (entry 2 offset) = 0x110.
345
+ assert_eq!((*fc).wrap_marker, 0x110);
346
+
347
+ // Third alloc wrapped to offset 0 (where entry 1 used to be);
348
+ // p3 should be buf + 0x10.
349
+ assert_eq!(p3, buf.add(0x10));
350
+
351
+ // Bookkeeping: 2 live entries (entry 2, entry 3), entry 3 is tail.
352
+ assert_eq!((*fc).entry_count, 2);
353
+ let e2 = buf.add(0x110) as *mut FrameCacheEntry;
354
+ let e3 = buf as *mut FrameCacheEntry;
355
+ assert_eq!((*fc).head_entry, e2);
356
+ assert_eq!((*fc).tail_entry, e3);
357
+ assert_eq!((*e2).next, e3);
358
+ assert!((*e3).next.is_null());
359
+ }
360
+ free_cache(sc, fc, buf, 0x300);
361
+ }
362
+ }
crates/openwa-core/src/render/sprite/lzss.rs ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! LZSS-style decompressor with palette LUT remapping for sprite subframes.
2
+ //!
3
+ //! Pure-Rust port of `Sprite_LZSS_Decode` (0x5B29E0). Used by
4
+ //! [`Sprite__GetFrameForBlit`](super::sprite) and `SpriteBank__GetFrameForBlit`
5
+ //! to lazily decompress sprite subframe pixel data into a `FrameCache`
6
+ //! payload.
7
+ //!
8
+ //! ## Format
9
+ //!
10
+ //! The decoder consumes a stream of source bytes from `src`. Each iteration
11
+ //! reads one byte and dispatches:
12
+ //!
13
+ //! - **Literal** (`src[0] & 0x80 == 0`): emit `lut[src[0]]` to `dst`,
14
+ //! advance `src` by 1, advance `dst` by 1.
15
+ //!
16
+ //! - **Control** (`src[0] & 0x80 != 0`): the control word is `src[0..2]`,
17
+ //! interpreted as `(src[0] << 8) | src[1]`.
18
+ //! - `distance = control & 0x7FF` (11-bit back-reference offset).
19
+ //! - `nibble = (src[0] >> 3) & 0xF`.
20
+ //!
21
+ //! Three sub-forms:
22
+ //!
23
+ //! 1. **Short back-ref** (`nibble != 0`): copy `nibble + 2` bytes from
24
+ //! `dst - distance - 1` to `dst`. Range 3..=17. Advance `src` by 2.
25
+ //! 2. **Long back-ref** (`nibble == 0`, `distance != 0`): read a third
26
+ //! byte at `src[2]`. Copy `src[2] + 18` bytes from `dst - distance`
27
+ //! (note the asymmetry β€” short refs use `-distance - 1`, long refs
28
+ //! use `-distance`). Range 18..=273. Advance `src` by 3.
29
+ //! 3. **Terminator** (`nibble == 0`, `distance == 0`): exit the loop.
30
+ //!
31
+ //! **Encoding asymmetry**: the encoded `distance` field for a short
32
+ //! back-ref is `real_distance - 1` (the asm's `DEC ECX` between the
33
+ //! `JZ` and the read makes up the difference), but for a long back-ref
34
+ //! it equals `real_distance` directly. The `JZ extended` jumps over
35
+ //! the `DEC ECX` instruction. This was verified from the disassembly
36
+ //! at 0x5B2A13/0x5B2A15 β€” there is no encoder-side fix-up that would
37
+ //! normalize the two paths.
38
+ //!
39
+ //! ## LUT
40
+ //!
41
+ //! `lut` is a 256-byte palette translation table. For sprites it lives at
42
+ //! `Sprite::palette_data_ptr` (`+0x68`); for sprite banks it's the inline
43
+ //! `SpriteBank::palette_lut` (`+0x30`). Entry 0 is always `0` (transparent
44
+ //! index); entries `1..=palette_count` are mapped runtime palette indices.
45
+ //! See `feedback_layer_sprite_palette.md` for the off-by-one rationale.
46
+ //!
47
+ //! ## Buffer requirements
48
+ //!
49
+ //! - `src` must contain at least 3 bytes per control sequence (the original
50
+ //! reads `src[2]` unconditionally on the long-backref path).
51
+ //! - `dst` must have already-written bytes at offsets `[-distance - 1, -1]`
52
+ //! for any short back-ref, or `[-distance, -1]` for any long back-ref.
53
+ //! Caller responsibility β€” the decoder does no bounds checking.
54
+ //! - `lut` must point at a 256-byte buffer.
55
+ //!
56
+ //! The original WA function wraps its body in `PUSHAD`/`POPAD` so callers
57
+ //! see no register clobber. The Rust port has no such concern.
58
+
59
+ /// Decode an LZSS-compressed sprite subframe into `dst`, remapping each
60
+ /// literal byte through `lut` first.
61
+ ///
62
+ /// # Safety
63
+ ///
64
+ /// - `dst` must point at a writable buffer large enough to hold the
65
+ /// decompressed payload (caller-known via `SpriteSubframeCache::decoded_size`
66
+ /// or `SpriteBankSubframeCache::decoded_size`).
67
+ /// - `src` must contain a well-formed LZSS stream terminated by a
68
+ /// zero-length zero-distance control word.
69
+ /// - `lut` must point at a 256-byte palette translation table.
70
+ /// - Back-references at `dst - distance - 1` (short) or `dst - distance`
71
+ /// (long) must lie within the already-decoded portion of `dst`.
72
+ pub unsafe fn sprite_lzss_decode(mut dst: *mut u8, mut src: *const u8, lut: *const u8) {
73
+ loop {
74
+ // ── Literal run ──────────────────────────────────────────────
75
+ let b: u8 = loop {
76
+ let b = *src;
77
+ if b & 0x80 != 0 {
78
+ break b;
79
+ }
80
+ *dst = *lut.add(b as usize);
81
+ src = src.add(1);
82
+ dst = dst.add(1);
83
+ };
84
+
85
+ // ── Control word ─────────────────────────────────────────────
86
+ // CX = src[0]<<8 | src[1]; distance = CX & 0x7FF.
87
+ let distance = (((b as u32) << 8) | (*src.add(1) as u32)) & 0x7FF;
88
+ let nibble = ((b as u32) >> 3) & 0xF;
89
+
90
+ if nibble != 0 {
91
+ // ── Short back-ref ───────────────────────────────────────
92
+ // Asm uses ECX = -distance - 1 for the entire run; copies
93
+ // happen as `*EDI = [ECX + EDI]` followed by `INC EDI`,
94
+ // which means each successive write reads one position later.
95
+ // The total length is `nibble + 2` bytes (range 3..=17).
96
+ //
97
+ // The original asm structure does 3 unconditional copies and
98
+ // then `(nibble - 1)` more in a loop. Equivalent to a flat
99
+ // loop of `nibble + 2` iterations.
100
+ let copy_offset = (distance as usize).wrapping_add(1);
101
+ let total = (nibble as usize) + 2;
102
+ for _ in 0..total {
103
+ *dst = *dst.sub(copy_offset);
104
+ dst = dst.add(1);
105
+ }
106
+ src = src.add(2);
107
+ } else {
108
+ // ── Terminator or long back-ref ──────────────────────────
109
+ if distance == 0 {
110
+ return;
111
+ }
112
+ // Long back-ref uses ECX = -distance (NOT decremented β€” the
113
+ // asm's `JZ extended` jumps OVER the `DEC ECX`). Length is
114
+ // `src[2] + 18` (range 18..=273).
115
+ let len = (*src.add(2) as usize) + 18;
116
+ let copy_offset = distance as usize;
117
+ for _ in 0..len {
118
+ *dst = *dst.sub(copy_offset);
119
+ dst = dst.add(1);
120
+ }
121
+ src = src.add(3);
122
+ }
123
+ }
124
+ }
125
+
126
+ #[cfg(test)]
127
+ mod tests {
128
+ use super::sprite_lzss_decode;
129
+
130
+ /// Identity LUT β€” `lut[i] == i`.
131
+ fn id_lut() -> [u8; 256] {
132
+ let mut l = [0u8; 256];
133
+ for i in 0..256 {
134
+ l[i] = i as u8;
135
+ }
136
+ l
137
+ }
138
+
139
+ /// Pure-literal stream of bytes 1..16, terminated.
140
+ #[test]
141
+ fn literals_only() {
142
+ let lut = id_lut();
143
+ // 16 literals + terminator (0x80, 0x00 β†’ nibble=0, distance=0).
144
+ let mut src = vec![];
145
+ for i in 1..=16u8 {
146
+ src.push(i);
147
+ }
148
+ src.push(0x80);
149
+ src.push(0x00);
150
+
151
+ let mut dst = vec![0u8; 16];
152
+ unsafe {
153
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
154
+ }
155
+ let expected: Vec<u8> = (1..=16u8).collect();
156
+ assert_eq!(dst, expected);
157
+ }
158
+
159
+ /// Literals routed through a non-identity LUT.
160
+ #[test]
161
+ fn literals_with_lut() {
162
+ let mut lut = id_lut();
163
+ lut[0x05] = 0xAA;
164
+ lut[0x10] = 0xBB;
165
+
166
+ let src: Vec<u8> = vec![0x05, 0x10, 0x80, 0x00];
167
+ let mut dst = vec![0u8; 2];
168
+ unsafe {
169
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
170
+ }
171
+ assert_eq!(dst, vec![0xAA, 0xBB]);
172
+ }
173
+
174
+ /// Short back-ref: emit "ABC", then copy 3 bytes from `dst - 3`.
175
+ ///
176
+ /// **Encoding asymmetry**: short refs encode `real_distance - 1` (the
177
+ /// asm's `DEC ECX` adds the +1), while long refs encode the real
178
+ /// distance directly. So to back up 3 bytes, we encode `2` in the
179
+ /// distance bits.
180
+ #[test]
181
+ fn short_backref_basic() {
182
+ let lut = id_lut();
183
+ // nibble = 1 (length = 3), encoded distance = 2 (real = 3).
184
+ // src[0] = 0x80 | (1 << 3) | ((2 >> 8) & 0x7) = 0x88
185
+ // src[1] = 2 & 0xFF = 0x02
186
+ let src: Vec<u8> = vec![b'A', b'B', b'C', 0x88, 0x02, 0x80, 0x00];
187
+ let mut dst = vec![0u8; 6];
188
+ unsafe {
189
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
190
+ }
191
+ assert_eq!(&dst, b"ABCABC");
192
+ }
193
+
194
+ /// Short back-ref length boundary: nibble = 15 β†’ copies 17 bytes.
195
+ /// Encode `real_distance - 1 = 16` to back up 17 bytes.
196
+ #[test]
197
+ fn short_backref_max_length() {
198
+ let lut = id_lut();
199
+ // Pre-fill 17 unique bytes, then back-ref:
200
+ // nibble = 15 (length 17), encoded distance = 16 (real = 17).
201
+ let mut src: Vec<u8> = (1..=17u8).collect();
202
+ // src[0] = 0x80 | (15 << 3) | ((16 >> 8) & 0x7) = 0xF8
203
+ // src[1] = 16 & 0xFF = 0x10
204
+ src.push(0xF8);
205
+ src.push(0x10);
206
+ src.push(0x80);
207
+ src.push(0x00);
208
+
209
+ let mut dst = vec![0u8; 34];
210
+ unsafe {
211
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
212
+ }
213
+ let expected: Vec<u8> = (1..=17u8).chain(1..=17u8).collect();
214
+ assert_eq!(dst, expected);
215
+ }
216
+
217
+ /// Long back-ref: nibble = 0, distance != 0, length = src[2] + 18.
218
+ /// Note long-form uses `-distance` (NOT `-distance - 1`).
219
+ #[test]
220
+ fn long_backref_basic() {
221
+ let lut = id_lut();
222
+ // Pre-fill 20 bytes 1..20, then long back-ref:
223
+ // nibble=0, distance=20 (so `-distance` reads dst[-20]=1, etc.)
224
+ // src[2] = 0 β†’ length = 18.
225
+ let mut src: Vec<u8> = (1..=20u8).collect();
226
+ // src[0] = 0x80 | (0 << 3) | ((20 >> 8) & 0x7) = 0x80
227
+ // src[1] = 20 & 0xFF = 0x14
228
+ // src[2] = 0 β†’ length 18
229
+ src.push(0x80);
230
+ src.push(0x14);
231
+ src.push(0x00);
232
+ src.push(0x80);
233
+ src.push(0x00);
234
+
235
+ let mut dst = vec![0u8; 38];
236
+ unsafe {
237
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
238
+ }
239
+ let expected: Vec<u8> = (1..=20u8).chain(1..=18u8).collect();
240
+ assert_eq!(dst, expected);
241
+ }
242
+
243
+ /// Self-overlapping short back-ref (RLE-style): emit one byte, then
244
+ /// short-copy with distance = 1 (which becomes `-distance - 1 = -2`,
245
+ /// a stride-2 read). This isn't classic RLE β€” verify against the
246
+ /// asm semantics, not against expectation.
247
+ ///
248
+ /// With dst = [X, _, _, ...] and copy_offset = 2:
249
+ /// write 1: dst[1] = dst[1-2] β†’ reads dst[-1] (out of buffer!)
250
+ ///
251
+ /// So distance = 1 short-copies are unsafe. Test distance = 0 with the
252
+ /// nibble path, which `-1` β†’ reads dst[-1]. Also unsafe.
253
+ /// Skip this test β€” both edge cases reach outside `dst` and only work
254
+ /// with valid pre-loaded backing.
255
+ #[test]
256
+ fn self_overlap_distance_zero_short() {
257
+ // Use distance = 0, nibble = 1 β†’ copy_offset = 1, length = 3.
258
+ // Each iteration: dst[i] = dst[i - 1].
259
+ // With dst = [42, _, _, _]: yields [42, 42, 42, 42].
260
+ let lut = id_lut();
261
+ let src: Vec<u8> = vec![
262
+ 42, // literal
263
+ // distance=0, nibble=1
264
+ // src[0] = 0x80 | (1<<3) | 0 = 0x88
265
+ // src[1] = 0x00
266
+ 0x88, 0x00, // terminator
267
+ 0x80, 0x00,
268
+ ];
269
+ let mut dst = vec![0u8; 4];
270
+ unsafe {
271
+ sprite_lzss_decode(dst.as_mut_ptr(), src.as_ptr(), lut.as_ptr());
272
+ }
273
+ assert_eq!(dst, vec![42, 42, 42, 42]);
274
+ }
275
+ }
crates/openwa-core/src/render/sprite/mod.rs CHANGED
@@ -1,8 +1,12 @@
 
1
  pub mod gfx_dir;
 
2
  pub mod spr;
3
  pub mod sprite;
4
  pub mod sprite_id;
5
 
 
 
6
  pub use spr::{ParsedSprite, SprError, SprHeader};
7
  pub use sprite::{
8
  Sprite, SpriteBank, SpriteBankBboxEntry, SpriteBankFrame, SpriteBankSubframeCache,
 
1
+ pub mod frame_cache;
2
  pub mod gfx_dir;
3
+ pub mod lzss;
4
  pub mod spr;
5
  pub mod sprite;
6
  pub mod sprite_id;
7
 
8
+ pub use frame_cache::frame_cache_allocate;
9
+ pub use lzss::sprite_lzss_decode;
10
  pub use spr::{ParsedSprite, SprError, SprHeader};
11
  pub use sprite::{
12
  Sprite, SpriteBank, SpriteBankBboxEntry, SpriteBankFrame, SpriteBankSubframeCache,
crates/openwa-dll/src/replacements/render.rs CHANGED
@@ -1358,6 +1358,7 @@ fn install_display() -> Result<(), String> {
1358
  update_palette => display_vtable::update_palette,
1359
  set_font_palette => display_vtable::set_font_palette,
1360
  slot 19 => blit_sprite,
 
1361
  load_sprite_by_layer => load_sprite_by_layer,
1362
  load_font => load_font,
1363
  load_font_extension => load_font_extension,
 
1358
  update_palette => display_vtable::update_palette,
1359
  set_font_palette => display_vtable::set_font_palette,
1360
  slot 19 => blit_sprite,
1361
+ get_sprite_frame_for_blit => display_vtable::get_sprite_frame_for_blit,
1362
  load_sprite_by_layer => load_sprite_by_layer,
1363
  load_font => load_font,
1364
  load_font_extension => load_font_extension,