Skip to content

fix: v4.0.0 — TC18-conformant NTSCF/TSCF AVTPDU header (BREAKING) - #134

Merged
SoundMatt merged 1 commit into
mainfrom
fix/ntscf-tscf-header-wire-format
Aug 1, 2026
Merged

fix: v4.0.0 — TC18-conformant NTSCF/TSCF AVTPDU header (BREAKING)#134
SoundMatt merged 1 commit into
mainfrom
fix/ntscf-tscf-header-wire-format

Conversation

@SoundMatt

Copy link
Copy Markdown
Owner

The bug

src/avtp.rs's NTSCF and TSCF header encode/decode were never reconciled against the specification. That module's own provenance note admitted it — TSCF_SUBTYPE and "the header's total length" were described as "this crate's own placeholder values pending that reconciliation" — and both were wrong.

This is worse than the comparable v3.0.0 ACF finding. TC18 §12.2 lists "NTSCF header processing" as the first of exactly four mandatory features, and every transport this crate ships (udp, l2, shmem, tlstransport, mock) frames through encode_ntscf_frame/decode_ntscf_frame unconditionally. Every NTSCF/TSCF frame rust-RCP has ever emitted was malformed. No release before this one could have interoperated with a conformant RC Server, so there is no backward-compatibility constraint to preserve — nothing correctly interoperated before.

How the correct format was established

Against the specification's own normative field diagrams, §11.1 "Usage of IEEE1722 for RCP", page 22:

  • Figure 6 — NTSCF-Header Version 0: subtype(0x82) (bits 0-7), sv (8), version(0x0) (9-11), r (12), ntscf_data_length (13-23), sequence_num (24-31); then stream_id (2 quadlets); then acf_payload_data. Three quadlets = 12 octets, no reserved gap.
  • Figure 5 — TSCF-Header Version 0: subtype(0x05) (0-7), sv (8), version(0x0) (9-11), mr (12), rsv (13-14), tv (15), sequence_num (16-23), reserved (24-30), tu (31); then stream_id (2 quadlets), avtp_timestamp (1), reserved (1), and stream_data_length(octets) (16 bits) + reserved (16 bits). Six quadlets = 24 octets.

Cross-checked against the worked examples on page 79: Figure 19 (single ACF_ABB under TSCF — subtype(0x05), stream_data_length(octets) = 0x003C) and Figure 20 (single ACF_GBB under NTSCF — subtype(0x82), ntscf_data_length = 0x038), which reproduce the same field order and widths.

All four figures are vector images with no extractable text layer in the source PDF — a text extraction gives only the field labels in reading order, not their bit boundaries. Both pages were rendered at 600 dpi (pdftoppm -r 600) and the bit-boundary tick marks counted directly against the 0-31 ruler. That is how the 11-bit vs. 16-bit asymmetry between ntscf_data_length and stream_data_length was confirmed, and it is not something the text layer could have shown.

The fix

before after source
NTSCF_HEADER_LEN 16 12 Fig. 6
NTSCF field order subtype, flags, seq, len, 3 reserved octets, stream_id@8 subtype, flags+len[10:8], len[7:0], seq, stream_id@4 Fig. 6
TSCF_SUBTYPE 0x83 0x05 Fig. 5, Fig. 19
TSCF stream_id octets 8-15 4-11 Fig. 5
TSCF avtp_timestamp octets 16-19 12-15 Fig. 5
TSCF stream_data_length 11 bits split across octets 3-4 16 bits at octets 20-21 Fig. 5
TSCF_DATA_LENGTH_MAX 0x07FF 0xFFFF Fig. 5
TSCF tv bit always 0 derived from avtp_timestamp != 0 Fig. 5 bit 15

0x83 was invented as "NTSCF_SUBTYPE plus one". The two subtypes are unrelated IEEE 1722 code points and TSCF's is the smaller.

encode_tscf_header previously rejected every legal stream_data_length from 2048 upward with InvalidSize. It now accepts the whole u16 range and can no longer fail, but keeps its Result return for symmetry with encode_ntscf_header and future validation headroom.

The tv bit fix means every TSCF frame this crate sent previously declared its own timestamp invalid.

Test quality — the golden vectors certified the bug

conformance::golden's NTSCF/TSCF/frame byte arrays were captured from this crate's own encoder output. That made them tautological: they could only ever catch drift away from whatever the encoder happened to do first, never that it was wrong to begin with. In practice they froze all six defects above as "correct".

Every golden array is now derived by hand from the TC18 figures, and each constant's doc comment carries an octet-by-octet derivation table naming the exact figure, page and field. Three new spec-anchored tests pin the worked examples directly — ntscf_header_matches_figure_20_worked_example, tscf_header_matches_figure_19_worked_example, tscf_tv_bit_tracks_avtp_timestamp_presence — mirroring v3.0.0's acf_*_matches_figure_* tests.

