Skip to content

Fix sprite wire format desync; add device-free damage repaint - #3

Merged
DryadAI merged 1 commit into
mainfrom
fix/sprite-wire-format-and-damage-repaint
Aug 25, 2026
Merged

DryadAI merged 1 commit into
mainfrom
fix/sprite-wire-format-and-damage-repaint

Conversation

@DryadAI

@DryadAI DryadAI commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Three latent wire-format bugs in SpriteSurface, plus the missing erase
path. All host-verified; none of it has touched hardware.

Verified against brilliant_msg 7.1.1tx_sprite.py,
tx_sprite_coords.py, lua/sprite.lua. CLAUDE.md pinned 7.0.0; 7.1.0
was explicitly a "True-up against Halo firmware 0.8.8", so the SDK is
tracking the same firmware revision this project reads.

The one that matters

Palette length is load-bearing and was not being enforced.
sprite.lua slices the palette as exactly num_colors * 3 bytes and
treats everything after it as pixel data. SpriteSurface sent
bytes(palette) whole, regardless of num_colors.

This works today only by coincidence: ramp_palette(4) is exactly 12
bytes, which happens to match. Any other palette shifts every pixel in
the frame.

It cannot fail host-side — PILSurface never reads palette_data — so
the suite would stay green while the glass showed garbage, and it would
present as a wire-format problem rather than a palette-length one.

The other two

  • Sprite codes collided after 224 draws. _next_code() derived from
    len(self.ops), and present() never clears the log, so codes ran
    past 0xFF and wrapped onto sprites still live on the device. Now
    cycles within base_code..0xFF, with reset_codes() for a cleared
    display.
  • num_colors could be a value the packer cannot express.
    max(2, len(palette) // 3) yields e.g. 5, which TxSprite.pack()
    encodes as 4bpp while declaring 5 — the same slice desync. Now rounded
    to {2, 4, 16}, rejected above 16.

Nothing erased

blit_coverage writes ink through a mask, so a re-rendered shorter line
left the previous tail on the glass. Neither backend recovered:
SpriteSurface.present() is a no-op, PILSurface.present() only clears
dirty.

RetainedSurface buffers a frame, diffs by value against the last
committed frame, and forwards only the damaged regions — filling
background where something was removed, then replaying every op
intersecting the region in draw order. Replaying only the changed op
takes overlapping static content with it.

It wraps either backend and contains no device call, so rule 1 holds.
damage.py is named apart from geometry.py deliberately: one is the
shape of the glass, the other is what changed since last frame.

Also relevant to rule 2 — Halo firmware 0.8.8 registers display.show()
as a no-op with no back buffer, so a clear-and-repaint is a full-field
luminance transient. Differential update is what keeps that off the
glass.

What this does not fix

The x/y origin remains unconfirmed and is not resolvable by reading
the SDK. sprite_coords.lua only parses the fields; what they mean is
decided by the app-side Lua calling frame.display.bitmap, which is
1-based on Halo. TxSpriteCoords documents x as 1..640 — Frame's panel,
the same stale bound as TxPlainText. Needs a device.

Tests

251 passing, up from 235. 16 new in tests/test_retained.py, split
between wire-format invariants and retained-mode behaviour over both
backends. The surface-agreement test in test_glanceable.py still
passes

@DryadAI
DryadAI merged commit 2eace57 into main Aug 25, 2026
2 checks passed
@DryadAI
DryadAI deleted the fix/sprite-wire-format-and-damage-repaint branch August 27, 2026 03:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant