Files
libfreemkv/docs/dts.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

15 KiB

DTS / DTS-HD elementary stream parser (src/mux/codec/dts.rs)

Internal design notes moved out of source comments (see ci/comment-guard.py prose caps). Each section is pointed to by a short // comment at the corresponding site in dts.rs.

DTS_HD_EXT_SYNC

An access unit is delimited by the next CORE sync; the parser locates and exactly sizes each extension substream (via exss_frame_size) so a false core sync inside the EXSS payload can't split the AU and truncate the lossless extension.

emit_or_drop

Gates an assembled access unit through the decodability check and either pushes it or drops it. au_pts/dur_ns are already stamped on the shared PTS clock (which the caller advances whether or not the AU survives), so a drop leaves the following audio on its true timeline — a gap, not a shift. Every drop is logged (fail-loud, never silent).

stamp_pts

front is the AU's own core-PES PTS (from front_pts); dur_ns its decoded duration.

The model matches the (correct) AC-3 path: re-base to each PES's own container timestamp, and advance by one frame duration ONLY within a run of AUs that share the same PES. A new PES (its front differs from the previous AU's) trusts its own timestamp — so the emitted timeline tracks the container and never drifts. Advancing within a PES is what fixes the DVD case (several DTS core frames packed in one PES, which otherwise all collided on that single PES timestamp → "non monotonically increasing dts"). A global running clock was WRONG here: once accumulated frame durations exceeded the PES spacing it never re-based, drifting the track minutes past the real length over a feature-long title.

CORE_HEADER_MIN_BYTES

Number of leading bytes that must be buffered before the core fsize field (bytes 5-7) can be decoded. This is a HEADER-LAYOUT minimum — "enough bytes to read the size field" — and is deliberately distinct from MIN_CORE_FRAME_BYTES (the decoded-frame-size validity floor). They must not be conflated: this one gates buffer reads of the header, the other rejects implausible decoded sizes.

MIN_CORE_FRAME_BYTES

Minimum plausible decoded DTS core frame size, per ETSI TS 102 114: the on-wire FSIZE floor is 95, so a real core frame is at least 96 bytes. A decoded core_size below this means we matched a false/corrupt core sync (a lucky 0x7FFE8001 in extension-substream payload whose 14-bit fsize decoded to a tiny value) rather than a real frame, so we resync instead of closing an access unit at a junk boundary and dropping the DTS-HD extension tail.

PTS_UNSET

Sentinel for "no valid PTS base captured yet". Real PTS-in-ns values are non-negative (derived from the unsigned 90 kHz PES timestamp), so a negative value can never collide with a genuine timestamp. Used to mark the PTS base invalid after a forced flush so the next PES sets it regardless of buffer state.

flush_tail

Emits the final buffered access unit (the last core + its extension substreams, which had no following core sync to close it during streaming), gated through the decodability check. Requires a complete core frame; drops a bare partial sync tail.

SYNCWORD_BYTES

Byte length shared by the DTS core sync and the DTS-HD EXSS sync (both 32-bit words). next_core_boundary finds the next valid core sync after the current core frame, to delimit the access unit: extension-substream payload can contain byte sequences that match the core syncword, so each candidate is validated by decoding its core size — a match whose decoded size is implausible (< MIN_CORE_FRAME_BYTES or > MAX_AU_BYTES) is a false sync and is skipped, continuing the search.

EXSS header field bit widths

EXSS_USER_DEFINED_BITS, EXSS_INDEX_BITS, EXSS_HEADER_SIZE_TYPE_BITS, etc. are the DTS-HD extension-substream (EXSS) header field bit widths (ETSI TS 102 114, ExtSS header). bHeaderSizeType selects the short form (nuExtSSHeaderSize 8 bits, nuExtSSFsize 16 bits) or, for larger substreams, the long form (12 / 20 bits).

exss_frame_size

DTS-HD extension substream (EXSS) total byte size — INCLUDING the 0x64582025 syncword — read precisely from its header. buf must begin with DTS_HD_EXT_SYNC. Returns None when the size fields aren't fully buffered.

nuExtSSFsize is the total frame size in bytes minus one. Parsing it lets the AU framer skip the extension by its exact length instead of scanning its (arbitrary) payload for a core sync.

next_core_boundary

Offset where the current access unit ends (the start of the next core frame). The AU is the core frame plus its trailing DTS-HD extension substreams, which are skipped PRECISELY by their declared size — so a chance core syncword inside the XLL lossless payload can never be mistaken for the next AU boundary (the bug that truncated the extension and produced the "Failed to decode block code(s)" class). Falls back to the heuristic core-sync scan only when an extension can't be sized (malformed / truncated input).

