fix: v4.0.0 — TC18-conformant NTSCF/TSCF AVTPDU header (BREAKING) - #134
Merged
Conversation
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
force-pushed
the
fix/ntscf-tscf-header-wire-format
branch
from
August 1, 2026 03:46
fbcbd28 to
d069300
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_SUBTYPEand "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 throughencode_ntscf_frame/decode_ntscf_frameunconditionally. 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:
subtype(0x82)(bits 0-7),sv(8),version(0x0)(9-11),r(12),ntscf_data_length(13-23),sequence_num(24-31); thenstream_id(2 quadlets); thenacf_payload_data. Three quadlets = 12 octets, no reserved gap.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); thenstream_id(2 quadlets),avtp_timestamp(1),reserved(1), andstream_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 betweenntscf_data_lengthandstream_data_lengthwas confirmed, and it is not something the text layer could have shown.The fix
NTSCF_HEADER_LENsubtype, flags,seq,len, 3 reserved octets,stream_id@8subtype, flags+len[10:8],len[7:0],seq,stream_id@4TSCF_SUBTYPE0x830x05stream_idavtp_timestampstream_data_lengthTSCF_DATA_LENGTH_MAX0x07FF0xFFFFtvbitavtp_timestamp != 00x83was invented as "NTSCF_SUBTYPEplus one". The two subtypes are unrelated IEEE 1722 code points and TSCF's is the smaller.encode_tscf_headerpreviously rejected every legalstream_data_lengthfrom 2048 upward withInvalidSize. It now accepts the wholeu16range and can no longer fail, but keeps itsResultreturn for symmetry withencode_ntscf_headerand future validation headroom.The
tvbit 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'sacf_*_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 asstream_id = streamMAC + unique_id,streamMAC: 6bytes,unique_id2 bytes.Requirements
REQ-NTSCF-001..004,REQ-TSCF-001..004,REQ-WIRE-001/004/007/009andREQ-CONF-001/002/005are 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 behavior —REQ-NTSCF-004literally 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
pubitem breaks compilation, andavtpis Tier 1 underdocs/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.mdgains 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.txtis updated forencode_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.pyandscripts/cyber-gap-check.sh: pass.docs/PUBLIC_API.txtwas updated by hand rather than regenerated —cargo-public-apiand a nightly toolchain are not available in the environment this was prepared in. Theapi-stabilityCI job is the check on that; the edit is the single[u8; 16]→[u8; 12]line.