Other
- TKEY initial-key typed accessor (spec §4.2.1 / §4.2.3)
- ID3v2.2 write support — frame-id demotion + 6-byte header + PIC layout (spec id3v2-00 §3.2/§4.15)
- typed TFLT file-type accessor file_type() + FileType enum
- typed language accessor + Language enum for COMM/USLT/USER/SYLT (spec v2.3/v2.4 structure §3)
- typed TMED media-type accessor + MediaType enum (spec v2.3 §4.6.3 / v2.4 §4.2.3)
- complete ID3v2.2.0 §4 frame-table read support + §3.1 compression-bit fix
- add the compression dimension to the write-options matrix
- frame-level zlib compression both directions + v2.3 format-flag additions (spec v2.3 §3.3 / v2.4 §4.1.2)
- typed TCON content-type accessor + ContentType enum (v2.3 §4.2.1 / v2.4 §4.2.3)
- typed SYTC tempo accessor + SytcTempo enum (spec v2.4 §4.7)
- drop release-plz.toml — use release-plz defaults across the workspace
- typed ETCO event-type accessor (spec v2.3 §4.6 / v2.4 §4.5)
- Add typed Equ2Interpolation accessor for EQU2 interpolation method
- typed RVA2 channel_type accessor (spec v2.4 §4.11)
- typed SYLT content-type + COMR received-as accessors (spec §4.9 / §4.24)
- surface v2.4 §4.2 text frames in to_key_value_pairs mapping
- typed TIPL/TMCL/IPLS pair-list accessors (spec §4.2.2)
- Round 209: Criterion bench harness for parse/write hot paths
- surface v2.4 extended-header is_update + restrictions sub-fields
- structural IPLS involved-people-list frame (spec v2.3 §4.4)
- structural EQUA equalisation frame (spec v2.3 §4.13)
- structural RVAD relative volume adjustment frame (spec v2.3 §4.12)
- Add structured RVRB reverb frame (spec v2.3 §4.13 / v2.4 §4.13)
- structural MLLT MPEG location lookup table frame
- ID3v2.4 footer emission + parse validation (spec §3.4)
- cover extended-header CRC × unsync combinations
- extended-header CRC-32 verification + emission (spec §3.2)
- drop enumerated reader names from fuzz.yml comment
- daily CI workflow + curated ID3 seed corpus for parse target
- writer-side unsynchronisation (WholeTag + v2.4 PerFrame)
- cargo-fuzz target driving panic-freedom over the ID3 parser
- typed TimestampUnit accessor for ETCO/SYTC/SYLT/POSS
- add ASPI audio seek point index frame (v2.4 §4.30)
- add ENCR encryption method registration frame
- add GRID group identification registration frame
- structural MCDI/ETCO/SYLT/POSS/RBUF/SEEK/SIGN/AENC/LINK frames
- structural USER/OWNE/COMR/SYTC/RVA2/EQU2 frames
- structural POPM / PCNT / PRIV / GEOB / UFID frames
- replace never-match regex with semver_check = false
- drop enable_miri input (miri now manual-only via workflow_dispatch)
- grant release-plz shim contents+pull-requests write
- migrate to OxideAV/.github reusable workflows
- pin release-plz to patch-only bumps
Added
-
Typed
TKEYinitial-key accessorId3Frame::initial_key()+ the
MusicalKey/KeyAccidentalenums (spec v2.3 §4.2.1 / v2.4 §4.2.3).
The frame "contains the musical key in which the sound starts",
"represented as a string with a maximum length of three characters";
the ground keys areA..G, the halfkeysb/#, minorm, and
"off key is represented with anoonly" (e.g. the spec example
Dbm). The accessor decodes that grammar toMusicalKey::Key { tonic, accidental, minor },MusicalKey::OffKey, orMusicalKey::Customfor
any value outside the grammar (tonic not inA..G, unknown / out-of-
order trailing character, or a value past the three-character maximum)
so a forward-compatible or non-conforming source surfaces structurally
rather than being dropped, matching the posture offile_type()/
media_type(). The grammar paragraph is identical across v2.2 (TKE),
v2.3, and v2.4 so the accessor is version-independent; the raw
Id3Frame::Text::valuesis unchanged and round-trips losslessly
throughwrite_tag. Three new lib tests (spec-example grammar coverage
for natural / minor / flat / sharp keys and theooff-key; non-
conforming inputs collapsing toCustom; accessorNoneon non-TKEY
frames) plus one integration round-trip test (write → parse under both
v2.3 and v2.4 envelopes preserving both the typed view and the raw
value) cover the matrix. -
ID3v2.2 write support (
write_tag(&tag, Id3Version::V2_2)/ the
write_tag_with_optionspath), completing the v2.2 round-trip — the
format was previously parse-only. Each four-char id is demoted to its
three-character v2.2 id (the inverse of the existing v2.2→v2.3
promotion table), the six-byte v2.2 frame header (3-char id + 3-byte
big-endian size, no flags; specid3v2-00§3.2) is emitted, and the
v2.2 PIC layout is reconstructed (fixed three-character image-format
code per §4.15 in place of v2.3 APIC's NUL-terminated MIME). The §4
frame set is closed, so a frame with no v2.2 equivalent (a v2.4-only
addition or an unrecognisedUnknownid) is skipped rather than
emitted under an id a conformant v2.2 reader could not interpret. v2.2
predates the extended header, footer, and per-frame flags byte, so the
writer rejectswith_crc/with_footer/with_update/
with_restrictions/with_compressionunder a v2.2 target;
UnsyncMode::WholeTagis supported via the header bit-7 unsync flag
andPerFramecollapses to whole-tag as it does for v2.3. Six new
round-trip tests cover text/comment/lyrics/URL/picture frames, the PIC
three-char image format, whole-tag unsync over a false-sync payload,
v2.4-only-frame skipping, the post-v2.2 option rejections, and a
structuredPOPMframe; the fuzz target now also drives the v2.2
write+reparse path for panic-freedom. -
Typed
TFLTfile-type accessorId3Frame::file_type()+FileType
enum (spec v2.3 §4.2.1 / v2.4 §4.2.3). The frame "indicates which type
of audio this tag defines" via a predefined code optionally followed by
/-separated refinements, "in a similar way to the predefined types in
theTMEDframe, but without parentheses". Because the wire form is
identical in both versions (MPG/3→code="MPG",refinements=["3"])
and carries no parentheses or v2.3 free-text refinement, a single bare
grammar covers both dialects; the only version difference is the
v2.4-addedMIMEtop-level code, which the predefined table resolves
under either envelope. The four predefined codes (MIME/MPG/VQF/PCM)
resolve to their spec descriptions; an out-of-table code surfaces as
FileType::Predefined { name: None, .. }so a forward-compatible
reference is preserved structurally rather than dropped, and a value
with an empty top-level segment surfaces asFileType::Custom. The raw
Id3Frame::Text::valuesis unchanged and round-trips losslessly,
mirroring theTMEDmedia_type()accessor. -
Typed
TMEDmedia-type accessorId3Frame::media_type()+MediaType
enum (spec v2.3 §4.6.3 / v2.4 §4.2.3). The frame "describes from which
media the sound originated — either a text string or a reference to the
predefined media types found in the list below." The accessor
normalises both version dialects onto one vocabulary, mirroring the
TCONcontent_types()accessor: v2.3 wraps a reference in(...)
optionally followed by a free-text refinement ((MC) with four channels→media="MC",text=" with four channels";(VID/PAL/VHS)
→media="VID",refinements=["PAL","VHS"]) with a((escape for a
literal-(free-text name, while v2.4 drops the parentheses (the spec
exampleVID/PAL/VHSparses to the same reference). The 15 predefined
top-level codes (DIG/ANA/CD/LD/TT/MD/DAT/DCC/DVD/TV/VID/RAD/TEL/MC/REE)
resolve to their spec descriptions; an out-of-table code surfaces as
MediaType::Predefined { name: None, .. }so a forward-compatible
reference is preserved rather than dropped. The raw text value is
unchanged and round-trips losslessly throughwrite_tag. -
Complete ID3v2.2.0 §4 frame-table read support. The v2.2 walker
(3-char ids, 3-byte sizes, no frame flags) previously dispatched
only text / URL /COM/ULT/PIC/REV/EQU; it now maps
every declared v2.2 frame onto the crate's typed surface:UFI§4.1
→Ufid,IPL§4.4 →Ipls,MCI§4.5 →MusicCdId,ETC§4.6
→EventTimingCodes,MLL§4.7 →MpegLocationLookup,STC§4.8
→SyncedTempo,SLT§4.10 →SyncedLyrics,GEO§4.16 →Geob,
CNT§4.17 →PlayCounter,POP§4.18 →Popularimeter,BUF
§4.19 →RecommendedBuffer,CRA§4.21 →AudioEncryption(all
byte-identical to their v2.3 descendants' payload layouts), plus two
v2.2-specific walkers —RVA§4.12 (right/left volume-change fields
are unconditional; presence is not keyed on the inc/dec sign bits
the way v2.3RVADgates its front block, so a both-decrement frame
keeps its data) andLNK§4.22 (the linked frame identifier is
always exactly 3 bytes, so a URL whose first byte is an uppercase
id-class character can never be folded into the identifier the way
the v2.3/v2.4 3-vs-4-byte heuristic would).CRM§4.20 (encrypted
meta frame) has no v2.3/v2.4 descendant and is preserved verbatim
viaId3Frame::Unknown. Thev22_promoteconversion table now
covers the full §4 id list so pass-through writes promote correctly. -
Frame-level zlib compression, both directions (spec v2.3 §3.3
format flagi/ v2.4 §4.1.2 format flagk).parse_tagnow
inflates compressed frames transparently in both dialects — v2.3's
4-byte big-endian decompressed-size header addition and v2.4's
mandatory data-length indicator (a compressed v2.4 frame without
the DLI bit is rejected per the spec's "requires the 'Data Length
Indicator' bit to be set as well") — and dispatches the recovered
payload structurally. The announced decompressed size is
authoritative: an inflate-length mismatch drops the frame (earlier
frames survive, matching the corrupted-frame posture), and the
announce doubles as the allocation cap under a 64 MiB per-frame
ceiling so a zlib bomb can't force a huge allocation. On the
writer sideWriteOptions::with_compression(true)deflates every
frame and emits the per-version flag + size field; it composes
with per-frame unsync (compression first, unsync second, mirroring
the parse order), the extended-header CRC (computed over the
post-compression frame bytes), and whole-tag unsync. The zlib
stream comes fromcompcol(the workspace-wide compression
collection),default-features = false,features = ["zlib"]. -
Typed accessor
Id3Frame::content_types()for theTCON
content-type (genre) frame (spec v2.3 §4.2.1 / v2.4 §4.2.3). Decodes
the frame's one-or-several content-type references into a
Vec<ContentType>, normalising both version dialects onto one
vocabulary: v2.3's parenthesised grammar ((21)numeric ID3v1 genre
reference,(RX)/(CR)Remix / Cover keyword references,
(4)Eurodisconumeric reference + free-text refinement,(51)(39)
multiple references in one string, the((-escape for a literal-(
custom genre, and a non-conforming unclosed(surfacing the
remainder as free text rather than dropping it) and v2.4's bare form
(numeric string → genre, bareRX/CRkeywords, NUL-separated
values as independent references). The newContentTypeenum carries
Genre { index: u8, name: Option<&'static str> }(numeric reference
resolved against the same Winamp-extended ID3v1 genre table the v1
trailer uses;name: Nonefor an out-of-table index so a
forward-compatible reference surfaces structurally rather than being
dropped),Remix,Cover, andCustom(String)for free-text
genres. The accessor walks the parser's already-NUL-splitvalues
and applies the per-value grammar to each, returningNonefor any
non-TCONframe andSome(Vec::new())for a present-but-empty
TCON. The rawId3Frame::Text::valuesis unchanged and round-trips
losslessly throughwrite_tag, so the typed view never costs callers
the ability to preserve the exact on-wire string — matching the
forward-compatible posture already published byetco_event_types(),
sytc_tempo_codes(), and the per-byte enum accessors. Four new tests
(lib-level grammar coverage of the parenthesised + bare forms, the
((-escape, an unclosed(, and bare numeric / keyword / free-text
values; integration accessor decoding of the v2.3 parenthesised form
including out-of-tablename: Noneand cross-variantNone; v2.4
bare-form decoding including a present-but-empty frame; writer→parser
round-trip preserving the typed view under both v2.3 and v2.4
envelopes) cover the matrix. -
Typed accessor
Id3Frame::sytc_tempo_codes()for the per-record
tempo values carried by aSYTCpayload (spec v2.4 §4.7). Decodes
to aVec<Option<SytcTempo>>whose length equals the source
codesvector so positional indexing stays stable when zipped
against the raw timestamps; each element isSome(SytcTempo)for a
spec-defined value (the two reserved-meaning bytes$00/$01plus
the2..=510BPM range) andNonefor any value beyond510so a
non-conforming source surfaces structurally rather than mapping to
a guessed variant. The newSytcTempoenum mirrors the spec
categorical meanings:BeatFree($00, "a beat-free time period,
which is not the same as a music-free time period"),SingleStroke
($01, "one single beat-stroke followed by a beat-free period"),
andBpm(u16)(the BPM verbatim in the spec range2..=510). The
wire-level one-byte vs$FF $xxtwo-byte split is already
normalised inId3Frame::SyncedTempo::codesso the typed view stays
at the logical layer;from_wire/to_wireform a bijection over
the spec range — matching the contract already published by
EtcoEventType,Rva2ChannelType,Equ2Interpolation,
SyltContentType,CommercialDelivery,TimestampUnit, and
Restrictions. The wire layout is byte-aligned and
version-independent (only the v2.4 frames doc listsSYTC, but the
on-wire layout is identical between v2.3 and v2.4 envelopes and
this crate's parser accepts it under both) so the accessor is
effectively version-independent, matching the cross-version posture
oftimestamp_unit()andetco_event_types(). The raw
SyncedTempo::codes: Vec<(u16, u32)>field is unchanged and
round-trips losslessly throughwrite_tagfor every value the wire
format can represent (0..=510), so the typed view never costs
callers the ability to preserve forward-compatible payloads. Three
new round-trip tests (wire bijection over the spec range plus
beyond-spec-rangeNone; accessor decoding of a mixed payload with
positional indexing stability + cross-variantNoneon aText
frame; writer→parser preserves every wire-representable value under
both v2.3 and v2.4 envelopes — single-byte and$FF-extension
forms — so the typed accessor surfaces the same vector after
round-trip) cover the matrix. -
Typed accessor
Id3Frame::etco_event_types()for the per-event
"type of event" bytes carried by anETCOpayload (spec v2.3 §4.6 /
v2.4 §4.5). Decodes to aVec<Option<EtcoEventType>>whose length
equals the sourceeventsvector so positional indexing stays
stable when zipped against the raw timestamps; each element is
Some(EtcoEventType)for a spec-defined byte andNonefor a byte
in either reserved range ($17..=$DF,$F0..=$FC) so a
non-conforming source surfaces structurally rather than mapping to a
guessed variant. The newEtcoEventTypeenum mirrors the spec's
value table verbatim: 23 named events$00..=$16("padding" through
"profanity end"), aNotPredefinedSync(u8)variant that carries the
low nibble of the$E0..=$EFuser-defined synchronisation slot
(0..=15), the$FD/$FEaudio-end markers, and the$FF
continuation marker the spec describes as "one more byte of events
follows".from_wire/to_wireform a bijection over the spec
range — matching the contract already published by
Rva2ChannelType,Equ2Interpolation,SyltContentType,
CommercialDelivery,TimestampUnit, andRestrictions. The
event-type table is identical between v2.3 and v2.4 (reproduced
bit-for-bit in both version docs) so the accessor is
version-independent, matching the cross-version posture of
timestamp_unit()andcommercial_delivery(). The raw
EventTimingCodes::events: Vec<(u8, u32)>field is unchanged and
round-trips losslessly throughwrite_tagfor every byte value —
including bytes in the reserved ranges — so the typed view never
costs callers the ability to preserve forward-compatible payloads.
Three new round-trip tests (wire bijection over the spec range plus
reserved-byte rejection for both$17..=$DFand$F0..=$FC;
accessor decoding of a mixed payload with positional indexing
stability + cross-variantNoneon aTextframe; writer→parser
preserves every byte value under both v2.3 and v2.4 envelopes) cover
the matrix. -
Typed accessor
Id3Frame::equ2_interpolation()for the
interpolation-method byte that opens anEQU2payload (spec v2.4
§4.12). Decodes to a spec-shapedEqu2Interpolationenum with the
two §4.12 values:Band($00, no interpolation between adjustment
points — a renderer jumps from one adjustment level to the next in
the middle between two adjustment points) andLinear($01, a
renderer interpolates linearly between adjacent adjustment points).
from_wire/to_wireform a bijection over the spec range
$00..=$01and returnNonefor any reserved byte so a
non-conforming source surfaces structurally rather than mapping to a
guessed variant — matching the contract already published by
Rva2ChannelType,SyltContentType,CommercialDelivery,
TimestampUnit, andRestrictions. The underlying
Id3Frame::Equ2::interpolation: u8field is unchanged and
round-trips losslessly throughwrite_tagfor both spec-named and
reserved bytes, so the typed view never costs callers the ability to
preserve forward-compatible payloads. EQU2 is v2.4-only per spec
(the v2.4 frames doc listsEQU2and v2.3 carriedEQUAinstead
with an unrelated per-band inc/dec bitfield rather than a
curve-level interpolation choice), so the accessor is version-locked
to v2.4 by virtue of its source variant. Four new round-trip tests
(wire bijection over$00..=$01plus reserved-byte rejection
$02..=$FF; accessor decoding both spec values + reserved-byte
collapse toNone+ cross-variantNoneon aTextframe; v2.4
writer→parser preserves bothBandandLinearso the typed
accessor surfaces the same variant after round-trip; reserved-byte
raw field round-trips throughwrite_tagwhile the typed view
collapses toNone) cover the matrix. -
Typed accessor
Rva2Channel::channel_type_typed()for the
type_of_channelbyte that opens each per-channel record inside an
RVA2payload (spec v2.4 §4.11). Decodes to a spec-shaped
Rva2ChannelTypeenum with the nine §4.11 values:Other/
MasterVolume/FrontRight/FrontLeft/BackRight/
BackLeft/FrontCentre/BackCentre/Subwoofer;from_wire
/to_wireform a bijection over the spec range$00..=$08and
returnNonefor any reserved byte so a non-conforming source
surfaces structurally rather than mapping to a guessed variant —
matching the contract already published bySyltContentType,
CommercialDelivery,TimestampUnit, andRestrictions. The
underlyingRva2Channel::channel_type: u8field is unchanged and
round-trips losslessly throughwrite_tagfor both spec-named and
reserved bytes, so the typed view never costs callers the ability
to preserve forward-compatible payloads. The wire byte is identical
between v2.3 and v2.4 (v2.3 carries the v2.4-introducedRVA2
frame on tags that have been upgraded; the spec layout is
byte-aligned and version-independent), so the accessor is
version-independent and mirrors the cross-version posture of
Id3Frame::timestamp_unit,Id3Frame::sylt_content_type, and
Id3Frame::commercial_delivery. Round-trip tests cover writer →
parser under both v2.3 and v2.4 envelopes across master / front /
back / centre / subwoofer / reserved channels plus exhaustive
wire-bijection coverage over the$00..=$08spec range and
reserved-byte rejection over$09..=$FF. -
Typed accessors
Id3Frame::sylt_content_type()and
Id3Frame::commercial_delivery()for the enumerated bytes carried
bySYLT(spec v2.3 §4.10 / v2.4 §4.9, "Content type") andCOMR
(spec v2.3 §4.25 / v2.4 §4.24, "Received as"). Both decode to
spec-shaped enums (SyltContentTypewith the nine §4.9 values:
Other/Lyrics/TextTranscription/MovementPartName/
Events/Chord/Trivia/UrlsToWebpages/UrlsToImages;
CommercialDeliverywith the nine §4.24 values:Other/
StandardCdAlbum/CompressedAudioOnCd/FileOverInternet/
StreamOverInternet/NoteSheets/NoteSheetsInBook/
MusicOnOtherMedia/NonMusicalMerchandise); both enums expose
from_wire/to_wireforming a bijection over the spec range
$00..=$08and returningNonefor any reserved byte so a
non-conforming source surfaces structurally rather than mapping to
a guessed variant — matching the contractTimestampUnitand
Restrictionsalready publish. Both accessors returnNonefor
any otherId3Framevariant, matching the cross-variant posture of
Id3Frame::timestamp_unit. The COMR mapping is identical between
v2.3 and v2.4 so the accessor is version-independent; the SYLT
byte's spec range extended from$06(v2.3 §4.10) to$08in
v2.4 §4.9, but the wire byte is unambiguous so the accessor
ignores the cross-version section-number rename. Round-trip tests
cover both accessors under both v2.3 and v2.4 envelopes plus
exhaustive wire-bijection coverage over the$00..=$08spec range
and the reserved-byte rejection over$09..=$FF. -
to_key_value_pairsnow maps the v2.4 spec §4.2 text frames the
prior table dropped to Vorbis-style keys: §4.2.5 timestamp class
(TDEN→encodingtime,TDTG→taggingtime); §4.2.3
informational (TMOO→mood,TFLT→filetype,TLEN→
length); §4.2.4 rights/radio (TOWN→owner,TPRO→
producednotice,TRSN→radiostation,TRSO→
radiostationowner); §4.2.5 sort-order (TSOA→albumsort,
TSOP→artistsort,TSOT→titlesort); §4.2.1 set-subtitle
(TSST→setsubtitle); §4.2.5 misc (TDLY→playlistdelay,
TOFN→originalfilename); plus the v2.3-only date/size frames
v2.4 folded intoTDRCor removed (TDAT→date_ddmmper spec
§TDAT DDMM shape — distinct fromTYER'sdateso the two don't
collide;TIME→time_hhmm;TRDA→recordingdates;TSIZ
→size). UnknownT???frames still fall through to the
lowercased frame id (catch-all unchanged). -
Typed accessors
Id3Frame::involved_people()and
Id3Frame::musician_credits()for the spec §4.2.2 pair-list text
frames (TIPL,TMCL) and the v2.3IPLSstructural frame.
Folds the parser's flat NUL-splitvaluesback into
(role, name)/(instrument, performer)pairs; surfaces both
TIPLandIPLSvia the sameinvolved_people()entry point so
callers handle either source version without matching on the
underlying variant (matching the cross-version posture of
timestamp_unit()); keepsTMCLseparate viamusician_credits()
since the logical mapping is distinct (instrument-to-musician
vs function-to-name) even though the wire layout is identical;
surfaces non-conforming odd-count sources as a pair with an empty
second component rather than dropping the trailing entry; returns
Some(Vec::new())for a present-but-empty frame so callers can
distinguish from a frame-absentNone. -
Criterion bench harness at
benches/id3.rscovering the three
public surfaces a typical caller exercises on an MP3-resident tag:
bench_parse_minimal_v24drivestag_size_at_head→parse_tag→
to_key_value_pairsover a hand-built ~135-byte v2.4 tag (TIT2 /
TPE1 / TALB text frames + one short COMM, exercising spec §3.1
header walk, §4.1 frame walk, §4.2 text-frame decode, and §4.10
comment-frame decode);bench_parse_apic_heavy_v24drives
parse_tagover a ~64 KiB v2.4 tag whose dominant cost is the
inner 60 KiB APIC picture copy (spec §4.14 — encoding byte +
NUL-terminated MIME + picture-type byte + description + picture
bytes) so MiB/s reflects the picture-copy bandwidth ceiling rather
than structural overhead;bench_write_text_v24round-trips the
minimal-v24 fixture throughwrite_tagwith the default
WriteOptions, isolating the write surface from the parse cost
(the parse step lives outside the timed region) and reporting
throughput against the produced output length;bench_parse_id3v1
parses the 128-byte trailer (spec layout:TAG+ 30 title + 30
artist + 30 album + 4 year + 28 comment + 1 zero + 1 track + 1
genre) as the baseline-floor measurement. Every fixture is
hand-built in the bench from the wire layout described in
docs/container/id3/id3v2.4.0-structure.htmland
docs/container/id3/id3v2.4.0-frames.html; the 60 KiB picture
payload is seeded by a fixed-seed xorshift32 so the compiler
cannot constant-fold the per-iteration copy out of the timed
region. Criterion drives all four scenarios via a single
criterion_group!inbenches/id3.rs;[[bench]]table with
harness = falseinCargo.tomland Criterion is added as a
[dev-dependencies]pin to0.5matching the other OxideAV crates
with benches. Run withcargo bench -p oxideav-id3 --bench id3.
README now carries a## Benchmarkssection publishing the
baseline numbers from this machine so future rounds can quote
deltas against a fixed reference point. -
v2.4 extended-header sub-fields surfaced via the new
parse_tag_with_extended_headerentry point and emitted via two
newWriteOptionsbuilders (with_update,with_restrictions).
Spec §3.2 defines three optional ext-header sub-fields:b"Tag
is an update",cCRC-32, anddrestrictions. Previously the
crate parsed and verified onlyc(CRC);banddwere
consumed structurally to keep parsing valid but their values were
dropped. They now decode into a typedExtendedHeaderstruct
carryingis_update: bool,crc: Option<u32>, and
restrictions: Option<Restrictions>. The five restrictions
sub-fields each get their own enum so a caller can pattern-match
the advisory limits without parsing the wire byte by hand:
TagSizeRestriction(bits 7..=6,%pp),
TextEncodingRestriction(bit 5,%q),
TextFieldsRestriction(bits 4..=3,%rr),
ImageEncodingRestriction(bit 2,%s), and
ImageSizeRestriction(bits 1..=0,%tt).
Restrictions::{from_wire, to_wire}is a bijection over all 256
bytes — the typed sub-fields cover every bit position with no
gaps. The writer emits an extended header whenever any ofcrc,
is_update, orrestrictionsis set, with the attached-data area
laid out in the spec-mandatedb, c, dflag-bit order. Both
v2.4-only sub-fields (is_update+restrictions) reject a v2.3
target withError::unsupported, matching thewith_footer
v2.4-only rejection pattern.parse_tagis unchanged — it
internally consumes the verified extended header and returns the
same(Id3Tag, usize)pair as before, so existing callers keep
working;parse_tag_with_extended_headeris the additive richer
sibling.
Fixed
-
The v2.2 header compression bit (§3.1 flag bit 6) was misread as the
v2.3/v2.4 extended-header flag, silently parsing a tag body the spec
says to skip ("Since no compression scheme has been decided yet, the
ID3 decoder (for now) should just ignore the entire tag if the
compression bit is set"). Bothparse_tagand
parse_tag_with_extended_headernow return the v2.2 version
envelope with an empty frame list and the correct consumed size so
container callers can still seek past the tag. -
The v2.3 frame parser previously ignored the frame-flags bytes
entirely, so a v2.3 frame with any format flag set had its payload
dispatched at the wrong offset (grouping) or its raw
ciphertext/deflate bytes fed to a structural parser (encryption /
compression). The §3.3 header additions are now consumed in spec
order — decompressed size, then encryption-method byte, then
group-identifier byte — with grouped frames parsing their real
payload and encrypted frames surfacing asId3Frame::Unknownwith
the method byte + ciphertext preserved, matching the existing v2.4
posture. -
v2.4 extended-header CRC writer was silently truncating CRC bit 31
when serialising the 32-bit CRC into the spec's 5-byte synchsafe
encoding. Spec §3.2 specifies 5×7-bit bytes = 35 bits, of which
the top 4 bits of the top synchsafe byte carry CRC bits 31..=28
(the remaining 3 bits are reserved zero). The writer masked the
top byte with0x07instead of0x0F, which clipped CRC bit 31.
Round-trip withWriteOptions::with_crc(true)happened to work
for tags whose body CRC had bit 31 clear, but ~half of all bodies
hit the bug and the parser would reject them with "CRC mismatch".
Mask is now0x0Fand a regression test (cycles through bodies
until it lands on a top-bit-set CRC) asserts the round-trip. -
IPLSinvolved-people-list frame (spec v2.3 §4.4). Replaces the
previous opaqueId3Frame::Unknown { id: "IPLS", .. }fallthrough
with a structuredId3Frame::Ipls { pairs }variant.pairsis a
Vec<(String, String)>of(involvement, involvee)so a writer can
never emit an odd count — the spec pairing is fundamental, each
involvement (role, e.g.producer,mixing engineer,guitar)
names exactly one involvee. The on-wire layout per spec is a single
encoding byte followed by alternating NUL-terminated strings in the
declared encoding:involvement_0\0 involvee_0\0 involvement_1\0 involvee_1\0 …. The writer follows the crate's default text
encoding (UTF-16 with BOM for v2.3 / UTF-8 for the v2.4 envelope
selector), so arbitrary Unicode role/name strings round-trip
losslessly. The parser tolerates a non-conforming source that omits
the final involvee terminator by folding the dangling involvement
into a pair with an empty involvee — the truncation surfaces
structurally rather than crashing or silently dropping the string.
An empty payload surfaces asId3Frame::Unknownso the wire bytes
round-trip untouched, matching theEQUAempty-payload behaviour
(the spec encoding byte is mandatory). A payload that's only the
encoding byte parses to an empty pair list (spec lets the pair list
follow but doesn't forbid zero pairs). IPLS is v2.3-only — v2.4
dropped it in favour of theTIPLtext frame (involved-people list)
and the newTMCLmusician-credits list, both of which are ordinary
text frames the existingId3Frame::Textvariant already handles —
so the writer returnsError::unsupportedunder an
Id3Version::V2_4envelope mirroring theRVAD/EQUAv2.3-only
contract. Spec also notes "There may only be oneIPLSframe in
each tag" — uniqueness is a caller-level concern, matching how the
crate treatsEQU2/MCDI/MLLT/RVRB/RVAD/EQUA.
Nine new lib tests cover the matrix (UTF-16 BOM writer pinned to a
hand-computed exact byte sequence; latin1 two-pair parser through
the encoding-0 path; v2.3 round-trip with three pairs; dangling
trailing involvement folds into an empty-involvee pair;
empty-payload Unknown fallback; encoding-byte-only payload yields
empty pair list; v2.4 writer rejection; zero-pairto_key_value_pairs
invariant; empty pair list round-trip). Two new integration
round-trip tests (roundtrip_ipls_v23_multi_pair_unicodeexercising
five pairs including a Japanese role/name pair through the public
write_tag+parse_tagsurface, and
roundtrip_ipls_writer_rejects_v24pinning the v2.3-only writer
contract through the publicwrite_tagsurface) finish the
coverage. -
EQUAequalisation frame (spec v2.3 §4.13). Replaces the previous
opaqueId3Frame::Unknown { id: "EQUA", .. }fallthrough with a
structuredId3Frame::Equa { adjustment_bits, bands }variant.
adjustment_bitsis the per-band magnitude width in bits ($10 = 16
is the spec-listed norm for MPEG audio; writer rejects the
spec-forbidden $00). EachEquaBandcarries the spec's
increment-decrement bit (truefor1 = increment,falsefor
0 = decrement— stored on the wire as the most-significant bit of
the 2-byte BE frequency word), a 15-bitfrequencyin Hz
(0..=32767), and an unsigned big-endianadjustmentmagnitude whose
width isceil(adjustment_bits / 8)bytes. The writer enforces the
two spec ordering rules at write time — "the equalisation bands
should be ordered increasingly with reference to frequency" and "a
frequency should only be described once in the frame" — by requiring
thebandslist to be sorted strictly increasing by frequency; the
parser preserves wire order so callers can detect a non-conforming
source. Sub-byte adjustment widths zero-pad at the high end per spec
"padded in the beginning (highest bits) when not a multiple of
eight"; over-wide adjustments and frequencies whose top bit collides
with the inc/dec flag (>= 0x8000) are rejected with
Error::invalid. EQUA is v2.3-only — v2.4 dropped it in favour of
EQU2(the v2.4 frames doc listsEQU2and does not mention
EQUA), so the writer returnsError::unsupportedunder an
Id3Version::V2_4envelope mirroring theRVADv2.3-only contract.
v2.2EQU(3-char id) promotes to the sameEquavariant via the
dispatch +v22_promotetable, matching how v2.2REVpromotes to
RVRB. Fourteen new lib tests cover the matrix (two-band writer
pinned to a hand-computed 9-byte sequence; multi-band 16-bit
round-trip including the 15-bit frequency boundary; sub-byte
12-bit-width round-trip;adjustment_bits = $00writer rejection;
v2.4 emission rejection; unsorted-bands rejection; duplicate-
frequency rejection; frequency-overflow rejection; over-wide
adjustment rejection; empty-payload Unknown fallback; short-trailing
band drop; zero-pair invariant forto_key_value_pairs; v2.2EQU
→Equadispatch;v22_promote("EQU") == "EQUA"). Two new
integration round-trip tests (roundtrip_equa_v23_multi_band_16bit
exercising five bands across the full 15-bit frequency range and
roundtrip_equa_writer_rejects_v24pinning the v2.3-only writer
contract through the publicwrite_tagsurface) finish the
coverage. Spec notes "There may only be one 'EQUA' frame in each
tag" — uniqueness is a caller-level concern, matching how the crate
treatsEQU2/MCDI/MLLT/RVRB/RVAD. -
RVADrelative volume adjustment frame (spec v2.3 §4.12). Replaces
the previous opaqueId3Frame::Unknown { id: "RVAD", .. }
fallthrough with a structuredId3Frame::Rvadvariant. Carries the
spec's raw inc/dec bitfield (top two bits reserved%00; bits 0..=5
declare per-channel presence + sign per "1 is increment and 0 is
decrement"), thebits_usedbyte (per-field width in bits;
spec-forbidden$00rejected by the writer), and four optional
channel blocks in the spec's wire order — front (rightthen
left), back (right_backthenleft_back), centre, bass — each
exposed as nestedOption<RvadFrontChannels>/RvadBackChannels/
RvadChannelso a caller can distinguish "block absent" from "block
present, peak omitted". EachRvadChannelcarries an unsigned
big-endianvolume_deltamagnitude (sign comes from the parent
bitfield) and an optional unsigned big-endianpeak, both
zero-padded toceil(bits_used / 8)bytes whenbits_usedis not a
multiple of 8 per spec "padded in the beginning (highest bits)". The
on-wire layout per block is all deltas first, then all optional
peaks — not interleaved per channel — matching the spec's listing
order (right delta, left delta, then right peak, left peak); a block
withpeak.is_empty()on every channel writes no peak bytes,
surfacing the spec's "completely omitted" form. The writer rejects
inc/dec bitfield vsOption-block mismatches (e.g. bit 2 set but
back == None), out-of-spec extension orderings (e.g. bass without
centre, or any extension without front), volume_delta or peak
exceeding the declaredbits_usedwidth, the reserved%00top
two bits being non-zero, and most importantly emission under
Id3Version::V2_4— v2.4 droppedRVADentirely in favour of
RVA2(the v2.4 frames doc listsRVA2and does not mention
RVAD), so the writer returnsError::unsupportedmirroring the
with_footer+V2_3rejection pattern. Ten new lib tests
(front-only writer pinned to a hand-computed 10-byte sequence;
front+back round-trip; six-channel round-trip exercising centre +
bass extensions; peak-omitted minimal wire form with bytes pinned;
sub-byte width round-trip atbits_used = 12;bits_used = $00
writer rejection; v2.4 emission rejection; inc/dec ↔ block-Option
mismatch rejection; short-payload Unknown fallback for both 0-byte
and 1-byte inputs; zero-pair invariant forto_key_value_pairs)
plus two new integration round-trip tests
(roundtrip_rvad_v23_all_channels_12bitexercising all six channel
blocks at sub-byte width;roundtrip_rvad_writer_rejects_v24
pinning the v2.3-only writer contract through the public
write_tagsurface) cover the matrix. Spec notes "There may only
be one 'RVAD' frame in each tag" — uniqueness is a caller-level
concern, matching how the crate treatsMCDI/MLLT/RVRB. -
RVRBreverb frame (spec v2.3 §4.13 / v2.4 §4.13). Replaces the
previous opaqueId3Frame::Unknown { id: "RVRB", .. }fallthrough
with a structuredId3Frame::Reverbvariant carrying the ten
spec-named fields: u16 BEreverb_left_ms/reverb_right_ms
delays, u8bounces_left/bounces_rightcounts (where$FF
denotes the spec's "infinite number of bounces"), four u8 feedback
bytes (feedback_ll/feedback_lr/feedback_rr/feedback_rl
for the four bounce-to-channel routings on the spec's
$00 = 0% .. $FF = 100%scale, where$7Fproduces the worked
example of "50% volume reduction on the first bounce, 50% of that on
the second"), and two u8 premix bytes (premix_lr/premix_rl,
also$00..$FF, with both$FFproducing mono output when reverb
is symmetric). The wire payload is exactly twelve bytes with no
encoding byte and no terminators, and the layout is identical
between v2.3 and v2.4 — the writer emits the same bytes under either
version envelope. A v2.2REVframe promotes to the same
Reverbvariant on parse (matching theTT2 → TIT2,PIC → APIC
table from §3.3 of the v2.2 spec). A payload shorter than 12 bytes
is preserved verbatim throughId3Frame::Unknown { id: "RVRB", .. }
because the spec layout is exact-size and a truncated frame cannot
be reconstructed unambiguously. Trailing bytes after the canonical
12 are dropped on read per spec "unknown bytes in a frame should be
skipped".to_key_value_pairsdoes not surfaceReverb(the frame
carries no text values, only DSP descriptors). Six new lib tests
(writer pinned to a hand-computed 12-byte sequence; v2.3 + v2.4
round-trip; short-payload Unknown fallback for both 11-byte and
zero-byte inputs; trailing-byte drop; v2.2REVpromotion to
Reverb; zero-pair invariant forto_key_value_pairs) and two new
integration round-trip tests (roundtrip_rvrb_v23_and_v24and
roundtrip_rvrb_extreme_valuesexercising$FFbounces / feedback
/ premix and the0xFFFFu16 delay extreme) cover the matrix. Spec
notes "There may only be one 'RVRB' frame in each tag" — uniqueness
is left to the caller, matching how the crate treats the analogous
single-instance constraints onMCDI/MLLT/SEEK. -
MLLTMPEG location lookup table frame (spec v2.3 §4.7 / v2.4 §4.6).
Replaces the previous opaqueId3Frame::Unknown { id: "MLLT", .. }
fallthrough with a structuredId3Frame::MpegLocationLookupvariant
carrying the u16 frame-counter increment, the two 24-bit
"between reference" fields (bytes + milliseconds), the two
per-reference deviation widths (bits), and the decoded
Vec<(u32, u32)>of(bytes_dev, ms_dev)pairs. Per-reference
packing is MSB-first across byte boundaries via a small bit-reader /
bit-writer pair; the writer enforces the spec's
(bits_for_bytes_deviation + bits_for_ms_deviation) % 4 == 0
constraint by returningError::invalidrather than emitting a
stream a conforming reader could not realign on. The 24-bit fields
are capped at0x00FF_FFFFand per-reference deviation values are
capped at the declared per-field bit width — out-of-range values
fail the writer with a specific error. Per-reference widths above
32 bits are refused on read (descriptor preserved, references
empty) and on write (returned asError::invalid) since the
in-memory representation tops out atu32. Six new lib tests
(bit-packing pinned to a hand-computed byte sequence; sub-byte
alignment round-trip) and six new integration round-trip tests
(v2.312 + 4, v2.48 + 8, four error-path tests: non-mult-of-4
total bits, 24-bit overflow, reference exceeds declared width, and
the >32-bit-width parser short-circuit) cover the matrix. -
ID3v2.4 footer support (spec §3.4). The parser used to advance past
the 10-byte trailer when the header's footer-present bit (0x10) was
set without verifying any of it; it now requires the trailer's
b"3DI"identifier and validates that the footer's version /
flags / synchsafe size mirror the header byte-for-byte, returning a
specific error per failure mode (magic, version, flags, size,
truncation →Error::NeedMore). A footer flag on a v2.2 / v2.3
header is rejected outright since the spec only defines the footer
for v2.4. On the writer side,WriteOptionsgrows afooterfield
(set via thewith_footerbuilder method); when enabled on a v2.4
target the writer sets header bit 0x10 and appends the 10-byte
trailer with identifierb"3DI"followed by a verbatim copy of the
header's version / flags / size bytes. Requesting a footer against a
v2.3 target returnsError::unsupportedrather than silently
dropping the flag — appended tags must be v2.4 to be discoverable by
a reverse scan.with_footercomposes cleanly with bothwith_crc
andwith_unsync(WholeTag | PerFrame): the footer lives outside the
announced synchsafe size (matching spec §3.1 "If a footer is present
this equals to ('total size' - 20) bytes"), so the unsync transform
never touches it and the CRC region is unchanged. Ten new lib tests
(footer default / +unsync / +crc round-trips, v2.3-rejection both
directions, four parser-validation tests for magic / size / flags /
truncation, plustag_size_at_headconsistency) and four new
integration round-trip tests cover the matrix. The fuzz target's
write × parse loop now iteratesfooter ∈ {false, true}alongside
the existing unsync × CRC dimensions. -
Extended-header CRC verification on read + emission on write
(spec §3.2 in both v2.3 and v2.4). The parser used to skip the
extended header outright; it now decodes it, walks the v2.4 per-flag
attached-data area (update / CRC / restrictions, with strict
data-length validation), and verifies the stored CRC-32 [ISO-3309]
against the spec-defined region — frames-only in v2.3, frames +
padding in v2.4. A mismatched CRC is a hard parse error. The writer
grows a newWriteOptions::crcflag (set via thewith_crcbuilder
method); when enabled the writer prepends a CRC-bearing extended
header (14 bytes for v2.3: size = 10 excl-self, flags0x80 0x00,
padding-size = 0, regular u32 CRC; 12 bytes for v2.4: synchsafe
size = 12 incl-self, flag-count = 1, flags = 0x20, data-length = 5,
5-byte synchsafe CRC) and sets the tag-header's extended-header bit.
WriteOptions::with_crccomposes cleanly with the existing
with_unsyncsetter: the CRC is computed on pre-unsync frame
bytes (matching v2.3's "calculated before unsynchronisation"), then
unsync is applied over(ext_header || frames); the parser reverses
unsync first, so the same byte sequence is fed to the CRC check on
the read side. Seven new round-trip tests cover the per-version
on-wire shape, default-options-emit-no-extended-header invariant,
CRC + WholeTag unsync round-trip on both versions, CRC + PerFrame
unsync on v2.4, and corrupted-CRC parse rejection on both versions.
Internal CRC-32 implementation is a 9-line bit-by-bit loop
(polynomial 0xEDB88320, init / xor-out 0xFFFFFFFF) — no new
dependencies. -
Writer-side unsynchronisation: new public
UnsyncModeenum
(None/WholeTag/PerFrame),WriteOptionsbag, and
write_tag_with_optionsentry point.WholeTagapplies spec §6.1
unsync over the entire serialised body and sets the header flag
bit 0x80 (works for both v2.3 and v2.4);PerFrameis v2.4-only
and unsynchronises each frame's payload independently with the
format-flag bit 0x02 set per frame. A new internalapply_unsync
is the byte-for-byte inverse of the existingreverse_unsync
(escapes$FFwhenever followed by an MPEG sync byte%111xxxxx,
by literal$00, or by end-of-buffer per spec §6.1 last
paragraph). The pre-existingwrite_tagshorthand is unchanged
(it forwards towrite_tag_with_optionswithUnsyncMode::None).
Six new round-trip tests cover identity composition,
false-sync elimination, v2.3 / v2.4 whole-tag round-trip via
parse_tag, v2.4 per-frame round-trip, and the v2.3 silent
downgrade ofPerFrametoWholeTag. The cargo-fuzzparse
target now also driveswrite_tag_with_optionsunder both unsync
modes on both target versions and re-parses the output. -
cargo-fuzztargetfuzz/fuzz_targets/parse.rsdrives arbitrary
bytes throughtag_size_at_head,parse_tag,parse_id3v1,
to_key_value_pairs,attached_pictures,write_id3v1, and
write_tag(v2.3 + v2.4) and asserts panic-freedom on every input.
Classic spots covered: synchsafe-size overflow, frame-size > tag-size,
v2.3/v2.4 extended-header bounds, encryption / compression /
data-length-indicator flag combos, GEOB length fields, SYLT
terminators. Sustained 15M+ iteration runs under libFuzzer find no
crashes. Run withcd fuzz && cargo +nightly fuzz run parse. -
Daily scheduled
FuzzCI workflow
(.github/workflows/fuzz.yml) runs theparsetarget for a
30-minute budget via the sharedcrate-fuzzreusable workflow, plus
a curated seven-input seed corpus underfuzz/corpus/parse/(minimal
v2.2 / v2.3 / v2.4 text tags, a mixed COMM/UFID/TXXX/APIC v2.4 tag, a
v2.3 extended-header tag, a whole-tag-unsync v2.4 tag, and an ID3v1
trailer). The seeds drive structural coverage ~30x deeper than the
prior noise corpus; a fresh 60-second two-worker baseline at ~4.4M
iterations is crash-free. -
New public
TimestampUnitenum (MpegFrames/Milliseconds) and
Id3Frame::timestamp_unit()typed accessor surface the
time_stamp_formatbyte carried byETCO,SYTC,SYLT, and
POSSframes per spec v2.3 §4.6 / §4.8 / §4.10 / §4.22 (identical
in v2.4 §4.5 / §4.7 / §4.9 / §4.21). The accessor returnsNone
for the reserved wire values so callers don't have to invent a
default. Two new round-trip tests prove the logical unit is
preserved when a SYLT frame is written under one major-version
envelope and re-parsed under the other. -
Structural parser + writer for five additional ID3v2.3 / 2.4 frames:
POPM(popularimeter — email, rating, wide-counter),PCNT(play
counter, widens past 32 bits per spec §4.16),PRIV(private frame
with owner identifier),GEOB(general encapsulated object), and
UFID(unique file identifier). Each frame surfaces as its own
Id3Framevariant and round-trips throughwrite_tag/parse_tag
bit-for-bit on the payload bytes (excluding the encoding-byte choice
which the writer picks per target version). -
Structural parser + writer for six more ID3v2.3 / 2.4 frames:
USERterms of use (language triplet + free text),OWNE
ownership (currency-prefixed price + 8-byte YYYYMMDD date +
seller),COMRcommercial offer (price + valid-until date +
contact URL + 1-byte delivery method + seller + description +
optional MIME-typed company logo),SYTCsynchronised tempo
codes (time-format byte +(BPM, timestamp)pairs with the
spec's$FF-prefix extension for 256..510 BPM),RVA2relative
volume adjustment 2 (identification + per-channel records with
Q9.7 dB volume + variable-width zero-padded peak),EQU2
equalisation 2 (interpolation byte + identification + sorted
(frequency, adjustment)points). NewRva2Channelpublic
struct exposes the per-channel RVA2 record shape. -
to_key_value_pairsnow surfacesplay_count,rating[:email],
rating_count[:email],termsofuse[:lang],ownership_price,
ownership_date, andownership_sellerkeys so consumers can
read these frames without matching on the enum. -
Structural parser + writer for nine more ID3v2.3 / 2.4 frames:
MCDImusic CD identifier (opaque CD-DA TOC bytes),ETCO
event timing codes (time-format +(event_type, timestamp)
pairs),SYLTsynchronised lyrics/text (language + time-format- content-type + descriptor +
(syllable, timestamp)syncs,
honouring both single-NUL v2.4-UTF-8 and double-NUL v2.3-UTF-16
terminators inside the sync-record loop),POSSposition
synchronisation (time-format + 32-bit position),RBUF
recommended buffer size (24-bit buffer + embedded-info flag +
32-bit next-tag offset; writer clamps oversized buffer-size to
the 24-bit field width),SEEKseek frame (32-bit next-tag
offset),SIGNsignature frame (group-symbol byte + binary
signature),AENCaudio encryption (owner + 2-byte preview
start / length + opaque encryption-info),LINKlinked
information (auto-detects 3-byte v2.3 vs 4-byte v2.4 frame ids
on read, and emits the on-wire form matching the target version
on write). All nine new variants round-trip through
write_tag/parse_tagfor v2.3 + v2.4 and lose no data via
Id3Frame::Unknown.
- content-type + descriptor +
-
Structural parser + writer for
GRIDgroup identification
registration (v2.3 §4.27 / v2.4 §4.26): NUL-terminated owner
identifier + 1-byte group symbol ($80-F0 per spec) + optional
group-dependent data. NewId3Frame::GroupIdvariant round-trips
throughwrite_tag/parse_tagfor both v2.3 and v2.4 (the wire
layout is version-independent), including the empty-data minimum
frame. -
Structural parser + writer for
ENCRencryption method
registration (v2.3 §4.25 / v2.4 §4.25): NUL-terminated owner
identifier + 1-byte method symbol ($80-F0 per spec) + optional
encryption-specific data. NewId3Frame::EncryptionMethodvariant
round-trips throughwrite_tag/parse_tagfor both v2.3 and
v2.4 (the wire layout is version-independent, identical in shape to
GRID), including the symbol-only minimum frame. -
Structural parser + writer for
ASPIaudio seek point index
(v2.4 §4.30): 32-bit indexed-data start + 32-bit indexed-data
length + 16-bit number of index points + 8/16 bits-per-point + N
Fifraction entries. NewId3Frame::AudioSeekPointIndexvariant
round-trips throughwrite_tag/parse_tagfor both the 8-bit
(short-file) and 16-bit (long-file) precision modes. The writer
refuses bit widths other than 8 or 16 (a conformant parser cannot
reconstruct intermediate widths) and capsNatu16::MAX. The
parser tolerates a fraction list shorter than the declaredN
(the truncated tail is dropped) and a sub-11-byte payload
(degenerates to a zeroed frame rather than failing the whole tag).
ASPI is declared v2.4-only per spec but the wire layout is
byte-aligned and version-independent, so the writer accepts it
under any version envelope.