Commit graph

21 commits

Author SHA1 Message Date
Tag
1360bffc82 Name the file HGTZ_TILE expects, in one sentence 2026-09-22 19:43:45 -06:00
Tag
d1caf353b3 Bring the decoder over from the project it grew in
Eighteen commits of history, grafted onto the scaffold rather than over
it. It leaves behind everything that only meant something inside that
project: paths under one machine's mount, references to a transcoder
that is not here, and a benchmark defaulting to a tile nobody else has.

One fixture is real terrain and stays, because it is the only small
input that exercises the direct-nibble Huffman weight form. Its licence
requires attribution to travel with it, so the README's
acknowledgements and the corpus manifest both carry the Copernicus
notice now. Everything else committed here is synthetic or was written
by the zstd CLI, including the .hgtz container, whose name looks like a
tile and whose samples are a function of their position.
2026-09-22 19:25:08 -06:00
Tag Spilman
1ba4baa9a5
Initial commit 2026-09-22 19:18:56 -06:00
Tag
45cab0620f Test the guards that had no test
A sweep found no defect in the decoder and several in its tests. Three
of the five fixes could be reverted with all 103 tests still green.

The content size against expectedSize check is the worst of those,
because the .hgtz reader was simplified to rest on it: deleting
it left everything passing. Eleven cases cover it now. The RLE block
ceiling and the requirement that a frame be the whole input had none
either, so there are fixtures for a 13-byte frame that returns 2 MB, for
the same shape at exactly the maximum, for four bytes of junk after a
frame, and for a trailing skippable frame.

Two of the new fixtures close coverage gaps rather than guards. No legal
frame in the suite decoded a one-stream Huffman literals section, since
the only one present had to be refused. And the only test of hashing
each block as it lands rode on the zstd CLI being installed, so a
multi-block frame carrying a checksum is committed now.

Also replaces an assertion that constrained nothing. "Fills the buffer
exactly" checked that the count was over 256 and under 1024, which
passes with the guard at two, at three, or absent. The stream encodes
257 symbols, a buffer of 257 takes it and one of 256 does not.

And two comments that were wrong. The weight buffer is one larger than
the maximum on purpose, and shrinking it to 255 breaks 124 tests. A
caller passing expectedSize gets the exact check in addition to the
ceiling, not instead of it.
2026-09-22 00:06:35 -06:00
Tag
b221ae0e98 Add the metadata pub.dev scores a package on
description, repository, issue_tracker and topics, plus a changelog,
which between them are most of a package's discoverability. The
repository URLs are a guess at the new home and want checking before the
first publish.

publish_to stays none so an accidental publish from inside this checkout
cannot succeed. Remove it in the new repository.

Version 0.1.0 rather than 0.0.1: a published version can be retracted
but never deleted, so the first one should mean something.
2026-09-21 23:45:05 -06:00
Tag
7bdad488ee Licence under MIT, and name what this was built from
MIT rather than a dual with BSD-3. Dual licensing solves a
compatibility problem this does not have, since both are permissive and
compatible with everything, so the choice would cost a downstream reader
a decision and buy them nothing.

The debt to zstd is real and a licence is the wrong instrument for it.
No code came from libzstd; the format came from RFC 8878 and every
answer is checked against libzstd through pyzstd. That belongs in prose
where people read it.

Also drops the line calling this a feasibility spike. It has 103 tests,
an audit against the RFC, and five defects found and closed.
2026-09-21 23:41:31 -06:00
Tag
ca57654bab Compress odd shapes at test time rather than commit them
Three of the shapes worth covering produce frames of a few hundred kilobytes,
which is too much to commit for what they prove. The input is built from a
seeded generator and handed to the zstd CLI instead, so the frame is libzstd's
own output and the expectation is the exact bytes that went in.

