From 85a6d9a806e77970d12b71b0737c2378abed4392 Mon Sep 17 00:00:00 2001 From: Alessandro Solbiati Date: Wed, 2 Sep 2026 17:38:48 -0700 Subject: [PATCH 1/3] Sprite v1: Define Encoded Sprite (0x08) with indexed, deflate, and palette-swap payloads Define Sprite (0x01) ships every sprite as raw RGBA under Snappy: 4 bytes per pixel before compression, and Snappy finds little in flat 16-color art. A 748x941 map with 16 colors costs 685 KB per copy on the wire, and a game that pre-composites five dusk tints of it pays that six times. This adds one server-to-client message, Define Encoded Sprite (0x08). It has the fields of Define Sprite plus an encoding byte before the payload length: 0x00 rgba-snappy the legacy payload, unchanged 0x01 rgba-deflate zlib over raw RGBA 0x02 indexed u8 count-1, count*4 RGBA palette, zlib over one index byte per pixel (sprites with at most 256 colors) 0x03 palette-swap u16 source sprite id, u8 count-1, count*4 RGBA palette; reuses the index plane of an indexed sprite the client already holds Define Sprite keeps working unchanged; a client that has not been updated only breaks if a server sends 0x08 to it, which is the same rule as for any new message type. Server side: addEncodedSprite picks indexed or rgba-deflate per sprite; addPaletteSwapSprite emits a swap when the new pixels are a per-color recoloring of the source and falls back to a normal sprite otherwise. parseSpritePacket returns 0x08 as spkSprite with the encoding and payload; decodeSprite turns any definition (legacy included) into straight RGBA and keeps the index plane for later swaps. Clients: the shared browser clients load client/spritecodec.js (a small zlib/deflate inflater plus the palette expansion) and parse 0x08 next to 0x01; the native and wasm global client decode through spriteprotocol.decodeSprite with zippy. client.nim serves spritecodec.js on the same routes as snappyjs.min.js. Tests: tests/test_spriteencoding.nim round-trips every encoding pixel-identically and covers the swap fallback, message sizes, and malformed payloads; tests/test_spritecodec_js.nim runs the browser decoder under node against packets the Nim encoder wrote (skips when node is absent); tests/test_client.nim pins the new routes and parser cases. Measured on Heartleaf's director init packet (313 sprites): 7,487,421 bytes with Define Sprite, 1,083,170 bytes with Define Encoded Sprite. Co-Authored-By: Claude Fable 5 --- client/global_client.html | 33 ++++ client/global_client.nim | 58 ++++++ client/player_client.html | 8 + client/spritecodec.js | 258 ++++++++++++++++++++++++ docs/sprite_v1.md | 56 +++++- src/bitworld/client.nim | 13 +- src/bitworld/spriteprotocol.nim | 340 ++++++++++++++++++++++++++++++++ tests/spritecodec_check.js | 91 +++++++++ tests/test_client.nim | 22 ++- tests/test_spritecodec_js.nim | 114 +++++++++++ tests/test_spriteencoding.nim | 242 +++++++++++++++++++++++ 11 files changed, 1232 insertions(+), 3 deletions(-) create mode 100644 client/spritecodec.js create mode 100644 tests/spritecodec_check.js create mode 100644 tests/test_spritecodec_js.nim create mode 100644 tests/test_spriteencoding.nim diff --git a/client/global_client.html b/client/global_client.html index 8118d42..96204eb 100644 --- a/client/global_client.html +++ b/client/global_client.html @@ -162,6 +162,7 @@
+ + " in html + doAssert "type===0x08" in html + doAssert "SpriteCodec.readEncodedSprite(bytes,offset,textDecoder)" in html + doAssert "SpriteCodec.decodeSprite(" in html + let codec = clientStaticBody(SpriteCodecClientRoute) + doAssert "root.SpriteCodec={inflate,expandIndices,decodeSprite,readEncodedSprite}" in codec + doAssert clientStaticBody(CoworldSpriteCodecClientRoute) == codec + doAssert clientStaticBody(SpriteCodecClientPath) == codec + proc testGlobalClientFitsIframeView() = ## Tests that the hosted viewer sizes UI to the canvas, not the page. echo "Testing global client iframe view size" @@ -111,6 +130,7 @@ testReplayClientPreservesUri() testGlobalClientFullScreenLayers() testGlobalClientWheelZoomTargetsMap() testPlayerClientSpeaksSpriteProtocol() +testClientsDecodeEncodedSprites() testGlobalClientFitsIframeView() testEmbeddedClientBodies() echo "All tests passed" diff --git a/tests/test_spritecodec_js.nim b/tests/test_spritecodec_js.nim new file mode 100644 index 0000000..bdc7d7f --- /dev/null +++ b/tests/test_spritecodec_js.nim @@ -0,0 +1,114 @@ +## Cross-checks the browser decoder (client/spritecodec.js) against the +## Nim encoder: builds a packet that exercises every encoding, writes +## the source RGBA next to it, and has node decode the packet with the +## same JavaScript the HTML clients load. Skips when node is missing. + +import + std/[os, osproc, random, strutils], + bitworld/spriteprotocol + +proc pixels(width, height, colors: int, seed: int): seq[uint8] = + ## Deterministic sprite with the given number of RGBA values. + var rng = initRand(seed) + result = newSeq[uint8](width * height * 4) + for i in 0 ..< width * height: + let + color = rng.rand(colors - 1) + offset = i * 4 + result[offset] = uint8(color * 7 mod 256) + result[offset + 1] = uint8(color * 13 mod 256) + result[offset + 2] = uint8(color * 29 mod 256) + result[offset + 3] = uint8(if color == 0: 0 elif color mod 3 == 0: 128 else: 255) + +proc bands(width, height, colors: int): seq[uint8] = + ## Long horizontal runs of few colors: highly compressible, so the + ## deflate stream has long matches. + result = newSeq[uint8](width * height * 4) + for y in 0 ..< height: + for x in 0 ..< width: + let + color = (x div 16 + y div 8) mod colors + offset = (y * width + x) * 4 + result[offset] = uint8(color * 40) + result[offset + 1] = uint8(255 - color * 30) + result[offset + 2] = uint8(color * 90) + result[offset + 3] = 255 + +proc noise(width, height: int, seed: int): seq[uint8] = + ## Incompressible RGBA, which makes deflate fall back to stored blocks. + var rng = initRand(seed) + result = newSeq[uint8](width * height * 4) + for i in 0 ..< result.len: + result[i] = uint8(rng.rand(255)) + +proc recolor(source: seq[uint8]): seq[uint8] = + result = source + for i in 0 ..< source.len div 4: + let offset = i * 4 + result[offset] = source[offset] xor 0xa5 + result[offset + 1] = source[offset + 1] div 3 + result[offset + 2] = 255'u8 - source[offset + 2] + +proc main() = + echo "Testing browser sprite codec against the Nim encoder" + let node = findExe("node") + if node.len == 0: + echo "node not found, skipping browser codec check" + return + + var packet, expected: seq[uint8] + proc expect(id: int, rgba: seq[uint8]) = + expected.addU16(id) + expected.addU32(rgba.len) + expected.add(rgba) + + let + small = pixels(37, 23, 16, 1) + full = pixels(40, 30, 256, 2) + flat = bands(300, 200, 6) + random = noise(64, 48, 3) + tinted = recolor(small) + tintedFlat = recolor(flat) + tiny = pixels(1, 1, 2, 4) + + packet.addEncodedSprite(1, 37, 23, small, "indexed") + expect(1, small) + packet.addEncodedSprite(2, 40, 30, full, "full palette") + expect(2, full) + packet.addEncodedSprite(3, 300, 200, flat, "bands") + expect(3, flat) + packet.addEncodedSprite(4, 64, 48, random, "noise deflate") + expect(4, random) + packet.addEncodedSprite(5, 64, 48, random, "noise snappy", SpriteEncodingRgbaSnappy) + expect(5, random) + packet.addEncodedSprite(6, 300, 200, flat, "bands deflate", SpriteEncodingRgbaDeflate) + expect(6, flat) + doAssert packet.addPaletteSwapSprite(7, 1, 37, 23, small, tinted, "tint") + expect(7, tinted) + doAssert packet.addPaletteSwapSprite(8, 3, 300, 200, flat, tintedFlat, "bands tint") + expect(8, tintedFlat) + doAssert packet.addPaletteSwapSprite(9, 8, 300, 200, tintedFlat, flat, "bands back") + expect(9, flat) + packet.addSprite(10, 37, 23, small, "legacy") + expect(10, small) + packet.addEncodedSprite(11, 1, 1, tiny, "tiny") + expect(11, tiny) + + let dir = getTempDir() / "bitworld_spritecodec_check" + createDir(dir) + let + packetPath = dir / "packet.bin" + expectedPath = dir / "expected.bin" + writeFile(packetPath, cast[string](packet)) + writeFile(expectedPath, cast[string](expected)) + + let script = currentSourcePath().parentDir() / "spritecodec_check.js" + let (output, code) = execCmdEx( + quoteShellCommand([node, script, packetPath, expectedPath]) + ) + echo output.strip() + doAssert code == 0, "browser codec check failed" + doAssert "11 sprites checked, 0 failed" in output + echo "All tests passed" + +main() diff --git a/tests/test_spriteencoding.nim b/tests/test_spriteencoding.nim new file mode 100644 index 0000000..c1b1bed --- /dev/null +++ b/tests/test_spriteencoding.nim @@ -0,0 +1,242 @@ +## Round-trip tests for Define Encoded Sprite (0x08): every encoding +## must decode to the exact RGBA bytes that went in, the legacy Define +## Sprite message must keep working next to it, and malformed encoded +## messages must raise. + +import + std/[random, tables], + bitworld/spriteprotocol, + supersnappy + +proc expectSpriteError(body: proc()) = + ## Asserts that a body raises SpriteProtocolError. + var raised = false + try: + body() + except SpriteProtocolError: + raised = true + doAssert raised, "expected a sprite protocol error" + +proc checkerPixels(width, height, colors: int, seed = 1): seq[uint8] = + ## Builds a deterministic sprite with the given number of distinct + ## RGBA values, including transparent and translucent pixels. + var rng = initRand(seed) + result = newSeq[uint8](width * height * 4) + for i in 0 ..< width * height: + let + color = rng.rand(colors - 1) + offset = i * 4 + result[offset] = uint8(color * 7 mod 256) + result[offset + 1] = uint8(color * 13 mod 256) + result[offset + 2] = uint8(color * 29 mod 256) + result[offset + 3] = uint8(if color == 0: 0 elif color mod 3 == 0: 128 else: 255) + +proc allColorsPixels(): seq[uint8] = + ## A 16x16 sprite that uses exactly 256 distinct RGBA values. + result = newSeq[uint8](256 * 4) + for i in 0 ..< 256: + result[i * 4] = uint8(i) + result[i * 4 + 1] = uint8(255 - i) + result[i * 4 + 2] = uint8(i * 3 mod 256) + result[i * 4 + 3] = uint8(if i == 0: 0 else: 255) + +proc noisePixels(width, height: int, seed = 2): seq[uint8] = + ## Builds an incompressible RGBA sprite with far more than 256 colors. + var rng = initRand(seed) + result = newSeq[uint8](width * height * 4) + for i in 0 ..< result.len: + result[i] = uint8(rng.rand(255)) + +proc recolor(pixels: seq[uint8]): seq[uint8] = + ## Recolors pixels one color at a time, the way a tint does. + result = pixels + for i in 0 ..< pixels.len div 4: + let offset = i * 4 + result[offset] = 255'u8 - pixels[offset] + result[offset + 1] = pixels[offset + 1] div 2 + result[offset + 2] = pixels[offset + 2] xor 0x55 + +proc decodeAll(packet: seq[uint8]): OrderedTable[int, DecodedSprite] = + ## Decodes every sprite definition in a packet in order, resolving + ## palette swaps against earlier definitions. + result = initOrderedTable[int, DecodedSprite]() + for message in packet.parseSpritePacket(): + if message.kind != spkSprite: + continue + var source: DecodedSprite + let sourceId = message.sprite.paletteSwapSourceId() + if sourceId >= 0 and result.hasKey(sourceId): + source = result[sourceId] + result[message.sprite.id] = message.sprite.decodeSprite(source) + +proc testEncodings() = + echo "Testing encoded sprite round trips" + let + indexed = checkerPixels(37, 23, 16) + full = allColorsPixels() + noise = noisePixels(64, 48) + tinted = recolor(indexed) + var packet: seq[uint8] + doAssert packet.addEncodedSprite(1, 37, 23, indexed, "indexed") == + SpriteEncodingIndexed + doAssert packet.addEncodedSprite(2, 16, 16, full, "full palette") == + SpriteEncodingIndexed + doAssert packet.addEncodedSprite(3, 64, 48, noise, "noise") == + SpriteEncodingRgbaDeflate + doAssert packet.addEncodedSprite( + 4, 64, 48, noise, "noise snappy", SpriteEncodingRgbaSnappy + ) == SpriteEncodingRgbaSnappy + doAssert packet.addEncodedSprite( + 5, 37, 23, indexed, "indexed deflate", SpriteEncodingRgbaDeflate + ) == SpriteEncodingRgbaDeflate + doAssert packet.addPaletteSwapSprite(6, 1, 37, 23, indexed, tinted, "tint") + packet.addSprite(7, 37, 23, indexed, "legacy") + packet.addEncodedSpritePayload(8, 37, 23, SpriteEncodingIndexed, [], "ghost") + + let decoded = packet.decodeAll() + doAssert decoded.len == 8 + doAssert decoded[1].pixels == indexed + doAssert decoded[1].indices.len == 37 * 23 + doAssert decoded[1].palette.len == 16 * 4 + doAssert decoded[2].pixels == full + doAssert decoded[2].palette.len == 256 * 4 + doAssert decoded[3].pixels == noise + doAssert decoded[3].indices.len == 0 + doAssert decoded[4].pixels == noise + doAssert decoded[5].pixels == indexed + doAssert decoded[6].pixels == tinted + doAssert decoded[6].indices == decoded[1].indices + doAssert decoded[7].pixels == indexed + doAssert decoded[8].pixels.len == 0 + doAssert decoded[8].width == 37 + + # A palette swap of a palette swap keeps working. + var chained: seq[uint8] + chained.addEncodedSprite(1, 37, 23, indexed) + doAssert chained.addPaletteSwapSprite(2, 1, 37, 23, indexed, tinted) + doAssert chained.addPaletteSwapSprite(3, 2, 37, 23, tinted, indexed) + let chainDecoded = chained.decodeAll() + doAssert chainDecoded[3].pixels == indexed + + # The messages parse with the encoding and payload preserved. + let messages = packet.parseSpritePacket() + doAssert messages[0].sprite.encoding == SpriteEncodingIndexed + doAssert messages[2].sprite.encoding == SpriteEncodingRgbaDeflate + doAssert messages[3].sprite.encoding == SpriteEncodingRgbaSnappy + doAssert uncompress(messages[3].sprite.compressedPixels) == noise + doAssert messages[5].sprite.encoding == SpriteEncodingPaletteSwap + doAssert messages[5].sprite.paletteSwapSourceId() == 1 + doAssert messages[6].sprite.encoding == SpriteEncodingRgbaSnappy + doAssert messages[6].sprite.paletteSwapSourceId() == -1 + doAssert messages[7].sprite.label == "ghost" + doAssert packet.spritePacketSpriteIds() == @[1, 2, 3, 4, 5, 6, 7, 8] + + # Sizes: indexed beats snappy RGBA by a wide margin on flat art, and a + # palette swap is header plus palette. + var flat = newSeq[uint8](64 * 64 * 4) + for i in 0 ..< 64 * 64: + let color = (i mod 64 div 8 + i div 64 div 16) mod 6 + flat[i * 4] = uint8(color * 40) + flat[i * 4 + 1] = uint8(200 - color * 30) + flat[i * 4 + 2] = uint8(color * 90 mod 256) + flat[i * 4 + 3] = 255 + var legacy, encoded, swap: seq[uint8] + legacy.addSprite(1, 64, 64, flat) + encoded.addEncodedSprite(1, 64, 64, flat) + swap.addPaletteSwapSprite(2, 1, 64, 64, flat, recolor(flat)) + doAssert encoded.len * 4 < legacy.len + doAssert swap.len == 14 + 2 + 1 + 6 * 4 + +proc testPaletteSwapFallback() = + echo "Testing palette swap fallback" + let + base = checkerPixels(20, 20, 8) + noise = noisePixels(20, 20) # 400 pixels, more than 256 colors + var inconsistent = base + # One pixel changes color while the other pixels of that source color + # keep theirs, so the recoloring is not a palette swap. + inconsistent[0] = 1 + inconsistent[1] = 2 + inconsistent[2] = 3 + inconsistent[3] = 4 + var packet: seq[uint8] + packet.addEncodedSprite(1, 20, 20, base) + doAssert not packet.addPaletteSwapSprite(2, 1, 20, 20, base, inconsistent) + doAssert not packet.addPaletteSwapSprite(3, 1, 20, 20, noise, base) + let decoded = packet.decodeAll() + doAssert decoded[2].pixels == inconsistent + doAssert decoded[2].indices.len == 400 + doAssert decoded[3].pixels == base + doAssert encodePaletteSwapPayload(1, base, base).len == 3 + 8 * 4 + doAssert encodePaletteSwapPayload(1, noise, noise).len == 0 + doAssert encodePaletteSwapPayload(1, base, base[0 ..< 8]).len == 0 + doAssert encodePaletteSwapPayload(1, base, inconsistent).len == 0 + +proc testEncodedMessageBytes() = + echo "Testing encoded sprite message byte sizes" + var packet: seq[uint8] + packet.addEncodedSprite(3, 4, 4, checkerPixels(4, 4, 3), "abc") + packet.addPaletteSwapSprite(4, 3, 4, 4, checkerPixels(4, 4, 3), + recolor(checkerPixels(4, 4, 3)), "") + packet.addObject(1, 0, 0, 0, 0, 3) + var offset = 0 + let first = packet.spriteMessageBytes(0) + doAssert packet[0] == SpriteMessageEncodedSprite + doAssert first > 14 + 3 + offset += first + doAssert packet[offset] == SpriteMessageEncodedSprite + doAssert packet.spriteMessageBytes(offset) == 14 + 3 + 3 * 4 + offset += packet.spriteMessageBytes(offset) + doAssert packet.spriteMessageBytes(offset) == 12 + offset += 12 + doAssert offset == packet.len + doAssert @[SpriteMessageEncodedSprite, 1'u8, 2].spriteMessageBytes(0) == 3 + +proc testMalformedEncodedMessages() = + echo "Testing malformed encoded sprites" + proc header(encoding: uint8, payloadLen: int): seq[uint8] = + result.addU8(SpriteMessageEncodedSprite) + result.addU16(1) + result.addU16(2) + result.addU16(2) + result.addU8(encoding) + result.addU32(payloadLen) + + expectSpriteError(proc() = discard parseSpritePacket(@[SpriteMessageEncodedSprite, 1'u8])) + expectSpriteError(proc() = discard parseSpritePacket(header(0x04, 0) & @[0'u8, 0])) + expectSpriteError(proc() = discard parseSpritePacket(header(SpriteEncodingIndexed, 5) & @[1'u8])) + expectSpriteError(proc() = discard parseSpritePacket(header(SpriteEncodingIndexed, 0))) + + # Decoding failures: bad deflate stream, index out of range, palette + # swap without a source, pixel count mismatch. + var badDeflate = header(SpriteEncodingRgbaDeflate, 3) & @[1'u8, 2, 3, 0, 0] + expectSpriteError(proc() = discard badDeflate.parseSpritePacket()[0].sprite.decodeSprite()) + + var payload: seq[uint8] + payload.add(0'u8) # one palette entry + payload.add([9'u8, 9, 9, 255]) + payload.add(encodeRgbaDeflatePayload([0'u8, 1, 0, 0])) # index 1 is out of range + var outOfRange = header(SpriteEncodingIndexed, payload.len) & payload & @[0'u8, 0] + expectSpriteError(proc() = discard outOfRange.parseSpritePacket()[0].sprite.decodeSprite()) + + var swap: seq[uint8] + swap.addU16(7) + swap.add(0'u8) + swap.add([1'u8, 2, 3, 4]) + var noSource = header(SpriteEncodingPaletteSwap, swap.len) & swap & @[0'u8, 0] + expectSpriteError(proc() = discard noSource.parseSpritePacket()[0].sprite.decodeSprite()) + + var wrongSize: seq[uint8] + wrongSize.addEncodedSprite(1, 2, 2, checkerPixels(3, 3, 2)) + expectSpriteError(proc() = discard wrongSize.parseSpritePacket()[0].sprite.decodeSprite()) + + expectSpriteError(proc() = + var packet: seq[uint8] + packet.addEncodedSprite(1, 64, 48, noisePixels(64, 48), "", SpriteEncodingIndexed) + ) + +testEncodings() +testPaletteSwapFallback() +testEncodedMessageBytes() +testMalformedEncodedMessages() +echo "All tests passed" From 3bd3782f8a1934eb1bf0f8db818f111eec5222c8 Mon Sep 17 00:00:00 2001 From: Alessandro Solbiati Date: Wed, 2 Sep 2026 17:40:19 -0700 Subject: [PATCH 2/3] docs: proposal for Define Encoded Sprite with Heartleaf measurements Co-Authored-By: Claude Fable 5 --- docs/compressed_sprites_proposal.md | 189 ++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/compressed_sprites_proposal.md diff --git a/docs/compressed_sprites_proposal.md b/docs/compressed_sprites_proposal.md new file mode 100644 index 0000000..2e2e723 --- /dev/null +++ b/docs/compressed_sprites_proposal.md @@ -0,0 +1,189 @@ +# Proposal: Define Encoded Sprite (0x08) for Sprite v1 + +To: Andre (treeform) +From: Alessandro Solbiati, with the prototype on branch `compressed-sprites` +of `SolbiatiAlessandro/bitworld` + +## The problem, with numbers + +Define Sprite (0x01) carries every sprite as raw RGBA, 4 bytes per pixel, +compressed with Snappy. Snappy is fast, but on flat pixel art it does not +remove much: it finds repeated 4-byte colors, not the structure of the image. + +Heartleaf's director view sends 313 sprites in its init packet. The source art +for the whole game is about 600 KB of aseprite files (`map.aseprite` is +289 KB). The init packet was 7,487,421 bytes. The top of the table, measured +with `tools/init_packet_report.nim` in the heartleaf repo: + +| id | label | size | wire bytes | raw RGBA | colors | +| ---: | --- | --- | ---: | ---: | ---: | +| 30 | forest underlay | 1708x1901 | 1,698,908 | 12,987,632 | 1629 | +| 1 | heartleaf bottom | 748x941 | 685,138 | 2,815,472 | 16 | +| 10..14 | heartleaf bottom tint 0..4 | 748x941 | 5 x ~680,000 | 2,815,472 each | 16 | +| 2 | heartleaf overhang | 748x941 | 164,407 | 2,815,472 | 17 | +| 15..19 | heartleaf overhang tint 0..4 | 748x941 | 5 x ~164,000 | 2,815,472 each | 17 | +| 4, 20..24 | home bottom and 5 tints | 251x247 | 6 x ~53,000 | 247,988 each | 16 | +| 8700 | chat banner | 318x60 | 29,353 | 76,320 | 1806 | +| 31..35 | forest dusk veil 0..4 | 512x256 | 5 x 24,650 | 524,288 each | 1 | + +Two things stand out. First, a 16-color map costs 685 KB because Snappy sees +RGBA, not indices. Second, the game pre-composites five dusk tints of each +map, and each tint is a full copy, although a tint of 16 colors is just 16 +different palette entries over the same index plane. + +## The change + +One new server-to-client message. The fields are Define Sprite's fields plus +one encoding byte in front of the payload length: + +| Field | Type | +| --- | --- | +| Message type | `u8` = `0x08` | +| Sprite id | `u16` | +| Width | `u16` | +| Height | `u16` | +| Encoding | `u8` | +| Payload length | `u32` | +| Payload | `u8[]` | +| Label length | `u16` | +| Label | `u8[]` | + +Encodings: + +| Value | Payload | +| ---: | --- | +| `0x00` rgba-snappy | Snappy stream of raw RGBA. Identical to the 0x01 payload. | +| `0x01` rgba-deflate | zlib stream of raw RGBA. | +| `0x02` indexed | `u8` palette count minus one, `count * 4` RGBA palette bytes, then a zlib stream of one index byte per pixel. At most 256 colors. | +| `0x03` palette-swap | `u16` source sprite id, `u8` palette count minus one, `count * 4` RGBA palette bytes. The client reuses the index plane of the indexed sprite it already holds under the source id. | + +A payload length of 0 is a pixel-free definition, same as 0x01. Every other +payload must decode to exactly `width * height * 4` bytes of straight RGBA. +The full text is in `docs/sprite_v1.md` on the branch. + +0x07 was skipped because stag_hunt already sends 0x07 as a private identity +packet and both browser clients skip its two bytes. The spec now says so. + +## Why this encoding and not PNG + +I measured PNG (pixie's encoder, RGBA, no palette), zlib over raw RGBA, +palette plus run-length, and palette plus zlib on all 313 sprites: + +| Encoding | Total bytes for the 313 sprites | +| --- | ---: | +| Snappy over RGBA (today) | 7,487,381 | +| PNG, RGBA color type | 3,765,210 | +| zlib over RGBA | 2,866,942 | +| palette + PackBits, PNG for >256 colors | 3,731,903 | +| palette + zlib, PNG for >256 colors | 2,195,573 | +| palette + zlib, zlib RGBA for >256 colors, palette swap for tints | 1,083,130 | + +Indexed PNG (color type 3) would give the same bytes as "palette + zlib" and is +a standard container, but it costs the same decoder work in JavaScript (an +inflater plus PNG chunk and filter parsing on top), it cannot express the +palette swap, and the browser's native PNG decode is asynchronous and does not +return exact bytes for translucent pixels (canvas premultiplies alpha). The +custom payloads are two short reads on top of inflate, decode synchronously +inside the existing packet parser, and are pixel-exact in every client. + +Snappy stays available as encoding 0x00 so a server can send the old payload +through the new message if it wants one code path. + +## Results on the Heartleaf director view + +Same 313 sprites, same pixels in every client: + +| | Bytes | +| --- | ---: | +| Init packet, Define Sprite | 7,487,421 | +| Init packet, Define Encoded Sprite | 1,083,170 | + +Per sprite, the big ones: + +| id | label | encoding | before | after | +| ---: | --- | --- | ---: | ---: | +| 30 | forest underlay | rgba-deflate | 1,698,908 | 766,928 | +| 1 | heartleaf bottom | indexed | 685,138 | 189,608 | +| 10..14 | heartleaf bottom tint 0..4 | palette-swap | 5 x ~680,000 | 5 x 81 | +| 2 | heartleaf overhang | indexed | 164,407 | 13,918 | +| 15..19 | heartleaf overhang tint 0..4 | palette-swap | 5 x ~164,000 | 5 x 85 | +| 4 | heartleaf home bottom | indexed | 53,032 | 14,448 | +| 20..24 | home bottom tint 0..4 | palette-swap | 5 x ~53,000 | 5 x 81 | +| 8700 | chat banner | rgba-deflate | 29,353 | 17,435 | +| 31..35 | forest dusk veil 0..4 | indexed | 5 x 24,650 | 5 x 41 | + +The forest underlay is now 71% of the packet. It is generated at half +resolution and upscaled 2x on the server, has 1629 colors, and does not index. +Quantizing it to 4 bits per channel (123 colors) would bring it to 306 KB; that +is a Heartleaf art decision, not a protocol one. A protocol-level fix would be +an object or sprite scale factor so the server can send it at half size; that +is a separate proposal. + +## Compatibility + +- Define Sprite (0x01) is unchanged and still tested. A server that never sends + 0x08 is unaffected. +- A client that predates this change closes the connection on 0x08, which is + the existing rule for unknown message types. A server should only send 0x08 + to clients it ships itself (the embedded browser clients, the native and + wasm global client) or to clients that are known to be updated. Heartleaf + serves its own copy of the bitworld browser client, so it upgrades both + sides in one deploy. +- `parseSpritePacket` returns 0x08 messages as `spkSprite`, with `encoding` + and the payload in `compressedPixels`. A legacy 0x01 message parses with + `encoding = SpriteEncodingRgbaSnappy`, so code that switches on encoding + handles both. +- Sprites Off clients get pixel-free definitions through either message. +- No change to the client-to-server direction, replays, or certification. + +## What is on the branch + +- `src/bitworld/spriteprotocol.nim`: constants, `encoding` on + `SpritePacketSpriteDef`, `DecodedSprite`, `indexPixels`, the four payload + encoders, `addEncodedSpritePayload`, `addEncodedSprite` (auto-picks indexed + or rgba-deflate), `addPaletteSwapSprite` (falls back to a normal sprite when + the recoloring is not consistent), `paletteSwapSourceId`, `decodeSprite`, + parser and `spriteMessageBytes` cases for 0x08. zippy is already a + dependency (pixie needs it); the module now imports it directly. +- `client/spritecodec.js`: an RFC 1950/1951 inflater (stored, fixed, and + dynamic blocks, decoded against the known output length) plus + `decodeSprite` and `readEncodedSprite`. About 250 lines, no dependencies. +- `client/global_client.html`, `client/player_client.html`: load + `spritecodec.js` next to `snappyjs.min.js` and parse `0x08` next to `0x01`. + Sprites keep `indices` and `palette` so a palette swap can find them. +- `client/global_client.nim`: the native and wasm renderer decodes 0x08 with + `decodeSprite` and keeps the index plane on `GlobalSprite`. +- `src/bitworld/client.nim`: `/spritecodec.js`, `/client/spritecodec.js`, and + `/clients/spritecodec.js` routes, embedded like the Snappy script. +- `docs/sprite_v1.md`: the message, encodings, error cases, and the 0x07 note. +- Tests: `tests/test_spriteencoding.nim` (round trips for every encoding, + chained swaps, swap fallback, sizes, malformed payloads), + `tests/test_spritecodec_js.nim` with `tests/spritecodec_check.js` (node + decodes 11 sprites the Nim encoder wrote, including a legacy 0x01 message, + and compares every byte; skipped when node is not installed), + `tests/test_client.nim` (routes and parser cases). + +## Server-side use + +```nim +packet.addEncodedSprite(id, w, h, pixels, label) # indexed or deflate +packet.addPaletteSwapSprite(tintId, id, w, h, pixels, tinted, label) +``` + +Heartleaf's branch `compressed-sprites` on `SolbiatiAlessandro/coworld-heartleaf` +switches its `addRgbaSprite` to `addEncodedSprite` and its map tints to +`addPaletteSwapSprite`, and adds a test that the encoded init packet decodes +pixel-identical to the legacy one. Dynamic sprites (name tags, speech bubbles, +cards) go through the same call and shrink too. + +## Open points for you + +1. Message value: 0x08 with 0x07 documented as taken by stag_hunt, or reclaim + 0x07 and move stag_hunt's identity packet. +2. Whether `SpriteEncodingRgbaSnappy` (0x00) is worth keeping in the new + message or whether 0x08 should carry only the new payloads. +3. The inflater is hand-written in `spritecodec.js`. `DecompressionStream` + exists in current browsers but is asynchronous, which would move sprite + definitions out of the synchronous parse loop; I kept it synchronous. +4. Whether to add a scale factor to sprites or objects, which is what the + forest underlay needs next. From aea8544158a4ddb5da9698fca8e6d59097f3eccd Mon Sep 17 00:00:00 2001 From: Alessandro Solbiati Date: Wed, 2 Sep 2026 17:45:37 -0700 Subject: [PATCH 3/3] docs: tint remap and constant-color floor numbers in the proposal Co-Authored-By: Claude Fable 5 --- docs/compressed_sprites_proposal.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/compressed_sprites_proposal.md b/docs/compressed_sprites_proposal.md index 2e2e723..93ab842 100644 --- a/docs/compressed_sprites_proposal.md +++ b/docs/compressed_sprites_proposal.md @@ -26,10 +26,18 @@ with `tools/init_packet_report.nim` in the heartleaf repo: | 8700 | chat banner | 318x60 | 29,353 | 76,320 | 1806 | | 31..35 | forest dusk veil 0..4 | 512x256 | 5 x 24,650 | 524,288 each | 1 | -Two things stand out. First, a 16-color map costs 685 KB because Snappy sees +Three things stand out. First, a 16-color map costs 685 KB because Snappy sees RGBA, not indices. Second, the game pre-composites five dusk tints of each map, and each tint is a full copy, although a tint of 16 colors is just 16 -different palette entries over the same index plane. +different palette entries over the same index plane. All twenty tinted copies +(main bottom, main overhang, home bottom, home overhang, five stages each) are +per-color remaps of their four base sprites, and they cannot be replaced by a +flat overlay: the early stages rotate hue (grass stays green while paths warm), +so only the darkest stage approximates a veil. Third, Snappy has a floor on +constant-color sprites: a solid 748x941 overlay costs 132,208 bytes, and each +512x256 dusk veil above costs 24,650, so shaped or solid overlay sprites are +not cheap under the current codec either. The same solid 748x941 sprite is 725 +bytes indexed and 2,772 bytes as deflated RGBA. ## The change @@ -112,6 +120,13 @@ Per sprite, the big ones: | 8700 | chat banner | rgba-deflate | 29,353 | 17,435 | | 31..35 | forest dusk veil 0..4 | indexed | 5 x 24,650 | 5 x 41 | +The twenty tints together went from about 4.5 MB to 1,660 bytes, with the +base sprites carrying the index planes once. The palette swap is exact: the +test suite decodes the encoded packet next to the legacy one and compares +every sprite byte for byte. Opening the director page in a browser against +this packet renders the map, forest, and clock through `spritecodec.js` with +no console errors. + The forest underlay is now 71% of the packet. It is generated at half resolution and upscaled 2x on the server, has 1629 colors, and does not index. Quantizing it to 4 bits per channel (123 colors) would bring it to 306 KB; that