scan_for_next_core

Heuristic fallback (the pre-fix behaviour): scan forward for the next core syncword whose decoded size is plausible. Used only when precise extension skipping can't proceed; a chance core syncword in extension payload usually decodes to an implausible size and is skipped.

dts_core_frame_size

fsize is the 14-bit field at bits 46-59 of the header (bytes 5-7). On the wire fsize is the frame length minus one, so this returns fsize + 1, i.e. the core frame length in bytes (range 1..=16384). Callers treat the result as the actual byte length and the MIN..=MAX range checks assume so.

Returns 0 when data is shorter than CORE_HEADER_MIN_BYTES — every call site rejects that via the minimum-frame lower bound, so a 0 is never mistaken for a valid tiny frame.

dts_core_samples

Samples in one DTS core frame: (NBLKS + 1) * 32. NBLKS (7 bits) is the core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5) CPF(1) NBLKS(7) FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.

DTS_PCMBLOCK_SAMPLES / DTS_SUBBAND_SAMPLES (core-header validity)

DTS core-header validity constants (ETSI TS 102 114). For a NORMAL frame deficit_samples must equal DTS_PCMBLOCK_SAMPLES — a termination frame may carry fewer; npcmblocks must be a multiple of DTS_SUBBAND_SAMPLES; audio_mode must be below DTS_AMODE_COUNT; lfe_present == DTS_LFE_FLAG_INVALID is rejected.

DTS_AMODE_COUNT

Number of LEGAL AMODE (channel-arrangement) codes. The 6-bit AMODE field (ETSI TS 102 114 §5.3.1) has 16 defined channel arrangements, codes 0-15; only 16-63 are reserved/user-defined and undecodable. The per-AMODE channel counts in ETSI TS 102 114 §5.3.1 cover all 16, confirming they are decodable — codes 10-15 are the 6/7/8-channel layouts. A frame is dropped only when audio_mode >= DTS_AMODE_COUNT (i.e. a truly reserved 16-63 code); dropping a legal 10-15 multichannel core would silence recoverable audio.

DTS_CORE_SR_VALID

Sample rate (Hz) per core SFREQ code (ETSI TS 102 114 Table 6-4); a 0 entry marks a reserved code that fails header validation as an invalid sample rate. Valid entries are locked to the spec by dts_core_sfreq_table_matches_the_dca_spec; the reserved codes are {0, 4, 5, 9, 10}.

core_header_drop_reason

Decodability gate: the core-frame header validity checks from ETSI TS 102 114. Returns Some(reason) when the DTS core-frame header is invalid — in which case the packet is undecodable ("Invalid data found") and dropping it loses nothing a decoder could have used. Returns None (keep) for a decodable header OR if the header can't be fully read (never false-drop on our own buffer underrun; the framer only emits AUs whose core is fully buffered and ≥ 96 bytes).

The 4-byte core sync is already validated by the framer, so this reads the header fields that follow it. The 16-bit CPF header CRC is not verified (we skip past it) and the audio-header/side-info CRCs are likewise not checked, because decoders treat those bytes as optional/ignored — verifying them would drop frames that decode fine (false positives).

Test: stamp_pts_reuses_front_when_no_running_cursor_yet

stamp_pts's third arm (else if front != PTS_UNSET) is only reached once the first arm's front != self.last_front_pts has already failed (i.e. front == self.last_front_pts) and no within-PES running cursor is available yet (next_pts_ns == PTS_UNSET). In that state the AU must still be stamped with its own (repeated) front timestamp, not silently collapsed to 0 — a != -> == typo on the guard would only take this arm when front IS the unset sentinel, producing 0 instead here and the sentinel value itself if front really were unset.

Test: drain_front_collapses_offset_zero_markers_instead_of_leaking

Every drained access unit that shares its PES with the previous one pushes a new (0, pts) marker once rebased; drain_front must collapse those down to the single most-recent one each time, or pts_marks grows once per access unit for the life of the track — an unbounded allocation on a multi-hour disc. front_pts() alone can't observe this: it always finds the last offset-0 entry regardless of how many duplicates precede it, so the leak is invisible unless something checks that the collapse actually ran.

Test: make_exss

Builds a real DTS-HD EXSS substream of total bytes (short header form), with an optional false DTS core syncword embedded in its payload (decoding to a plausible core size) — to prove precise sizing, not a payload scan, bounds the extension.

Test: make_dts_ext