The go-RCP cross-check comment was pointing at the real bug

src/conformance.rs's module doc comment already recorded this exact byte divergence against go-RCP (13 vs. 16 octets) and explicitly declined to resolve it, calling reconciliation "out of scope for this item" and treating it as a benign difference between two independent readings of a confidential spec.

That was the wrong call. It was the real bug. The section is rewritten as a resolved finding, with the lesson recorded in the code rather than only here: both implementations agreed on the wrong subtype 0x83. A cross-implementation byte comparison can tell you at least one side is wrong, but never which — and when both sides agree, it cannot tell you even that. What resolved it was reading the specification's own normative diagrams.

go-RCP's 13-octet untimed header is likewise non-conformant (it has the length as a plain 16-bit field placed after sequence_num). That is recorded as an observation about a sibling implementation, to be raised upstream — not fixed here.

While in the area, avtp's one remaining Guiding Principle 5 flag is also resolved: StreamId's MAC/suffix split is confirmed by TC18 §12.6.1 Table 16 and §12.6.2 Table 17 (p.48), both of which spell the field out as stream_id = streamMAC + unique_id, streamMAC: 6bytes, unique_id 2 bytes.

Requirements

REQ-NTSCF-001..004, REQ-TSCF-001..004, REQ-WIRE-001/004/007/009 and REQ-CONF-001/002/005 are rewritten to state the real wire format and cite the TC18 figure and page each derives from. Several previously specified the wrong constants as the target behaviorREQ-NTSCF-004 literally read "rejects frames shorter than 16 bytes". 564/564 requirements still fully traced.

Version rationale

v3.2.0 → v4.0.0. A wire-format change breaks interoperation with any peer built against a prior release just as surely as removing a pub item breaks compilation, and avtp is Tier 1 under docs/SEMVER.md. This follows the precedent set by v3.0.0, the previous TC18 wire-format correction (ACF layout), which took a MAJOR bump for the same reason.

docs/SEMVER.md gains that rule explicitly — a wire-format change is MAJOR even when it is a fix — and its stale "the version does not move until v1.0.0" scheme note is retired (the crate is well past v1.0.0). docs/PUBLIC_API.txt is updated for encode_ntscf_header's narrowed return type, Result<[u8; 12], RcpError>; that is the only public-signature change.

Verification

cargo test --all-targets (debug and release): 1045 + 32 pass. cargo clippy --all-targets --all-features -- -D warnings: clean. cargo fmt --check: clean. cargo build --release: clean. scripts/fusa-gap-check.sh: 564/564 traced. scripts/hara_asil_check.py and scripts/cyber-gap-check.sh: pass.

docs/PUBLIC_API.txt was updated by hand rather than regenerated — cargo-public-api and a nightly toolchain are not available in the environment this was prepared in. The api-stability CI job is the check on that; the edit is the single [u8; 16][u8; 12] line.

src/avtp.rs's NTSCF and TSCF header encode/decode were never reconciled
against the specification — that module's own provenance note said so
outright, calling TSCF_SUBTYPE and "the header's total length" this
crate's "own placeholder values pending that reconciliation" — and both
were wrong. This matters more than the comparable v3.0.0 ACF finding did:
TC18 §12.2 lists "NTSCF header processing" as the first of exactly four
mandatory features, and every transport this crate ships (udp, l2, shmem,
tlstransport, mock) frames through it unconditionally. Every NTSCF/TSCF
frame rust-RCP has ever emitted was malformed; no release before this one
could have interoperated with a conformant RC Server, so there is no
backward-compatibility constraint to preserve.

Verified against the specification's own normative field diagrams, §11.1
"Usage of IEEE1722 for RCP", page 22 — Figure 5 "TSCF-Header Version 0"
and Figure 6 "NTSCF-Header Version 0" — and cross-checked against the
worked examples on page 79, Figure 19 (ACF_ABB under TSCF,
stream_data_length(octets) = 0x003C) and Figure 20 (ACF_GBB under NTSCF,
ntscf_data_length = 0x038). All four are vector images with no extractable
text layer; the bit-boundary tick marks were counted from a 600 dpi render
of both pages.

- BREAKING: avtp::NTSCF_HEADER_LEN is 12, was 16. Figure 6's NTSCF header
  is exactly three quadlets — one packed quadlet plus a 64-bit stream_id —
  with acf_payload_data starting immediately at octet 12. The previous
  layout inserted three fabricated reserved octets between the length field
  and stream_id that the specification does not have.

