fourdgs 0.1.0
fourdgs: ^0.1.0 copied to clipboard
A decoder for the 4dgs container format: 4D gaussian splat scenes with native audio in a single seekable file. Streamed and indexed read paths, pure Dart.
Changelog #
All notable changes to the Dart package are documented here, following Keep a Changelog and Semantic Versioning.
0.1.0 #
Prepared, not published. The heading carries the version rather than Unreleased because
pubspec.yaml already declares 0.1.0 and pub refuses to pack a package whose changelog does not
mention its own version — and because the release gate extracts this section as the release notes,
so it has to exist before the tag rather than after it. There is no date: the release has not
happened.
The writer #
writeFourdgsToSinkencodes aFourdgsGaussianSetone complete framed record at a time without retaining the output file;writeFourdgsBytesis the explicit in-memory convenience over that sink API. Both write the magic at both ends, Header, Quantization, Window Table, Chunks and their attribute streams, spherical-harmonic band records, and Footer. A second encoder rather than a binding — it shares no code with the other five SDKs and lands on the same integer bins, so a decoder cannot tell its output from theirs once decoded.- Chunked encode. The timeline is partitioned into an interval tree whose top level is the window
table rather than an even split — gaussians fitted over one window straddle the boundaries of an
even split, so all of them would be pushed to the root and the tree would have one node in it. A
gaussian goes in the deepest node whose interval fully contains its support, so it is stored
exactly once however long it lives, and a reader that wants one instant fetches the nodes covering
it instead of the scene.
maxDepthandminChunkGaussianscontrol the shape; a node too small to be worth its own chunk hands its gaussians back to its parent. A node whose midpoint has run out of doubles — an interval can exhaustdoublebefore it exhausts the depth limit — is a leaf, because the alternative is a nonempty chunk over a zero-width interval that no seek can ever select. And a partition finer than the262144entries an indexed reader will open is refused by name rather than written: the top level is the window table, so a scene giving every gaussian its own validity window sets the floor on the entry count, and the resulting file would be one only the streamed path could read. - The Dart indexed conformance runner now makes a selective seek on every variant it decodes,
not only a whole-scene assembly. For probe instants away from a chunk boundary it reads just the
entries whose interval covers the instant and requires the state they reconstruct to equal the
state the whole scene gives. This is the claim the canonical summary cannot make: a gaussian filed
in the wrong chunk still appears in a whole-scene summary, at the same values, with both read
paths agreeing — and is simply missing from a seek. The guard around a boundary is derived from
the file's own declared pitches, and the number of probes that ran is asserted, so a guard that
swallowed every instant is a failure rather than a green check that never executed. Planning costs
O(n log n)in the gaussian count and notO(n²): a scene that gives every gaussian its own validity window has as many top-level intervals as gaussians, and each gaussian finds its interval by binary search over the split points rather than by every interval being offered every gaussian. - Summary writing, in the shape spec §4.5 requires: the Chunk Index, then Statistics, then the
Summary Offset, one contiguous run immediately before the Footer with nothing else inside it. That
contiguity is what lets a streamed reader verify
summary_crcby retaining the trailing records rather than the whole file, so it is asserted on the written bytes rather than assumed. Every offset and length in the index frames a whole record, opcode byte included (§5.8). - Per-band spherical-harmonic bit depths, spec §6.5.
shBitDepthstakes a list, band 1 first; named lists are available asfourdgsShLadders['balanced']!(andflatoraggressive), the ladderswebsite/docs/reference/compression.mdmeasures.nbits rounds a coefficient byte onto a grid of2^(8 - n)code units at bin centres, so nothing sub-byte is packed, no decoder changes, and the saving is realized by the stream codec having that many fewer symbols to code. The depths are appended to the Quantization record, each band's bound is declared assh_band<n>, and the singlestep_shandshthe record has always carried take the coarsest band's value — an upper bound for a consumer that reads them and not the appended field, rather than a number that is true of one band and wrong for the others. A Quantization record that declares no depths appends no bytes at all and preserves coefficient bytes exactly; profile-widestep_shremains compatibility metadata, not an implicit grid. A ladder shorter than the scene's degree is refused rather than filled in with eight bits. - The encoder verifies each band's declared bound by decoding the record it just wrote and measuring every coefficient of every gaussian, rather than trusting the three lines of arithmetic that produced it. A bound nobody verified is worse than no bound, because consumers will trust it.
FourdgsWriteOptionscarries the quantization profile, the Header'scutoff, the deflate level, the chunking, the SH bit depths, which summary records to write, and the Header'sprofile,libraryand attributes. The defaults are the reference encoders'.- Refusals name the field and the gaussian, not just the failure: a non-finite value in a lane that
lands on a grid, a NaN validity window, a validity window whose lower bound is above its upper, a
NaN or
-infsigma, a bin outside the signed 32-bit symbols a stream carries, an unknown profile, a cutoff outside(0, 1].+infis a value in three places and is written as one — a never-fadingsigma_t, and awin_lo/win_hithat says a gaussian is present at every instant. An inverted window is refused rather than written because visibility is gated onlo <= t < hi: it would cover no instant, and this package's own reader refuses the record carrying it, so writing one produces a file neither read path can reopen. - Every option has a range and is checked against it before anything acts on it:
levelis-1or0–9,maxDepthis0–32,minChunkGaussiansis at least1,shBandsis not negative. Each is aFourdgsInvalidInputnaming the option, the value and the range, because the alternative is what these did before — an out-of-range deflate level surfacing as aRangeErrorfrom inside a compression package, and an unboundedmaxDepthsurfacing as aStackOverflowErrorfrom inside the chunk planner, where a gaussian that lives for a single instant never straddles a midpoint and so never stops descending. Neither names the option or the caller who set it, and neither is catchable as one of this library's own exceptions. - Three lanes that a domain repair would otherwise change silently are refused instead: an rgb or
- Three lanes that a domain repair would otherwise change silently are refused instead: an rgb or
opacity component outside
[0, 1], whichdecodeChunkclamps back into the range, so a channel of 1.2 returns as 1.0 in a file declaring anrgbbound near 0.004; a scale at or below zero, whose logarithm is undefined; and a finite negativesigma_t, which would be floored to a positive lifetime. Every strictly positive Float32 scale, including values below1e-30, is quantized from its actual logarithm and stays inside the declared relative bound. Asigma_tof exactly zero stays legal: it is a gaussian whose support is a single instant. - Finite authoring input is also checked in the representation the decoder returns. A position or
velocity whose legal integer bin reconstructs beyond finite
float32is refused rather than emitted as infinity. Equal infinite window endpoints are temporarily refused by the writer: the wire value is a legal empty span, but version-1 SDKs do not yet share one motion-grid interpretation forInfinity - Infinity. - A spherical-harmonic row must be whole degrees — 3, 8 or 15 coefficients per colour component — and its buffer must be the size that row implies. Bands are whole and a reader takes them whole (§6.5), so a row of four coefficients cannot be written as a degree-2 band, and building one out of the columns that happen to be there produced a band record declaring three channels where the band defines fifteen: a file neither of this package's read paths could reopen.
- The optional identity streams
source_group,source_indexandobject_idare decoded and written when the set carries them, rather than dropped. §6.6: "The Object Table, Object Tracks andobject_idstream are independently optional … None is a reason to invent or discard another."object_idcrosses to the stream's signed symbols by the same-bits two's-complement view §6.6 defines, so the whole unsigned 32-bit domain survives; delta coding stays available because a candidate that will not fit a 32-bit symbol is dropped rather than truncated. - The Summary Offset's declared range ends where the Chunk Index ends. Measured after Statistics had
been appended, it advertised
chunk-indexover a run whose tail was a Statistics record — which defeats the one thing the record exists for, letting a consumer range-read a single class of summary record. - The scene profile
objectsis refused. A profile is a promise about what the file contains, and that one promises anobject_idstream in every non-empty chunk and an Object Table (registry, Profiles); this writer emits neither, so accepting it would put a claim in the Header that the bytes below it do not keep. - The Header declares the spherical-harmonic degree the file actually carries, which is the highest
band written rather than the degree the input held:
shBandscaps what is emitted, and bands are whole (§6.5), so a degree-3 scene written withshBands: 1is a degree-1 file. - Deterministic: two encodes of one scene are byte-identical, including the Header's attribute map, whose keys are sorted rather than emitted in whatever order the caller's map iterates.
- Proved by
dart/encode-roundtrip.sh, which re-encodes all 46 corpus variants — into as many as 42 chunks each — and makes two separate claims about each result. Fidelity: the written scene is compared against the scene it was written from, by the Python reference reader, attribute by attribute, against the error bounds the written file itself declares — including the per-gaussian velocity and birth-time pitches, derived the way a decoder derives them, and the presence, degree and shape of the spherical harmonics, which are checked before any coefficient is compared because an encoder that emitted none at all would otherwise skip the comparison entirely. The object-bearing case also pairs and compares both exact source identity lanes. Agreement: the Dart, Python and Rust decoders must produce identical canonical JSON from the result, on both read paths each — six readers, three implementations. Neither claim implies the other. Decoders reading one file the same way say nothing about whether that file is the scene that went in, and an encoder checked only by its own decoder proves that two halves of one implementation share an opinion. Every reader is required: a missing one is an error rather than a reader quietly dropped from the comparison.tests/conformance/encode_roundtrip.py --encoder dartalso puts the same gaussians through this encoder and through the shared Rust reference and requires the Python decoder to read both files as the same scene, including a pass at per-band depths. Before ignoring layout-dependent interval choices, that gate also checks that every emitted Chunk is indexed exactly once, the population agrees, and its reconstructed support fits the indexed interval. Both run in the conformance workflow. - The tests pin every named ladder, the pitch and bound each bit depth declares, and the maximum coefficient deviation after this encoder writes and decodes every band.
The keyframe-delta writer #
writeKeyframeDeltaBytesencodes a sequence ofFourdgsSamples — a population, with identities, at a sequence of instants — into a completekeyframe-deltafile: a keyframe Chunk or a Delta Chunk per sample, the extended Chunk Index, Statistics and a Footer. Dart could play temporal scenes back and could not produce them; it can now.writeKeyframeDeltaToSinkemits the same framed records without retaining the completed file in memory.- Every sample is quantized when it is emitted, on grids derived from the whole sequence, so a delta is an integer subtraction between two bins on the same grid. The composition telescopes and the bin at any depth is the bin an absolute statement of that instant would have carried, which is what makes the declared bounds hold after the second delta rather than only after the first.
FourdgsKeyframeDeltaOptionscarries the cadence (keyframeEvery,keyframeAt), the reference mode (deltaMode: chained, or the group's keyframe), the quantization profile, the cutoff, the deflate level, which summary records to write, and whether to verify. The defaults are the reference encoders'.- The counting rules the format actually states, rather than the ones that read naturally. The
Header's
gaussian_countis the number of distinct ids, not a sum over chunks — under this model the chunks restate the same gaussians. A delta index entry'sgaussian_countcounts operations — updates plus births plus deaths — and itslive_countis the population those compose to.live_countis stated on keyframe entries too, because §5.8 defines it for every extended entry and this package's indexed reader cross-checks it: a writer that left it zero would produce files this SDK refuses. - A sequence that does not tile
[0, duration_sec)is refused rather than written: instants that start after zero or go backwards, and a zero-width interval with a population behind it — one the half-open seek rule could never select. So is a change tosigma_t,flagsorwindow_indexbetween two samples of one group, which would subtract bins living on two different grids; the refusal names the gaussian and says a keyframe or a death and a birth is the fix. - Rotation is restated outright in an update rather than differenced: the smallest-three basis changes whenever the largest quaternion component does, so the three stored bins mean different components either side of it. Births state all eleven attributes absolutely; deaths are identity and nothing else; an empty group is zero bytes.
- Every keyframe, update and birth states its position at that sample's
t0. A moving row whose raw position and motion bins repeat is therefore still updated when inheriting the older anchor would advect it away from the new sample. Strictly positive Float32 scales are quantized from their actual logarithm, including values below1e-30; equal infinite window endpoints are refused because their NaN length gives version-1 decoders different motion grids. - The encoder decodes the file it just wrote, on both read paths, and checks every lane of every
sample against the bounds that file declares — position against
pos, scale andsigma_tin the log domain, velocity and birth time against the per-gaussian pitches a decoder recomputes, colour and opacity againstrgbandalpha, the quaternion against the dot product a per-component bound implies, and the validity window for equality. Two encoders agreeing proves they share an opinion; this is what proves the scene survived. keyframeDeltaPopulationis the decode-side accessor that check needs and a consumer wanted: the composed bins of a chunk as rest-state gaussian values, indouble, without the reconstruction step. It is what an exporter reads when it needs the state rather than a summary.- The index's own numbers are checked against the chunks they describe, with the Python SDK rather
than with Dart's reader. Issue #195: nothing in the canonical summary comes off the index —
liveCountthere is read from the composed state — so an entry can carry the wronggaussian_countand every reader in the project reconstructs the scene correctly while the file lies about the seek cost. Verified by injection: corrupting a delta entry'sgaussian_countalone passes all six readers and is caught only here; swapping it withlive_countis caught earlier still, by Dart's own indexed reader. - Proved against the corpus's own expectations.
dart/encode-roundtrip.shrebuilds the four corpus sequences, writes them with this encoder, and requires the states JSON to equal the expectation a Python-written file of the same sequence produced — read back by Dart, Python and Rust, on both paths in each. Three more sequences the corpus does not carry go through the same gate: two validity windows, a never-fading population, and one with real velocity, birth times and orientations.
Hostile-input hardening #
The decoder refuses a class of file it previously accepted — values that decode into plausible-looking output rather than an error, so nothing downstream notices:
- Header. A spherical-harmonic degree outside the 0-3 registry, a temporal model this build does
not implement (
gaussian-birthandkeyframe-deltaare accepted; anything else is named rather than assumed), a NaN or negative duration, and a cutoff outside(0, 1]. Zero duration stays legal — a zero-duration scene is a real fixture — and so does+Infinity, which is how an open-ended scene declares itself. - Chunk and Delta Chunk intervals. The same rule the Chunk Index gets, applied where a chunk states its own interval. Without it the streamed keyframe-delta path, which never reads the index, accepted a file the indexed path refused.
- Validity windows and chunk-index intervals. NaN or inverted bounds on both. Visibility is
gated on
lo <= t < hi, so a NaN bound is false at every instant and the content behind it silently never appears.lo == histays legal. - Chunk index. A nonempty chunk over a zero-width interval. The seek rule is half-open, so
nothing can ever select it, yet its gaussians still count toward the file's total. For a
keyframe-deltaentry the population isliveCountrather than the operation count, so that rule is applied by the readers, which know the temporal model — the record parser sees the appended block by length alone and cannot tell a delta entry from fields a later revision adds. All three read paths apply it, including the dedicated keyframe-delta opener. The record parser applies it only to an entry with no appended block, wheregaussianCountis unambiguously a population: a delta that only removes gaussians declares its removals there and aliveCountof zero, and is empty despite the count. - Keyframe-delta index. A
chunk_kindother than 0 or 1 — not a forward-compatible extension but a chunk that cannot be placed in a chain, and one the population rule and the composer read differently. And a chain whose composed population disagrees with thelive_countthe index declares: §5.8 states that duplication is there to be checked, and checking it is what stops an entry declaring nothing from summarising a payload that decodes to something. - Keyframe-delta index, continued. A keyframe's
live_countis checked against the population its chunk composes to, not only the count the population rule happens to select — §5.8 defines the field for every extended entry and the reference writers set it on keyframes too. - Streamed reader. The chunk-index clock bound the indexed reader already applied, so a container is not accepted or refused according to which reader opened it; and a cross-check that the chunks assemble to the total the header declares, for complete files only — a truncated file is expected to hold fewer.
- Truncation recovery is unchanged, deliberately. A record length running past the buffer and a
download cut short raise the same error, and they cannot be told apart: in both cases the walk
never reaches the tail, so nothing at the tail is evidence.
recoverTruncatedtherefore still returns the decoded prefix withtruncated == truefor both. What a complete file cannot do is disagree with itself — once the walk reaches the Footer, the totals above are checked.
Resource ceilings #
The hardening above is about what is legal — which files conform. This is about what is affordable. A malformed length field could still size an allocation before anything noticed it was malformed, which is the difference between refusing a bad file and being taken down by one. Every ceiling here is shared by scope and value with the other SDKs, because a ceiling only one implementation has means a file that decodes in three of them and is refused in the fourth:
- Bounded Camera framing without a Dart-only count ceiling. Before building any keyframe list,
the parser proves that all declared 56-byte samples fit in the already bounded record. Camera has
no cross-SDK
MAX_TRAJECTORY_SAMPLESrule, so valid large Camera records remain accepted while a truncated declaration cannot allocate every available row before failing. - Keyframe-delta groups are framed before decoding. Every declared stream payload is bounds-checked before the first decoded array is allocated. Decoded size remains subject to the shared per-stream ceiling; no Dart-only aggregate chunk limit changes which conforming files are accepted. A repeated stream whose payload is incomplete is consequently diagnosed as truncated before the duplicate-attribute rule is considered.
- Quantization scheme.
uniform-v1is what this build implements, and a record naming anything else is refused as an unsupported codec rather than decoded through a grid it was not given — the steps are the only description of what a bin means, so readinguniform-v9bins throughuniform-v1arithmetic produces a scene that is wrong everywhere and complains nowhere. - Quantization parameter magnitude. Every step and
pos_origincomponent must be finite (spec §5.3), and the refusal names the field. This decoder acts on the rule rather than reporting it for a reason specific to Dart: the per-gaussian pitches are derived withlog2and rounded withfloor, anddouble.floor()on a NaN or an infinity throwsUnsupportedError, which names no byte, no record and no field. - A default cutoff, from one constant.
fourdgsDefaultCutoff(0.05,DEFAULT_CUTOFFelsewhere) now supplies every default in the package rather than a repeated literal, andstateAtandsupportrefuse a threshold outside(0, 1]instead of turning it into an infinite support radius or a comparison that keeps the whole scene.supportderives its half-width throughsupportK, so there is one implementation of the rule.
Each ceiling names the byte, the record, the value and the expectation, so
FourdgsQuantization.parse and FourdgsCamera.parse take the fileOffset their record begins at,
as FourdgsChunkIndexEntry already did, and the chunk parsers report where their stream blocks
start.
Inspect and validate #
- A
fourdgscommand.dart pub global activate fourdgsputsfourdgs inspectandfourdgs validateon the PATH.inspectwalks the records — opcode by name, byte offset, content and total length, and whether the Footer's summary checksum covers that record — reading nine bytes per record and never a record's content, so it costs the same on a file carrying an hour of audio as on one carrying none.validatechecks a file against the specification and, for the rules whose whole content is a refusal, names which rule and which byte. The identifiers and the offsets are the ones the Python, Rust, TypeScript and C++ tools print for the same files; the sentences around them are this decoder's own. The command lives inbin/and reaches forpackage:fourdgs/io.dart, so the decoder itself stays free ofdart:io. - Exit codes a pipeline can act on:
0fine,1refused or invalid,2valid with warnings,3the tool itself could not run. The last one is the point of the set — a tool that exits1both for "I read the file and it is not conforming" and for "I fell over" is indistinguishable from a broken tool, and nothing downstream can tell those apart after the fact. - The validator decodes the chunks, one at a time. A framing walk steps over a chunk by its declared length, which is exactly not looking inside it, so an unimplemented stream codec and an out-of-range window index are invisible to it — and both are in the invalid corpus. Each chunk is fetched by byte range from the index, decoded, and dropped before the next one, so a file larger than memory is still validatable (AGENTS.md §1). A file with no index is walked frame by frame and its chunks decoded the same way, rather than handed to the front-to-back reader, which would keep every one of them.
- And their spherical-harmonic bands. Each declared band record is decoded on its own, so a band
that will not decode is named at its byte rather than at the chunk's. A validator that never
opened a whole record class would call a file with a corrupt band
valid. keyframe-deltais validated against its own model. The Header's declared model selects the reader, and each chain is composed and dropped in turn —composeKeyframeDeltaChainis now public for exactly that, sincedecodeKeyframeDeltaIndexedanswers "what does this file decode to" and therefore holds every composed state at once. Without this a conforming keyframe-delta file was reported as broken for declaring a model this package implements.- A truncated file reports what survived. Records are length-prefixed, so everything complete before the cut is intact and the streamed reader keeps it. Both commands say which byte the file was cut at and how many whole records precede it, rather than printing one line and throwing the rest away.
inspectFourdgs,validateFourdgs,walkFourdgsFraminganddescribeFourdgsRefusalare exported from the library and take aFourdgsReadable, so everything the command does is something a caller can do — which is what keeps a command-line tool from becoming a second, undocumented implementation of the format.- Nothing in the validator holds the file. The Python, Rust, TypeScript and C++ validators all
take the whole thing as a byte array; this one is the only one whose entry point is a
FourdgsReadable, and a validator that reads a file to check a file is the API AGENTS.md §1 calls wrong. So the framing walk supplies the record table, each record's content is fetched by its own range when a check needs it — twenty-four bytes of a Chunk for the count it declares, twelve of an Audio Data record for its id and length — and the summary checksum runs a block at a time throughfourdgsCrc32Range. What a multi-gigabyte scene costs to validate is its largest single chunk.inspectgets the same treatment:summary_startis an untrusted field on a file the command is pointed at because it is suspect, and a Footer naming the whole file as its summary region used to be one allocation. - A file whose Footer will not parse still gets its record table.
inspectreported nothing at all — no table, no offsets — for exactly the malformed record its holder ran it for. The framing walk is kept and the checksum line says the Footer does not parse. inspectexits 1 for a file with no trailing magic, not only for one the walk stopped inside. A file cut exactly at a record boundary leaves the walk nothing to stop on, so a script reading the exit code alone was told a truncated file inspected cleanly — whilevalidatecalled the same file an error.- The object layer and the provenance records are parsed, not merely framed. Opening a scene
frames them and stops, which is what makes a thousand-sample rig trajectory cost nothing to open;
a validator is the caller that asks for them. Without asking, a file carrying a truncated
ObjectTrackor two Object Tables was reported valid by the tool and refused byreadFourdgsObjects— the tool and the API disagreeing about the same bytes. - A
keyframe-deltafile is opened the way a seeking reader would, like every other file. That branch composed its chains straight from the framing, so a delta file with a record after its Footer, an unknownchunk_kindor a nonempty zero-width entry passed validation while every reader in this package refused it. Its composed states are now checked against thelive_count§5.8 declares, its declared spherical-harmonic bands are decoded, and every composedwindow_indexis checked against the Window Table — composition is arithmetic on bins and never looks a window up, so a file naming a window it does not carry composed without complaint and was refused the moment anything reconstructed it. - A chunk index entry pointing at the end of the file is a finding rather than a crash.
chunk_offset == sizewithchunk_length == 0satisfies "does the range fit" and points at no byte at all; reading the opcode there threw an uncaught range error, so the tool fell over on exactly the input it exists for. - Walking every chain in an index is linear in the index again. Each walk rebuilt the map from
chunk offset to entry, so composing a ten-thousand-entry index cost hundreds of millions of map
insertions before a chunk was read. The lookup is built once and passed
(
keyframeDeltaChainIndex), by the validator and bydecodeKeyframeDeltaIndexedalike.
Refusal diagnosis #
- Refusals say which rule was broken. Every exception in this package now carries an optional
refusalCode, and the six identifiers the specification's refusal table names —magic-mismatch,unsupported-major-version,unknown-temporal-model,unknown-quantization-scheme,unknown-stream-codec,window-index-out-of-range— are exported as named constants rather than written as literals at the raise sites, because six implementations are compared on those strings and a typo in one reads in CI like a decoder bug. The exception class alone was too coarse to compare on:FourdgsUnsupportedCodeccovers an unknown temporal model, an unknown quantization scheme and an unknown stream codec alike, so "it threwFourdgsUnsupportedCodec" cannot tell a decoder that refused for the right reason from one that refused for the wrong one.nullmeans "a real error the refusal table does not name", not "no error" — which is whyFourdgsTruncatedFile, the one refusal that is recoverable rather than refusable, offers nowhere to put an identifier. This is additive:refusalCodeis a property on the existingFourdgsExceptionrather than a new class, so everycatchand everyischeck keeps working, andFourdgsExceptionstill extendsFormatException.
Fixed #
-
A string on the wire keeps its leading U+FEFF.
Cursor.stringreturnedutf8.decode's result, and Dart's UTF-8 decoder skips a leading byte-order mark. That is right for reading a text file, where the mark is a preamble; every string here is length-prefixed UTF-8 inside a binary record, where U+FEFF is a character the writer wrote. So this reader returned a different string from the Python and Rust readers for the same bytes — silently changing aboundsvalue, a metadata key or anobject_id. The mark is now put back after decoding. -
A per-band SH bound is read by the grammar spec §5.3 writes down. The comparison trimmed a hand-written whitespace set copied from Python's, folded every Unicode
Nddigit to ASCII through a 68-entry table, removed underscores, and refused exponents outside CPython's build-dependentDecimalrange — all to mirror one interpreter's string syntax rather than the format's. §5.3 now states the grammar: an optional sign, ASCII digits, an optional point and an optional exponent, with nothing around it.1_6,١٦and a space-padded bound are now reported as bounds that disagree with the record;0e999999999999999999999999is accepted as the zero it spells. Equivalent spellings such as5e-05,5e-5and0.00005remain one bound. -
A file whose magic is corrupted anywhere but the version byte is no longer reported as an unsupported version.
checkMagictested only that bytes 1-4 read4DGS, so flipping the leading0x89sentinel — the byte that stops byte-oriented tooling treating a 4dgs file as text — produced "4dgs major version 1 is not supported by this reader". That sends the file's holder looking for a newer reader, which would not have helped. The version byte must now be the only difference, which also catches the mangledCR LFthe sentinel exists alongside. Nothing inside Dart could see this, because both answers are aFourdgsUnsupportedVersioncarrying a plausible sentence; it took giving the two answers different names. -
A window index outside the Window Table is refused rather than clamped. The
gaussian-birthchunk decoder clamped an out-of-range index to the nearest window, which substitutes one gaussian's lifetime for another's in a file that is already wrong in some way nobody has diagnosed — the scene renders and the fault is gone. Python and Rust have always refused it, and this package's ownkeyframe-deltapath already did, so one file decoded two ways depending on its temporal model. An absent or empty Window Table is still one default(0, 0)window (spec §5.4), so index 0 resolves and everything past it does not. The refusal names the offending record and not only the offending value: the identifier says which rule broke, but a file has many chunks and all of them decode through one function, so "window index 7 is outside the 1-entry window table" left its holder a whole file to search. It is built in one place now — three sites reach it, the chunk decoder and both keyframe-delta grid lookups — and readsgaussian 5 of the chunk at byte 4096 names window index 7, …on the chunk path andgaussian 77 names …on the keyframe-delta path, where the stable id is what the file carries and the row is an artefact of composition order. -
Every refusal a chunk raises says which chunk.
decodeChunkStreamstakes the chunk record's file offset and names it in all of them — the duplicate attribute stream, the element and channel count mismatches, the missing required attributes, the decoded-size ceiling, the chunk-level codec and the two per-gaussian refusals. None of them could be placed in a multi-chunk file, and fixing one of them would have left the rest to be found the same way a second time. -
The keyframe-delta window refusal names the gaussian even when its id is in the top half of the
u32range.gaussian_idis au32(spec §11.2) and bins decode as signed 32-bit in every SDK, so0xFFFFFFFFarrives as-1— and "no gaussian supplied" was spelled-1too. The one gaussian whose id was the largest the format allows was therefore the one gaussian whose refusal silently lost its location. The absent case is anullnow, which no id can collide with. The id is still printed as the signed value the decoder holds, because that is the value this package'sstatesJSON carries and the value Python and Rust print for the same file; a message naming an id that appears nowhere else would be a worse diagnosis than a negative one. -
A truncated Header is not an unsupported one. A Header that ended after its
temporal_modelstring was refused for naming a model this build does not implement, when what it actually is, is incomplete — sending whoever holds it to add codec support for a file that needs none. Every mandatory field is read before the model is classified. -
An interval refusal names a byte in the file. The Chunk Index parser reads a cursor over the record's content, so its own byte 0 is the start of that content, and a NaN or inverted interval was reported at byte 0 whatever its position. Callers pass the record's file origin now, so the byte named is one a reader can seek to.
-
An open-ended scene is probed at instants that exist. The canonical summary derives probe times from each chunk's interval and from
duration - 1e-6, both of which are+Infinityfor an open-ended scene — an instant no half-open interval contains, which reported a nonempty scene as an empty state at a null time. Both are omitted when they are not finite; a chunk is still probed at its start. -
An open-ended state chunk is composable on the indexed path.
[t, +Infinity)is a legal interval, and composing each chunk used to probe it at its own midpoint — which for an open-ended interval is+Infinity, an instant no half-open interval contains, its own included. The chain is now built from the entry the caller already holds, so the indexed path no longer refuses a file the streamed path reads.
These checks were pinned by a hostile-input suite kept alongside a first-party viewer that vendors this decoder. They are here now so that depending on the published package directly is not a step down in robustness for anyone doing the same.
Added #
fourdgs, a pure-Dart decoder for the.4dgscontainer. No Flutter dependency and nodart:ioin the decoder itself, so the same code runs on the Dart VM, inside Flutter, and compiled to JavaScript or Wasm.- Both read paths.
readFourdgsByteswalks a whole file front to back, needs no index, and recovers what preceded a cut;openFourdgsIndexedreads the Footer, then the index, then only the byte ranges an instant needs. keyframe-deltadecode, both read paths.decodeKeyframeDeltaStreamedcomposes each chunk onto the one it references front to back;decodeKeyframeDeltaIndexedwalks only an instant's chain from the index. Composition is bin-difference and telescopes, so the declared error bound holds at any chain depth (spec §11); GOP-invariants are enforced and rotation is restated absolutely.keyframeDeltaStatesJsonemits the canonical reconstruction-at-an-instant the SDKs are diffed on. Decode only this milestone — there is no encoder.- Object-layer decode (spec §5.15.6-§5.15.7, §6.6).
FourdgsObjectTable.parseandFourdgsObjectTrack.parseread the two records, theobject_idattribute stream (id 14) is decoded ontoFourdgsGaussianSet.objectId, andFourdgsObjectLayercomposes an object's SE(3) track onto reconstructed state —center = R * c0 + T,orientation = R ⊗ r0, base first. Available on both read paths:scene.objectson the streamed path,readFourdgsObjectson the indexed one, where the records are framed at open and fetched only when asked for, as provenance is. A gaussian withobject_id = 0, or whose object has no track, keeps its base state; a scene that carries no layer produces an emptyFourdgsObjectLayer, which is a value and not an error.FourdgsStatenow carriesorientationsandobjectIdalongside centres and opacity, so a caller can compose the layer without re-deriving either. FourdgsReadableas the single abstraction either path needs — a size and a byte range — withFourdgsBytesin the core andFourdgsFileReadableinpackage:fourdgs/io.dart. Transports live at the edges, so the decoder can be tested without a network and shipped without a platform.- §3's reconstruction arithmetic:
FourdgsGaussianSet.stateAtgives the marginal, the validity window, the advected centre and the scaled opacity at a timet, and decoding ends there. - Multiple spatial
FourdgsAudioSourcevalues with independent timing, payloads, gain, looping and fixed or keyframed scene-space poses.stateAtreconstructs source-local playback time and moving pose; listener-relative HRTF, attenuation, occlusion and mixing remain player-owned. - Descriptor-only audio inspection, source-state reconstruction and bounded source-relative payload reads, so an indexed caller never has to materialize an entire track to update a moving source.
- Spherical harmonics on both read paths, bands 1 to 3, merged into whole scene-wide degrees. The indexed path fetches only the bands asked for, since each band is its own byte range in the chunk index.
- Scene provenance (spec §5.15): Coordinate Frame, Sensor Calibration, Rig Trajectory and Geodetic
Anchor on both read paths. Streamed decode fills
FourdgsScene.provenance; the indexed path frames ranges at open and fetches them viareadFourdgsProvenance.FourdgsProvenancecarries the cross-record rules (unique names, resolving rig and frame references) and the arithmetic the records imply — shortest-arc slerp, clamped pose sampling, sensor-in-scene composition. conformance/, buildingdecode_streamedanddecode_indexed, registered intests/conformance/run.pyand skipped until built. Both paths report provenance and compose the object layer in the canonical summary, which is 105 checks — the same count Rust and TypeScript take. The invalid corpus's refusal expectations are declined.- Tests for the one behaviour the corpus cannot reach: the indexed reader's front-matter scan runs to the first Chunk, so a Camera, Metadata or Attachment record sitting behind a large embedded audio track is still found. The harness only ever exercises the default 64 KiB probe on scenes that fit inside it, so a scan that stopped early would pass every check; these decode a real corpus file at probes down to 64 bytes and assert that shrinking it changes the number of round trips and nothing else. They fail rather than skip when the corpus is absent.
Security #
- Ceilings on the three counts a file chooses and the reader allocates against: validity windows, SH band descriptors per chunk-index entry, and chunk-index entries. Each is bounded twice — once against the bytes actually present, which the record itself disproves for free, and once against a ceiling far past anything a real encoder emits. Without them a 64 MiB summary names millions of objects that are built before any later budget is consulted.
- A chunk-index entry may not list the same SH band twice. A repeated descriptor is not merely redundant: each copy is fetched and then overwrites the same map entry, so N copies of a valid range are N transfers that leave one record behind.
Fixed #
- The velocity precision class is derived from the file's own
cutoffrather than from the default 0.05. A file that declares something else was encoded against that number, so assuming the default decodes a minority of gaussians' motion on the wrong pitch — the corpus'sCustomCutoffvariant is off by a factor of two — and nothing in the file says so. A cutoff outside(0, 1]is now refused rather than allowed to become a domain error inside a logarithm. - An unsupported major version is reported as the digit in the magic rather than as that character's ordinal, so "version 9" no longer arrives as "version 57".
Nothing is released. There is no pub.dev entry for this package yet and nothing should depend on it.
The first publish must be done by hand — pub.dev requires it before automated publishing can be
enabled — after which the gated job in .github/workflows/release.yml takes over.