Builds a minimal DTS-HD extension substream of size bytes (just the sync

  • zero-padding). The parser delimits extensions by the next CORE sync, not by the extension's own size header, so a valid header isn't required — only that the bytes carry no spurious core sync.

Test: bogus_tiny_core_sync

Builds 4 bytes that look like a DTS core sync but whose fsize field decodes to a tiny core_size (< MIN_CORE_FRAME_BYTES). With the dead-code guards this passed validation and could close an access unit at a junk boundary; with the fix it must be drained and resynced past.

Test: next_core_boundary_exact_syncword_length_is_not_need_more

Only the 4-byte EXSS syncword is buffered after the core frame — no header fields at all. The guard buf.len() < pos + SYNCWORD_BYTES must be strict <: at exactly pos + SYNCWORD_BYTES the sync bytes ARE fully present, so the function proceeds to identify them as an extension sync (then fails to size the header and falls back, ending in NextCore::None here since nothing after it looks like a core sync either). A <= typo would instead return NeedMore at this exact length without ever inspecting what the 4 buffered bytes are.

Test: make_bad_dts_core

Builds a structurally-framed but UNDECODABLE core: a valid make_dts_core whose LFE flag is set to the reserved value 3 (DTS_LFE_FLAG_INVALID). It still sizes and syncs correctly (so the framer delimits it normally), but the core-frame header validity check rejects it as an invalid LFE flag (ETSI TS 102 114 §5.3.1: an LFF value of 3 is invalid). LFE is byte10 bits2-1, and does NOT feed the frame duration (NBLKS + SFREQ only), so a dropped bad core still carries the same DTS_CORE_DUR_NS as its good peers.

Test: reparse_real_dts_file

Real-data fixture (ignored). Re-parses a raw .dts elementary stream through DtsParser and writes the emitted access units back out, so the garbage-extension → core-only drop can be validated against an actual damaged stream (e.g. an extracted DTS-HD MA track) end-to-end with an external DTS decoder. Env: DTS_IN (input), DTS_OUT (output).

cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture

Test: find_sync_matches_a_buffer_that_is_exactly_the_syncword

find_sync scans 0..=len-4, so a buffer that is EXACTLY the syncword still matches. The existing tests cover 0, 3 and "longer than 4", which leaves the len == 4 boundary open — and that is the case a syncword split across PES packets lands on the moment its last byte arrives.

Test: a_core_header_of_exactly_the_minimum_length_is_decoded_not_deferred

CORE_HEADER_MIN_BYTES is "enough bytes to DECODE the size field", so a buffer holding exactly that many must be decoded, not deferred. The distinction is visible precisely at the boundary: with 10 bytes of a core sync whose decoded size is sub-spec, the parser must recognise the false sync, drain past it and resync down to the 3-byte carry-over tail. Waiting instead leaves the false sync sitting at the front of the buffer, where it blocks every later real core behind it.

Test: flush_emits_a_core_that_exactly_fills_the_buffer

The end-of-stream flush must emit a final access unit whose core is exactly as long as the buffer — the ordinary case, since the last AU is closed by end-of-stream rather than by a following core sync. Rejecting it at the boundary silently drops the last frame of every DTS track.

Test: flush_discards_a_long_buffer_that_does_not_begin_with_a_core_sync

The flush guard is a DISJUNCTION: a buffer that does not BEGIN with a core sync is discarded whatever its length. Requiring both conditions instead lets a long run of junk through to dts_core_frame_size, which happily decodes a 14-bit size out of arbitrary bytes — and the flush then emits an "access unit" that is not DTS at all.

The junk here is sized so that mis-decoding it yields a plausible core size that the buffer fully covers, which is exactly when the wrong answer looks like a right one.

Test: exss_frame_size_needs_the_worst_case_header_and_no_more

EXSS_HEADER_MIN_BYTES is the WORST-CASE header length — the long form's 43 bits after the syncword, rounded up to 6 bytes, plus the 4-byte sync. It gates whether an extension substream can be sized precisely, and that is what keeps a chance core syncword inside XLL payload from being mistaken for the next AU boundary; too large and every extension falls back to the payload scan, too small and the reader runs off a truncated header.

Pinned at both sides of the boundary rather than by value, so the field widths it is summed from stay honest.

Test: an_access_unit_carries_the_source_of_the_packet_its_core_arrived_in

The whole point of the shared buffer: an access unit whose core arrived in an EARLIER packet keeps that packet's source offset, not the offset of whichever packet completed it. At a clip boundary the two belong to different clips, and taking the later one puts the unit in the wrong clip — which is what left nine audio and subtitle tracks unplaceable.