The pool size decides which sequence tables libzstd picks. Two hundred tokens
gets RLE and repeat tables for the literal lengths, two thousand gets an RLE
table for the match lengths, and both put more than 32,512 sequences in one
block, which is the only thing that writes the three-byte sequence count.
2026-09-21 23:20:56 -06:00
Tag
d1159ca695 Check fseDecodeInterleaved has room for the third symbol
It has no symbol count to stop on and runs until the bitstream is spent, so
its own room check is the only thing keeping it inside the caller's buffer.
Both exhausted branches write a third symbol in the same pass. This is the
53-byte reproducer with the frame stripped away, so a regression points at
the function rather than at a block somewhere.
2026-09-21 23:20:55 -06:00
Tag
c4bc910c05 Run one decoder over mixed frames in both orders
A ZstdDecoder keeps its Huffman table, its three FSE tables, the repeat flags
and the three recent offsets between calls, and every one of those belongs to
a single frame. A leak is invisible while every frame in the list looks alike,
which is what the .hgtz corpus is. Reversing the order gives each frame a
different predecessor, so a leak shows up as a difference between the passes.
2026-09-21 23:20:53 -06:00
Tag
b9553d9566 Decode the corpus against pyzstd, never against ourselves
A test that asserts what the decoder currently does proves nothing. Every
legal frame here is compared byte for byte with what pyzstd decompressed it
to, and every malformed frame is one pyzstd also refuses.

The refusal has to be a ZstdException. RangeError, OutOfMemoryError and
IndexError are all Error rather than Exception, so a caller writing
"on ZstdException" does not catch them and the isolate dies.
2026-09-21 23:20:51 -06:00
Tag
3c9f035b7f Build a zstd corpus for the branches real tiles miss
The only fixture was one .hgtz container written by one producer at level 19.
An audit ran 16,490 real blocks through an instrumented decoder and found
thirteen branches of RFC 8878 that corpus never reaches at all: raw blocks,
RLE blocks, RLE literals, treeless literals at the three-byte size format,
frames with no content size, frames with a checksum, the one and eight byte
content sizes, the three-byte sequence count, a block with no sequences, and
RLE sequence tables for literal and match length.

make_corpus.py builds frames for them, either through libzstd or by hand and
then past pyzstd for a verdict, so legal and malformed are libzstd's opinion
rather than ours. The expected plain bytes of a legal frame are whatever
pyzstd decompressed it to, gzipped to keep the fixture small, and the
manifest records the exact command behind every frame. 46 KB in all.
2026-09-21 23:20:38 -06:00
Tag
724e9b0433 Close the last of the five defects 2026-09-21 23:12:46 -06:00
Tag
bd4ccf19b5 Verify the frame checksum instead of stepping over it
Skipping it left the decoder with no integrity check at all. Over
600,000 mutations of real block data with the frame header held valid, a
third decoded into wrong bytes and raised nothing, because a damaged
match or literal length usually still produces plausible output of the
right length. Every other guard in here catches a frame that cannot be
parsed; this is the only one that catches a frame that parses and is
wrong.

Hashed only when the frame carries a checksum. The .hgtz reader would
pay for it on every block and has no use for it: pyzstd writes none, and
the container's own SHA-256 is verified before the download is kept.
2026-09-21 23:12:23 -06:00
Tag
6b48be1bfd Add XXH64 so the content checksum can be verified
The decoder parses Content_Checksum and steps over it without reading it.
An audit mutated real block data 600,000 times behind a valid frame header
and 34 percent decoded into wrong bytes with no error raised. A frame can
also set the checksum flag and supply no bytes at all. This is the only
mechanism that would catch a wrong decode from anything else, and the
package decodes elevation tiles on a phone.

RFC 8878 section 3.1.1 stores the low 32 bits of the XXH64 of the whole
content, seed zero, little-endian. Xxh64.digest32 is that value. The
incremental form is for the decoder, which writes the output block by
block and should not walk it a second time.

Every operation relies on int being a wrapping 64-bit two's complement
integer, which holds on the VM and on AOT and not under dart2js. The
package already targets native only.

Verified against the xxhash Python package, version 3.0.0, over both byte
patterns at every length class around the 32, 8, 4 and 1 byte boundaries,
over 400 random inputs one shot and in random chunkings, and end to end
against the trailing four bytes of a real zstd -19 --check frame.
2026-09-21 23:10:44 -06:00
Tag
cc5d8b8914 Mark four of the five defects closed
Only the unverified checksum is left, and it is narrower here than it
looks: the downloader verifies a SHA-256 over the whole container before
it renames the .part, so a tile damaged in transit is already caught.
What remains is corruption at rest on the phone.
2026-09-21 23:09:56 -06:00
Tag
09b06ca9a0 Refuse a frame this decoder cannot account for
Three ways a header was taken on trust.

Frame_Content_Size is up to a 64-bit number straight from the input and
it sized the output buffer unchecked, so a sixteen-byte frame could ask
for four exbibytes. It is reachable by accident: one bit flipped in a
real tile's frame descriptor moves the content-size flag from 2 to 3,
eight bytes of compressed data are read as the size, and the eighth
lands on the sign bit. That is a RangeError or an OutOfMemoryError, both
Errors rather than Exceptions, so a caller writing `on ZstdException`
catches neither and the isolate dies. There is a ceiling now, and a
caller that passes expectedSize gets an exact check instead, which is
what the .hgtz reader wants since a block is height * width * 2.

Reading one frame is the whole API, and nothing compared the cursor with
the input length, so a second frame, a trailing skippable frame and four
bytes of junk were accepted and silently ignored. Two frames
concatenated returned the first one's bytes and raised nothing. The
checksum's four bytes were stepped over rather than read, so a frame
could claim one and supply none.

And an RLE block was exempt from the block size ceiling, which let 97
bytes return 44 MB that a caller cannot tell from a good tile.
2026-09-21 23:09:28 -06:00
Tag
489c898b14 Guard the FSE pass for the three symbols it writes
Both exhausted branches write a third symbol in the same pass that the
guard admitted for two. The Huffman weight decoder calls this with a
256-byte buffer and a limit of 256, so `at` reached 256 exactly, which
is one past the end, and a crafted weight stream turned that into an
IndexError rather than a refusal. A 53-byte input reproduces it, and
random mutation of a real tile found it independently.
2026-09-21 23:09:25 -06:00
Tag
019423f08f Record what an audit found, including a wrong claim
The table said several frames in one buffer are refused. They are not:
the block loop breaks on the last block and never checks the cursor
against the input length, so a second frame, a trailing skippable frame
and appended junk are all accepted and silently ignored.

Four more, each with a reproducer and none fixed yet. The content size
sizes an allocation with no ceiling, and one flipped bit in a real
tile's descriptor kills the isolate with an error no caller can catch.
The interleaved FSE decode writes three symbols in a pass that checks
room for two. RLE blocks skip the block size ceiling. And the checksum
is never verified, which is the one that makes the others systemic:
34% of 600,000 mutations decoded into wrong bytes in silence.
2026-09-21 23:01:56 -06:00
Tag
fdab6db634 Time the decoder across compression levels, interleaved
Sequential runs of same_blocks.dart disagreed by 70% between the first
and third invocation on this box: schedutil plus boost ramp is a larger
effect than any difference between levels. Interleaving one block from
each file in turn puts that drift on every level equally.
2026-09-21 22:11:05 -06:00
Tag
0cbe208eb0 Measure the decoder on the phone, which is the number that decides it
0.414 ms a block in profile AOT on the Pixel, so a 113-block sweep is 47 ms.
The phone is about twice as quick at this as the laptop the first figures
came from, which read 0.917 ms and 104 ms for the same sixteen blocks.

Debug on device is 0.540 ms, so the shipped configuration is the fast one
rather than the slow one.

The blocks are real, out of N39W105 in srtm1z. The committed .hgtz fixture
is no use for timing: its samples are a synthetic pattern that compresses
to almost no sequences, and decode cost tracks sequence count, so it reads
about six times too fast. It was measured that way once before this was
noticed.
2026-09-21 21:53:21 -06:00
Tag
d970a0cca8 Add a pure-Dart zstd decoder, as a benchmark
A feasibility spike, standalone so it can move to its own repository. Not
wired into the app and not a dependency of it.

Decompression only, and only what our containers need: no dictionaries, no
multi-frame, no streaming, and the content checksum is parsed but never
verified. Native only, since the bit reader carries 56 bits in an int.

It is correct on real data. The committed fixture decodes to the samples
the generator wrote, and tool/oracle.py drives a byte-for-byte comparison
against pyzstd over 32 containers and 2,615 blocks from both tiers, every
thinned column count from 3601 to 361, and 88 S to 80 N. All identical.

The numbers, AOT on this box, 131,072 bytes a block: 0.66 ms median
against pyzstd's 0.175, so about 200 MB/s against 750. Across three tiles
the range is 0.66 to 0.94 ms, and a 113 block sweep is 67 to 105 ms. The
cost is the sequence loop and nothing else: a terrain block at level 19
carries around 1,700 literals and 14,400 sequences, so optimising the
literal path bought nothing.
2026-09-21 21:37:56 -06:00