- BREAKING: NTSCF's first quadlet is reordered to Figure 6's actual field
  order. ntscf_data_length (11 bits) sits at bits 13-23, i.e. before
  sequence_num (bits 24-31), packed as the low 3 bits of octet 1 followed
  by all of octet 2. The previous layout put sequence_num at octet 2 and
  split the length across octets 3-4 with its low 3 bits left-justified
  into octet 4's top 3 bits. Neither the order nor the packing was right.

- BREAKING: avtp::TSCF_SUBTYPE is 0x05, was 0x83. Figures 5 and 19 both
  give subtype(0x05). 0x83 was invented as "NTSCF_SUBTYPE plus one"; the
  two subtypes are unrelated IEEE 1722 code points and TSCF's is smaller.

- BREAKING: TSCF's field positions are corrected throughout to Figure 5's
  six quadlets — stream_id at octets 4-11 (was 8-15), avtp_timestamp at
  12-15 (was 16-19), a reserved quadlet at 16-19, and stream_data_length at
  20-21 (was split across octets 3-4). The 24-octet total was
  coincidentally right; nothing inside it was.

- BREAKING: avtp::TSCF_DATA_LENGTH_MAX is 0xFFFF, was 0x07FF. Figure 5
  gives stream_data_length a full 16-bit half-quadlet of its own; only
  NTSCF's ntscf_data_length is 11 bits. encode_tscf_header previously
  rejected every legal length from 2048 upward with InvalidSize and now
  accepts the whole u16 range — it can no longer fail, though it keeps its
  Result return for symmetry with encode_ntscf_header and future headroom.

- encode_tscf_header now emits Figure 5's tv ("timestamp valid") bit at
  bit 15, derived from avtp_timestamp being non-zero — the same
  all-zero-is-untimed sentinel timestamp::AvtpTimestamp already defines.
  Previously that bit was always zero, so every TSCF frame this crate sent
  declared its own timestamp invalid.

- Test quality: conformance::golden's NTSCF/TSCF/frame byte arrays were
  captured from this crate's own encoder output, which made them
  tautological — they could only catch drift away from whatever the encoder
  did first, never that it was wrong to begin with, and in practice they
  certified all six defects above as correct. Every golden array is now
  derived by hand from the TC18 figures, with an octet-by-octet derivation
  table naming the exact figure, page and field. New spec-anchored tests
  ntscf_header_matches_figure_20_worked_example,
  tscf_header_matches_figure_19_worked_example and
  tscf_tv_bit_tracks_avtp_timestamp_presence pin the worked examples
  directly, mirroring v3.0.0's acf_*_matches_figure_* tests.

- src/conformance.rs's module doc comment recorded this exact byte
  divergence against go-RCP (13 vs. 16 octets) and explicitly declined to
  resolve it, calling reconciliation "out of scope for this item". That was
  the wrong call: it was the real bug, not a benign implementation
  difference. The section is rewritten as a resolved finding, including the
  observation that both implementations agreed on the wrong subtype 0x83 —
  a cross-implementation comparison can show that one side is wrong, never
  which, and when both agree it cannot show even that. go-RCP's own
  13-octet untimed header is likewise non-conformant and is recorded as
  such (to be raised upstream, not fixed here).

- avtp's remaining Guiding Principle 5 flag on StreamId's MAC/suffix split
  is also resolved while in the area: TC18 §12.6.1 Table 16 and §12.6.2
  Table 17 (p.48) both spell the field out as stream_id = streamMAC +
  unique_id, with streamMAC 6 bytes and unique_id 2 bytes.

- .fusa-reqs.json: REQ-NTSCF-001..004, REQ-TSCF-001..004,
  REQ-WIRE-001/004/007/009 and REQ-CONF-001/002/005 rewritten to state the
  real wire format and cite the TC18 figure and page each derives from,
  replacing text that specified the wrong constants as correct (e.g.
  REQ-NTSCF-004 read "rejects frames shorter than 16 bytes"). 564/564
  requirements still fully traced.

- docs/PUBLIC_API.txt: encode_ntscf_header's return type narrows to
  Result<[u8; 12], RcpError>. docs/SEMVER.md gains an explicit rule that a
  wire-format change is a MAJOR bump even when it is a fix, and its stale
  "the version does not move until v1.0.0" scheme note is retired.

MAJOR release: the wire format changes, and avtp is Tier 1 under
docs/SEMVER.md. cargo test (debug and release), clippy -D warnings, fmt,
fusa-gap-check and cyber-gap-check all pass locally.

Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com>
@SoundMatt
SoundMatt force-pushed the fix/ntscf-tscf-header-wire-format branch from fbcbd28 to d069300 Compare August 1, 2026 03:46
@SoundMatt
SoundMatt merged commit 0cff226 into main Aug 1, 2026
18 checks passed
@SoundMatt
SoundMatt deleted the fix/ntscf-tscf-header-wire-format branch August 1, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant