Skip to content

v0.0.9

Latest

Choose a tag to compare

@MagicalTux MagicalTux released this 15 Jun 05:10
· 7 commits to master since this release
27b95df

Added

  • (aiff) fold oxideav-aiff into oxideav-iff::aiff

Other

  • add SSND (Sound Data) chunk writer — §5.0 block-aligning body encoder
  • frame_chunk framing helper + write_fver_chunk (FVER) writer
  • ANIM op-1 (XOR ILBM mode) full-frame decode + encode
  • COMM chunk writer + public 80-bit extended sample-rate encoder (§2.1/§3.2)
  • ANIM op-4 (Generalized short/long Delta) decode + encode
  • op-2/op-3 Long/Short Delta mode decode + encode (spec §1.2.2-§1.2.3, §2.2.1)
  • EA IFF 85 §5 LIST/CAT group-children walker
  • surface chunk::ReservedId §3 reserved-ckID classifier
  • surface EA IFF 85 §3 universally-reserved ckID classifier
  • generic top-level group probe primitive (FORM/LIST/CAT envelope)
  • typed PCHG header surface + derived-hint consistency check
  • typed Sham row-palette accessors mirroring Pchg::palette_at_line
  • drop release-plz.toml — use release-plz defaults across the workspace
  • cargo-fuzz harness — aiff_decode + anim_decode + pchg_parse
  • §14 chunk-precedence surface (ChunkClass + Form helpers)
  • structured SPRT (sprite-precedence) chunk surfacing
  • structured DEST (destination-merge) chunk surfacing
  • structured SAXL (Sound Accelerator) chunk surfacing
  • structured §13.0 text chunks (NAME / AUTH / (c) / ANNO)
  • structured MIDI (MIDI Data) chunk surfacing
  • ANIM op-7 encoder + AIFF COMT/AESD/APPL surfacing + MARK/INST write
  • structured INST (Instrument) chunk parsing
  • structured MARK (Marker) chunk parsing

Added

  • AIFF/AIFF-C SSND (Sound Data) chunk writer
    (docs/audio/aiff/aiff-c.txt §5.0). aiff::write_sound_data(&SoundData)
    emits the §5.0 SoundDataChunk data portion — offset (u32) +
    blockSize (u32) + offset bytes of block-alignment padding +
    soundData — completing the AIFF round-trip story: SSND was the
    one read-path chunk class (Form::sound) without a body writer. Per
    §5.0 "offset determines where the first sample frame in the soundData
    starts", a non-zero offset inserts that many zero alignment bytes
    before the samples (the §5.0 "Block-Aligning Sound Data" mechanism),
    so the result round-trips through the SSND reader, whose samples
    slice begins at byte 8 + offset. The common case
    (§5.0 "Applications that don't care about block alignment should set
    blockSize and offset to zero") emits eight zero header bytes followed
    by the raw samples. Like every other aiff::write_* helper it emits a
    chunk body; pair it with aiff::frame_chunk(b"SSND", body) for the
    full header + odd-length pad. Covered by 4 new unit tests in
    src/aiff/form.rs (zero-offset layout, full-FORM parse round-trip,
    non-zero-offset alignment-gap round-trip, and a frame_chunk
    ChunkIter framing round-trip).

  • AIFF/AIFF-C frame_chunk framing helper + write_fver_chunk
    (FVER) writer
    (docs/audio/aiff/aiff-aifc-format.md §1 / §3.1).
    Every per-chunk aiff::write_* helper emits a chunk body and
    leaves the 8-byte ckID + ckSize header and the odd-length pad byte
    to the caller. aiff::frame_chunk(id, body) factors that into one
    place — the exact inverse of aiff::ChunkIter: it prepends the
    4-byte ckID + big-endian int32 ckSize header and appends a
    single 0x00 pad byte iff the body length is odd (the §1 16-bit
    alignment rule; the pad is not counted in ckSize), returning
    AiffError::OversizedChunk when the body exceeds u32::MAX. This
    closes the last write-side gap: FVER was the one read-path chunk
    class (Form::fver_timestamp) without a body writer.
    aiff::write_fver_chunk(timestamp) emits the 4-byte big-endian
    timestamp, and aiff::AIFC_VERSION_1 (0xA280_5140) is the §3.1
    AIFF-C v1 spec timestamp every AIFC file carries. Covered by 6 new
    unit tests in src/aiff/chunk.rs (even/odd/empty framing, pad
    transparency through a frame_chunkChunkIter round-trip, and an
    FVER framed round-trip).

  • ANIM op-1 (XOR ILBM mode) decode + encode for the full-frame
    case (docs/image/iff/anim.txt §1.2.1 / §1.3 / §2.1). op-1 is the
    original ANIM compression method: the encoder XORs every byte of the
    new frame against the previous frame's planar bitmap, producing a
    bitmap that is 0 where the frames agreed, and stores it
    run-length-encoded (anim::encode_op1_body / anim::encode_anim_op1,
    honouring BMHD.compression for ByteRun1 or uncompressed BODY). The
    decoder (apply_op1, wired into parse_anim via ANHD.operation = 1) expands the BODY and XORs it into the running planar state — a
    zero byte in the XOR bitmap leaves the running state unchanged per
    §1.3. The §2.1 "XOR mode only" mask / w / h / x / y ANHD
    fields narrow the BODY to a plane subset / sub-rectangle "to
    eliminate unnecessary un-changed data"; the staged spec gives no wire
    description of that partial-BODY layout, so a plane-masked or
    partial-rectangle ANHD is rejected with Error::unsupported and the
    full-frame case (all planes, whole bitmap) is decoded. Covered by
    tests/anim_op1.rs (8 tests: ByteRun1 + uncompressed round-trips,
    sparse / 2-plane / multi-frame sequences, all-plane-mask tagging,
    no-op-XOR on identical planar buffers, partial-rectangle rejection).

  • AIFF / AIFF-C COMM chunk writer + 80-bit extended sample-rate
    encoder
    (docs/audio/aiff/aiff-aifc-format.md §2.1, §3.2). Adds
    aiff::write_common_chunk, the round-trip inverse of
    parse_common: it emits the fixed 18-byte AIFF body
    (numChannels / numSampleFrames / sampleSize / 10-byte
    sampleRate) and, when the CommonChunk carries a
    compression_type, the AIFF-C extension — the 4-byte
    compressionType FourCC followed by the compressionName Pascal
    string padded so its total length (length byte + chars) is even per
    §3.2. A None compression name collapses to the canonical
    zero-length pstring. The writer follows the body-only convention of
    the other write_*_chunk functions (the FORM header / whole-chunk
    pad byte are the muxer's job). Backing this, the 80-bit IEEE-754
    extended encoder previously available only to the test suite was
    promoted to the public aiff::encode_extended (the exact inverse
    of decode_extended for finite normalised values) plus
    aiff::encode_sample_rate, the validating wrapper that rejects
    NaN / infinite / non-positive rates with InvalidSampleRate so a
    writer can never emit a COMM the parser would refuse. With this the
    required COMM chunk joins the existing optional-chunk writers, so
    every AIFF chunk class except the top-level FORM/SSND muxer now has
    symmetric read + write paths.

  • ANIM op-4 (Generalized short/long Delta mode) decode + encode
    (spec §1.2.4, wire format §2.2.2). Implemented from the §2.2.2
    SetDLTAshort reference routine, the only normative description of
    the op-4 wire format. The DLTA opens with 16 big-endian u32
    pointers — 8 data-list pointers then 8 op-list pointers — and these
    pointers (plus the per-op column offsets) are measured in 16-bit
    words
    , not bytes, because the reference routine performs WORD*
    pointer arithmetic (data = deltaword + deltadata[i], dest = planeptr + *ptr); this is the key behavioural difference from the
    byte-offset ops 5 / 7. Each plane's op list is a flat run of
    (offset, size) pairs terminated by 0xFFFF: offset is the
    absolute word position where the run begins (non-cumulative),
    size > 0 copies size data words one-per-row (Uniq) and
    size < 0 copies one data word to |size| rows (Same), with the
    dest stepping nw = row_bytes / word_size words per row down each
    vertical column. ANHD.bits selects the variant — bit 0
    short/long data, bit 2 separate-vs-shared info list (both
    supported), bit 5 short/long op offsets — while the XOR (bit 1) and
    horizontal (bit 4 clear) variants and any reserved high bit are
    rejected with Error::Unsupported since the spec gives them no
    separate wire format (§2.1 directs players to verify undefined bits
    are zero). parse_anim / the iff_anim demuxer now accept
    ANHD.operation = 4; the write-side surface is
    [anim::encode_anim_op4] (container-level) plus the lower-level
    [anim::encode_op4_body], both emitting the short/long-data,
    vertical, RLC, separate-info, non-XOR configuration the reference
    routine reads. Covered by tests/anim_op4.rs (10 tests:
    hand-built Same / Uniq decode in short + long data modes, word-unit
    pointer semantics, shared-info-list, unsupported-variant rejection,
    and encode→decode + full-container round-trips).

  • ANIM op-2 / op-3 (Long / Short Delta mode) decode + encode
    (spec §1.2.2 / §1.2.3, wire format §2.2.1). The DLTA opens with 8
    big-endian u32 plane pointers (0 = plane unchanged; the §2.2.1
    worked value for the first list is 32); each plane's payload is a
    list of groups whose offsets and counts are big-endian shorts and
    whose data words are longs (op 2) or shorts (op 3). A word cursor
    starts at the plane's first word; a positive offset advances the
    cursor and places one data word, a negative offset (absolute value
    = offset + 2) advances the cursor and a count short introduces that
    many contiguous data words, and 0xFFFF terminates the plane's
    list. The bitplane is addressed as the contiguous height × row_bytes byte array it occupies in memory, so op-2 long words may
    straddle row boundaries — the decoder gathers each plane into a
    contiguous buffer, applies the groups, and scatters the rows back.
    parse_anim / the iff_anim demuxer now accept ANHD.operation
    2 and 3; the new write-side surface is [anim::encode_anim_op2] /
    [anim::encode_anim_op3] (container-level, mirroring the op-5 /
    op-7 encoders) plus the lower-level [anim::encode_op23_body].
    The encoder collapses runs of ≥ 2 changed words into a
    negative-offset group per §1.2.2 ¶ "Strings of 2 or more long-words
    in a row which change can be run together", emits single-word
    groups otherwise, and bridges offsets wider than a positive short
    by rewriting an unchanged word in place. After a run group the
    cursor convention is "last written word" (the spec prose tracks
    the pointer at the position the data word "would be placed at" and
    never says it advances past a write); the in-tree encoder and
    decoder share that reading. 14 new integration tests in
    tests/anim_op23.rs — hand-crafted DLTA byte vectors pinning the
    group grammar (single words, runs, terminator, zero pointers,
    straddling long words, truncation/overrun rejection) plus
    encode → parse_anim round-trips for both modes.

  • EA IFF 85 §5 LIST/CAT group-children walker
    ([chunk::GroupChild] + [chunk::parse_group_children] +
    [chunk::prop_for_form_type]). Appendix A's productions close the
    child grammar of the two outer group kinds — LIST ::= "LIST" #{ ContentsType PROP* (FORM | LIST | CAT)* } and CAT ::= "CAT " #{ ContentsType (FORM | LIST | CAT)* } — so a generic walker can
    decode every LIST/CAT child without per-form knowledge. The new
    [chunk::parse_group_children] takes a [chunk::GroupKind] plus
    the group's payload after its ContentsType (the caller bounds the
    slice by the declared ckSize per §5 Group CAT ¶ "programs must
    respect it's ckSize as a virtual end-of-file for reading the nested
    objects") and returns typed [chunk::GroupChild] entries — Prop { form_type, body } for §5 shared-property sets, Group { kind, inner_type, body } for nested FORM/LIST/CAT — enforcing every
    structural rule §5 states: PROPs only in LISTs (¶ "PROP chunks may
    appear in LISTs (not in FORMs or CATs)" / Rules for Writer Programs
    ¶ "PROPs may only appear inside LISTs"), PROPs before any nested
    group (¶ "all the PROPs must appear before any of the FORMs or
    nested LISTs and CATs"), at most one PROP per FORM type (¶ "A LIST
    may have at most one PROP of a FORM type"). §3 FILLER children are
    walked past without being surfaced ("chunks that fill space but
    have no meaningful contents"); reserved-future-version IDs and bare
    data ckIDs are rejected since the grammar admits no other child.
    GroupKind::Form is refused outright — §4's production admits
    LocalChunk children whose IDs are form-type-specific, so FORM
    bodies stay with the per-form walkers. [chunk::prop_for_form_type]
    is the §5 ¶ "Here are the shared properties for FORM type
    <FormType>" lookup joining a FORM type against the parsed child
    list. Nine new unit tests in src/chunk.rs (worked-example LIST
    decode, PROP-after-group rejection, duplicate-FormType rejection,
    PROP-in-CAT rejection, FORM-kind rejection, data/future-version
    ckID rejection, FILLER skip, bounds checks, odd-size pad handling)
    plus 3 integration tests in tests/group_children.rs building the
    §5 worked example (LIST { PROP TEXT { FONT } FORM TEXT … }), a
    CAT of heterogeneous FORMs with the blank JJJJ contents ID,
    and a LIST nested inside a CAT walked recursively — all end-to-end
    from probe_top_level_group through the child walk. Doc reference:
    docs/image/iff/ea-iff-85.txt §5 "LISTs, CATs, and Shared
    Properties" lines 842–986; §6 reader/writer rules lines 1119–1196;
    Appendix A grammar lines 1244–1253.

  • EA IFF 85 §3 universally-reserved ckID classifier
    (chunk::ReservedId).
    §3 ¶ "the following ckIDs are universally
    reserved to identify chunks with particular IFF meanings: 'LIST',
    'FORM', 'PROP', 'CAT ', and ' '. […] The IDs 'LIS1' through
    'LIS9', 'FOR1' through 'FOR9', and 'CAT1' through 'CAT9' are
    reserved for future 'version number' variations" enumerates the
    full reserved set every conforming IFF reader must recognise. The
    new [chunk::ReservedId] enum maps any 4-byte ckID to one of
    Group(GroupKind) (the three group-chunk IDs already surfaced
    by [chunk::GroupKind]), Prop (the §3 PROP property-set group
    that only appears as the first child of a LIST), Filler (the
    four-space ID for "chunks that fill space but have no meaningful
    contents"), or ReservedFuture { parent, digit } (the
    twenty-seven LIS1..9 / FOR1..9 / CAT1..9 version-number
    variants, carrying back the parent group kind and the ASCII
    digit). Two new module-level constants — [chunk::FILLER_ID]
    (b" ") and [chunk::PROP_ID] (b"PROP") — give the two
    previously-unnamed reserved IDs a typed home alongside the
    existing [chunk::GROUP_FORM] / [chunk::GROUP_LIST] /
    [chunk::GROUP_CAT] constants. [chunk::ReservedId::classify]
    is the free entry point; [chunk::ChunkHeader::reserved] and
    [chunk::ChunkHeader::is_filler] are the convenience accessors
    the per-form walkers can route on. Three predicates
    ([ReservedId::is_group], [is_filler], [is_reserved_future])
    cover the §3-aware fall-through cases, and
    [ReservedId::all_reserved_ids] enumerates the full set in
    spec-listed order. The classifier rejects every FORM-local
    property surfaced elsewhere in the crate (BMHD / CMAP / BODY /
    CAMG / GRAB / DEST / SPRT / CRNG / CCRT / DRNG / SHAM / PCHG /
    VHDR / CHAN / ANNO / NAME / AUTH / COMM / SSND / MARK / INST /
    COMT / AESD / APPL / MIDI / SAXL / FVER / ANHD / DLTA) so the
    data-chunk dispatch path is unaffected. Sixteen new tests cover
    the classifier surface — 9 unit tests in src/chunk.rs
    (reserved_id_classifies_three_groups,
    reserved_id_classifies_prop_and_filler,
    reserved_id_classifies_all_twenty_seven_future_versions,
    reserved_id_rejects_non_reserved_ckid,
    reserved_id_rejects_boundary_version_digits,
    reserved_id_predicates,
    all_reserved_ids_covers_every_id_classify_recognises,
    chunk_header_reserved_and_is_filler,
    filler_chunk_walks_past_body_without_decoding) plus 5
    integration tests in tests/reserved_ids.rs covering the
    per-id-round-trip, the constant surfaces, an end-to-end
    FILLER-before-FORM stream walk via [chunk::skip_chunk_body],
    the future-version parent-group routing, and the FORM-local
    rejection sweep. Doc reference: docs/image/iff/ea-iff-85.txt
    §3 "Chunks" lines 524–531; Appendix A C macros
    ID_FORM/ID_LIST/ID_PROP/ID_CAT/ID_FILLER lines
    1230–1234. The current spec defines no decoder for the
    reserved-future-version IDs; the predicate is offered so callers
    can route them to a versioning-aware fall-back path instead of
    misclassifying them as ordinary data chunks. The §3 paragraph
    cites "23 chunk IDs" as the magic number callers must account
    for, but the explicit enumeration in the same paragraph spells
    out 5 base + 3×9 = 32 distinct IDs; the enum and the helper
    match the explicit list, with the discrepancy flagged in the
    [all_reserved_ids] doc comment.

  • Top-level group probe primitive ([chunk::probe_top_level_group]

    • [chunk::read_top_level_group]). EA IFF 85 §6 restricts a
      conforming file to a single FORM/LIST/CAT group at offset 0
      whose first 12 bytes encode kind + ckSize + the 4-byte inner
      type ID (FormType for FORM/PROP, ContentsType for LIST/CAT).
      Today the four forms this crate handles (8SVX / ILBM / ANIM /
      AIFF/AIFC) each open with a near-identical hand-rolled magic
      check (&buf[0..4] == b"FORM" && &buf[8..12] == b"<type>"); the
      new primitive lifts that into one tested entry point that returns a
      typed [chunk::TopLevelGroup] (with [chunk::GroupKind] +
      inner_type 4CC + size + [TopLevelGroup::declared_total_len])
      and surfaces "starts with a non-group FourCC" as
      Error::invalid("IFF: not a top-level group chunk (got XXXX)")
      so callers can fall through cleanly to a non-IFF container probe.
      Cross-form regression coverage lives in
      tests/top_level_group_probe.rs (5 envelope checks, one per
      shipped form plus a Cursor round-trip).
  • ILBM PCHG typed-header surface. The PCHG (Palette CHanGe)
    chunk now exposes its 20-byte fixed-layout header via a typed
    [Pchg::header] accessor returning an Option<PchgHeader>
    carrying every field the parser reads off the wire:
    [PchgHeader::compression], [PchgHeader::flags],
    [PchgHeader::start_line], [PchgHeader::line_count],
    [PchgHeader::changed_lines], [PchgHeader::min_reg],
    [PchgHeader::max_reg], [PchgHeader::max_changes], and
    [PchgHeader::total_changes]. A companion [PchgKind]
    (Small / Big) enum decodes the change-record encoding
    selector out of the flags word; [Pchg::kind] is the
    convenience accessor (zero-flag bodies report Small per the
    annex's documented default), and [PchgHeader::is_compressed]
    flags the Compression == 1 (Huffman-compressed-records)
    variant — that wire shape still isn't decoded into per-line
    changes, but it now surfaces through the typed header for
    callers needing to fall back to [Pchg::raw]. The new
    [Pchg::derive_header_hints] re-derives the four annex hint
    fields (changed_lines, min_reg, max_reg, max_changes,
    total_changes) directly from the decoded [Pchg::lines],
    matching the annex's canonical semantics including the
    "empty-PCHG ⇒ MinReg == MaxReg == 0" rule, and
    [Pchg::header_matches_payload] gates the on-wire header
    against the canonical re-derivation so editors can flag hint
    drift after modifying the change list before re-encoding.
    Fifteen new tests in tests/ilbm_pchg_header.rs cover the
    Small + Big header round-trips, both payloads decoded through
    the typed accessor, the four-field re-derivation against
    Small/Big/empty PCHGs, the consistency check on both
    round-trip and deliberately stale-hint inputs (incorrect
    TotalChanges / MinReg), the zero-flag default-to-Small
    rule, the is_compressed flag on a synthetic
    Compression == 1 body, and the short-raw "header is
    None" path for hand-crafted Pchg values. No behaviour
    change for existing call sites; the helpers are pure
    additions on top of the existing parser. Doc reference:
    header-layout comment in src/ilbm.rs PCHG section (the
    annex layout was already documented inline in the parser;
    this round only surfaces the fields the parser already reads).

  • ILBM Sham typed row-palette accessors. The Sliced-HAM
    per-scanline palette descriptor now exposes typed accessors that
    match the [Pchg::palette_at_line] shape: [Sham::row_palette(y)]
    borrows the 16-entry RGB888 palette for scanline y without
    allocating (returning None past the parsed-row count, so callers
    can spot the explicit-vs-padded boundary on hand-built Sham
    values), [Sham::palette_at_line(base, y)] returns an owned
    16-entry palette mirroring the Pchg accessor's shape (SHAM row
    verbatim when present; base truncated/padded to 16 entries
    otherwise — the fallback always emits a 16-entry CMAP suitable for
    feeding directly into [expand_ham_row]), and
    [Sham::is_empty] / [Sham::rows] surface the
    "any explicit rows?" / "how many?" predicates without forcing
    callers to reach into the palettes field. Three new tests in
    tests/ilbm_round2.rs exercise the explicit-row path, the
    past-end fallback path (both full-length and short base
    palettes — the helper pads with [0, 0, 0] when base has fewer
    than 16 entries), the empty-chunk path, and the parser's
    short-chunk row-padding behaviour as observed through the typed
    accessors. No behaviour change for existing call sites; the
    helpers are pure additions and Sham::palettes remains
    publicly readable.

  • cargo-fuzz harness with three libFuzzer targets. New fuzz/
    subdirectory wires the standard cargo-fuzz layout into the crate
    with three targets covering the highest-risk parser surfaces:
    aiff_decode (the top-of-stack
    aiff::demuxer::AiffDemuxer::from_bytes FORM AIFF / AIFC walker),
    anim_decode (the anim::parse_anim FORM ANIM walker with its
    op-0 / op-5 / op-7 delta decoders), and pchg_parse (the
    failure-mode-dense ilbm::Pchg::parse PCHG palette-change-per-line
    chunk decoder). The contract under fuzz is purely that each call
    returns a Result and never panics / aborts / OOMs, regardless of
    how malformed the input is. Run with
    cargo +nightly fuzz run <target> from the crate root; see the
    README "Fuzzing" section for the full target catalogue and the
    failure-mode notes each target was authored to keep honest.

  • AIFF-C §14 chunk-precedence surface. A new aiff::ChunkClass
    enum ranks the §3.1 / §4..§13 chunk classes per the §14 ordering
    ("Highest precedence Common Chunk … Lowest precedence Application
    Specific Chunk"). The enum's repr(u8) is the precedence rank
    (FVER = 0; the §14 ranked block runs 1..=13 from COMM to
    APPL), with [ChunkClass::rank] returning that rank and
    [ChunkClass::higher_precedence_than] giving the §14 ¶ "the
    loop points in the Instrument Chunk take precedence over
    conflicting loop points found in the MIDI Data Chunk" predicate
    in one call. [ChunkClass::ck_id] returns the on-wire 4-byte
    ckID (with the §13.0 ¶ "the 'c' is lowercase and there is a
    space [0x20] after the close parenthesis" Copyright case
    honoured exactly as b"(c) "), and
    [ChunkClass::all_in_precedence_order] enumerates the full
    fourteen-entry table for callers that want to iterate by §14
    rank. The matching [aiff::Form::precedence_order] helper
    walks a parsed Form and emits a Vec<ChunkClass> of the
    classes the FORM actually contains in §14 order (regardless of
    the on-wire chunk layout — §4 of the staged AIFF-AIFC layout
    doc is explicit that chunk order inside a FORM is unspecified);
    multi-instance classes (§8.0 SAXL, §10.0 MIDI, §12.0
    APPL, §13.0 ANNO) appear once per instance and preserve the
    document-order semantics §14 ¶ "Annotation Chunk[s] -- in the
    order they appear in the FORM" requires. The companion
    [aiff::Form::highest_precedence_class] returns the top entry
    (always Some because COMM is mandatory) and identifies an
    AIFF-C FORM with a §3.1 FVER chunk as FormatVersion-led.
    Eight unit tests in src/aiff/precedence.rs and seven
    integration tests in tests/aiff_precedence.rs cover the
    rank-vs-spec-order invariant, the ckID round-trip, the
    §13.0 Copyright (c) byte literal, the §14 worked example
    Instrument-outranks-MIDI, the §14 "Common always outranks" /
    "APPL always loses" envelope, irreflexivity of
    higher_precedence_than, Ord agreement with rank, a
    minimal AIFF FORM ordering, a minimal AIFF-C FORM with
    FVER ordering, a full thirteen-class FORM with deliberately
    scrambled on-wire layout, multi-instance multiplicities for
    ANNO / APPL / MIDI / SAXL, and the
    highest_precedence_class switch from Common to
    FormatVersion when FVER is present.
    Doc reference: docs/audio/aiff/aiff-c.txt §14 ("Chunk
    Precedence"), lines 1209–1259 of the staged spec text.

  • ILBM SPRT (sprite-precedence) chunk surfacing. parse_ilbm
    now lifts the SPRT property (ILBM supplement §2.7) into a
    structured [ilbm::Sprt] (single UWORD precedence) and
    exposes it through IlbmImage::sprt. The supplement defines
    the chunk as "presence flags the ILBM as intended as a sprite"
    with a UWORD SpritePrecedence where "0 is the highest"
    (foremost). The Appendix A grammar slots SPRT between [DEST]
    and [CAMG] (BMHD [CMAP] [GRAB] [DEST] [SPRT] [CAMG]); §6
    also notes the property chunks "may actually be in any order
    but all must appear before the BODY chunk". encode_ilbm emits
    the two-byte payload immediately after DEST, before BODY.
    A const sentinel [ilbm::Sprt::FOREMOST] = 0 plus a
    [ilbm::Sprt::is_foremost] predicate surface the §2.7
    "0 is the highest" convention without forcing callers to
    remember the bare-int sentinel. The full unsigned-16 range
    0..=0xFFFF round-trips. Eleven new tests in tests/ilbm_sprt.rs
    cover the two-byte wire layout, foremost-zero handling,
    max-UWORD handling, short-payload rejection, the implicit
    no-SPRT default, the grammar-ordering invariant (DEST precedes
    SPRT precedes BODY), full-property-set coexistence with GRAB +
    DEST + CAMG, and parse → encode → parse byte-stability. Doc
    reference: docs/image/iff/ilbm.txt §2.7 + Appendix A.

  • ILBM DEST (destination-merge) chunk surfacing. parse_ilbm
    now lifts the DEST property (ILBM §2.6) into a structured
    [ilbm::Dest] (depth / pad1 / plane_pick / plane_on_off /
    plane_mask) and exposes it through IlbmImage::dest. The §6
    grammar fixes the property order as BMHD [CMAP] [GRAB] [DEST] [SPRT] [CAMG] ... BODY; encode_ilbm honours that slot, writing
    an eight-byte payload (UBYTE depth, UBYTE pad1, three big-endian
    UWORD masks) right after GRAB. A helper
    [ilbm::Dest::pick_count_matches_depth] surfaces the §2.6 soft
    expectation "the number of '1' bits should equal nPlanes" without
    rejecting non-conforming inputs at parse time (the spec frames the
    equality as an expectation, not a requirement). Round-trip is
    byte-stable, including a non-zero pad1 byte (§2.6: "unused; for
    consistency put 0 here"). Eight new tests in tests/ilbm_dest.rs
    cover the wire layout, the implicit (1 << nPlanes) - 1 default
    case, mismatch detection, and the FORM-envelope ordering invariant.

  • AIFF / AIFF-C SAXL (Sound Accelerator) chunk surfacing. The
    FORM walker now decodes every SAXL chunk
    (docs/audio/aiff/aiff-c.txt §8.0 + Appendix D) into a structured
    [aiff::SaxelChunk] (with a Vec<aiff::Saxel> of (id, data)
    pairs) and exposes them through [aiff::Form::saxels] in document
    order. §8.0 explicitly permits "any number of Saxel Chunks" per
    FORM AIFC (and "Multiple Saxel Chunks are allowed in a single FORM
    AIFC file"), so the surface is a Vec rather than an Option
    matching how §10.0 MIDI and §12.0 APPL handle the
    "any-number-per-FORM" rule. The chunk body is preserved verbatim
    as a raw byte stream — Appendix D ¶ "saxelData contains the
    specific sound accelerator data which is compression-type specific"
    and §8.0 ¶ "Under Construction" / Appendix D ¶ "Caution" emphasise
    the mechanism remained a "rough proposal" in the 1991 draft, so
    this crate does not interpret data against any particular
    decompressor's state-priming convention. Lightweight observers
    [aiff::Saxel::len] / [aiff::Saxel::is_empty] cover the common
    "what's the priming-data length?" inspection without re-parsing.
    Lookups are provided in both directions: [aiff::Saxel::resolve_marker]
    joins a saxel's id against a supplied [aiff::MarkerChunk] per
    §8.0 ¶ "id identifies the marker for which the sound accelerator
    data is to be used" (returning None when the id isn't a positive
    MarkerId per §6.0 or no marker with that id is present), and
    [aiff::SaxelChunk::by_marker_id] scans the chunk's saxel list
    for a matching id. The per-saxel pad byte (Appendix D ¶ "The data
    must be padded with a byte at the end as needed to make it an even
    number of bytes long. This pad byte, if present, is not included
    in size.") is honoured on parse and written on encode; end-of-chunk
    pad on the last saxel is tolerated as either present or absent,
    mirroring the MARK / COMT pstring-tail tolerance for legacy
    encoders that elided the trailing pad. The matching
    [aiff::write_saxel_chunk] write-side helper completes the
    round-trip story; an encoder building a FORM AIFC can now emit
    every chunk class the read path surfaces (MARK, INST, COMT, AESD,
    APPL, MIDI, SAXL + the four §13.0 text chunks). New
    tests/aiff_saxel.rs covers single-chunk + multiple-chunk-in-FORM

    • empty-Vec-when-absent surfacing, the empty-saxel-list intra-chunk
      case, odd-size saxelData per-saxel pad handling, the write-side
      round-trip, resolve_marker against a FORM-level MARK chunk plus
      the zero/negative-id MarkerId-sentinel rejection per §6.0,
      by_marker_id lookup, and SAXL coexisting with MARK + COMT + APPL
    • MIDI + ANNO in a single FORM. Internal saxel.rs tests exercise
      the same surfaces against the lower-level helpers (empty list,
      single-saxel even/odd data, empty-data, multiple saxels in
      document order with mixed pad, by_marker_id happy-path, three
      truncation classes, end-of-chunk pad tolerance, write-helper
      round-trip, write-helper empty-chunk, byte-for-byte write layout
      match, and write document-order preservation). Doc reference:
      docs/audio/aiff/aiff-c.txt §8.0 + Appendix D.
  • AIFF / AIFF-C §13.0 text chunks (NAME / AUTH / (c) / ANNO).
    The FORM walker now decodes the four §13.0 Text Chunks of
    docs/audio/aiff/aiff-c.txt into a structured [aiff::TextChunk]
    (with a [aiff::TextKind] discriminant tagging which of the four
    ckIDs the chunk came from) and surfaces them through new
    [aiff::Form::name] / [aiff::Form::author] /
    [aiff::Form::copyright] / [aiff::Form::annotations] fields.
    Per §13.0 ¶ "No more than one Name / Author / Copyright Chunk may
    exist within a FORM AIFC", NAME / AUTH / (c) are
    duplicate-checked singletons (a second occurrence raises
    [aiff::AiffError::DuplicateChunk]); per §13.0 ¶ "Any number of
    Annotation Chunks may exist within a FORM AIFC", ANNO is
    accumulated into a Vec<TextChunk> in document order, matching how
    §10.0 MIDI and §12.0 APPL handle the "any-number-per-FORM" rule.
    The text body is preserved byte-for-byte (§13.0 ¶ "text contains
    pure ASCII characters. It is neither a pstring nor a C string");
    [aiff::TextChunk::as_str] returns a borrowed &str for valid
    UTF-8 bodies and [aiff::TextChunk::as_string_lossy] decodes the
    full body with U+FFFD substitution so MacRoman / Latin-1 bodies
    produced by older encoders are still salvageable. Empty text
    bodies (ckDataSize == 0) are accepted — §13.0 places no
    minimum on the text field. A matching [aiff::write_text_chunk]
    write-side helper completes the round-trip story; an encoder
    building a FORM AIFF / AIFC can now emit every §13.0 ckID
    alongside the COMT / MARK / INST / AESD / APPL / MIDI write paths
    added in earlier rounds. The (c) ckID uses the canonical
    four-byte ASCII form 0x28 0x63 0x29 0x20 per §13.0 ¶ "the 'c' is
    lowercase and there is a space [0x20] after the close parenthesis";
    the spec uses the round-bracket character itself as the ckID glyph
    standing in for ©. New tests/aiff_text_chunks.rs covers
    standalone parse/write round-trips, the four-kind happy path with
    one file carrying NAME + AUTH + (c) + 2 × ANNO, three
    duplicate-chunk rejection paths, a document-order check for ANNO,
    empty-body acceptance, odd-length pad-byte round-trip, and the
    (c) ckID variant-resistance check. Internal form.rs tests
    exercise the same surfaces against the lower-level helpers.

  • AIFF / AIFF-C MIDI (MIDI Data) chunk surfacing. The FORM
    walker now decodes every MIDI chunk (docs/audio/aiff/aiff-c.txt
    §10.0) into a structured [aiff::MidiDataChunk] and exposes them
    through [aiff::Form::midi] in document order. §10.0 explicitly
    permits "any number of MIDI Data Chunks" per FORM AIFC, so the
    surface is a Vec rather than an Option. The chunk body is
    preserved verbatim as a raw MIDI byte stream — the spec calls
    MIDIdata "a stream of MIDI data" and imposes no internal
    framing, so an MMA Standard MIDI File-style decode (MThd / MTrk
    / variable-length quantity / running status) remains the job of
    the oxideav-midi sibling crate. Lightweight observers
    [aiff::MidiDataChunk::len] /
    [aiff::MidiDataChunk::is_empty] /
    [aiff::MidiDataChunk::is_sysex] cover the common "is this a
    SysEx patch dump or something else?" classification without
    re-parsing (is_sysex matches the leading 0xF0 status byte
    the spec calls out as the chunk's "primary purpose"). The
    matching [aiff::write_midi_chunk] write-side helper completes
    the round-trip story for encoders building a FORM AIFC. Empty
    chunks (ckDataSize == 0) are accepted per §10.0 ¶ "MIDIData
    contains a stream of MIDI data." — the spec sets no minimum
    body length. New tests/aiff_optional_chunks.rs cases
    (surfaces_single_midi_chunk,
    surfaces_multiple_midi_chunks_in_document_order,
    surfaces_zero_midi_chunks_as_empty_vec,
    midi_chunk_with_odd_length_round_trips_through_chunk_walker,
    midi_chunk_write_helper_roundtrips,
    empty_midi_chunk_is_accepted,
    midi_chunk_coexists_with_other_optional_chunks) exercise the
    full surface plus 6 module-level unit tests
    (parses_empty_chunk, preserves_byte_stream_verbatim,
    is_sysex_false_when_first_byte_is_not_f0, write_round_trips,
    write_round_trips_empty_chunk, classifies_odd_length_stream,
    accepts_large_body). Doc reference:
    docs/audio/aiff/aiff-c.txt §10.0 MIDI DATA CHUNK.

  • ANIM op-7 (Short / Long Vertical Delta) encoder. New
    [anim::encode_op7_body] builds the 64-byte pointer table + 8
    per-plane opcode lists + 8 per-plane data lists from a prev /
    cur planar-frame pair, picking Skip / Same / Uniq ops per column
    to minimise byte cost (Same for runs ≥ 2 items, Uniq otherwise,
    Skip for unchanged runs). [anim::encode_anim_op7] wraps it into a
    full FORM/ANIM file with leading FORM ILBM (seed) + per-delta
    FORM ILBM { ANHD(op=7, bits=long_data?1:0) + DLTA } frames. The
    short (2-byte items, ANHD.bits bit 0 cleared) and long (4-byte
    items, bit set) variants both round-trip through the in-tree
    [anim::parse_anim] decoder. The parser was extended to accept the
    DLTA chunk id alongside BODY so op-7 / op-5 / op-0 streams all
    decode via the same path. New tests/anim_op7_encode.rs exercises
    identical-frame elimination, sparse and full-change deltas, short
    vs long mode, and rejects long_data=true with row_bytes
    unaligned to 4. Doc reference: docs/image/iff/anim.txt Appendix
    Anim7 §#.# (Wolfgang Hofer, 23.6.92).

  • AIFF / AIFF-C COMT (Comments) chunk parsing. The FORM walker
    now decodes the COMT chunk into a structured
    [aiff::CommentsChunk] surfaced through [aiff::Form::comments]
    per docs/audio/aiff/aiff-c.txt §7.0. Each comment carries a
    timestamp (seconds since 1904-01-01 UTC, the Mac epoch), a
    MarkerId (0 = comment is not linked to any marker, otherwise
    references the FORM's MARK entry), and a UTF-8-lossy decoded text
    body. The accompanying [aiff::Comment::linked_marker] returns
    Option<i16> so callers can distinguish linked vs free-floating
    comments without checking the marker field directly, and
    [aiff::Comment::resolve_marker] joins the linkage against a
    supplied [aiff::MarkerChunk]. At most one COMT per FORM per
    §7.0 — duplicates are rejected as AiffError::DuplicateChunk ("COMT"). The per-comment text pad byte rule (pad to even byte
    count, pad NOT included in count) is honoured with the same
    end-of-buffer tolerance as MARK.

  • AIFF / AIFF-C AESD (Audio Recording) chunk parsing. The FORM
    walker now decodes the AESD chunk into a structured
    [aiff::AesdChunk] surfaced through [aiff::Form::aesd] per
    §11.0. The 24-byte AES channel-status block is preserved verbatim
    in status; [aiff::AesdChunk::emphasis] extracts the 3-bit
    recording-emphasis field from byte 0 bits 2..=4 the spec calls out
    as "of general interest". At most one AESD per FORM per §11.0;
    duplicates rejected as AiffError::DuplicateChunk("AESD"). The
    spec's "ckDataSize is always 24" invariant is enforced — shorter
    is Truncated, longer is
    InvalidValue { what: "AESD ckSize", ... }.

  • AIFF / AIFF-C APPL (Application Specific) chunk parsing. The
    FORM walker now decodes every APPL chunk into an
    [aiff::ApplicationChunk] and collects them into
    [aiff::Form::applications] in document order (§12.0 explicitly
    permits any number of APPL chunks per FORM, unlike the other
    optional chunks). [aiff::ApplicationChunk::dialect] classifies
    the four-byte applicationSignature into the three §12.0
    dialects (pdos Apple II, stoc non-Apple, anything else =
    Macintosh); [aiff::ApplicationChunk::application_name] decodes
    the leading Pascal-string application name for pdos / stoc
    chunks (Macintosh dialect carries raw bytes with no required
    leading structure) and [aiff::ApplicationChunk:: payload_after_name] returns the slice after the name, stepping
    by exactly 1 + length_byte (§12.0 specifies chunk-level
    pad-to-even on the whole APPL but not an inner pad after the
    leading pstring).

  • MARK and INST write-side encoders. Encoders building AIFF /
    AIFF-C files can now construct the exact wire body for these
    chunks via [aiff::write_marker_chunk] / [aiff:: write_instrument_chunk]. The marker writer preserves document
    order, honours the §6.0 pstring pad-to-even discipline, and caps
    oversize names / lists at the wire field widths (u8 length, u16
    numMarkers); the instrument writer emits exactly 20 bytes in spec
    field order and accepts arbitrary Loop substructures. Companion
    [aiff::write_comments_chunk] / [aiff::write_appl_chunk] /
    [aiff::write_aesd_chunk] cover the other newly-surfaced
    chunks. All five round-trip through the FORM-level [aiff::parse]
    walker (verified by tests/aiff_optional_chunks.rs).

  • AIFF / AIFF-C INST (Instrument) chunk parsing. The FORM
    walker now decodes the INST chunk into a structured
    [aiff::InstrumentChunk] surfaced through
    [aiff::Form::instrument]. Every wire field is preserved:
    baseNote / lowNote / highNote (MIDI 0..=127),
    detune (cents -50..=+50), lowVelocity / highVelocity
    (1..=127), signed-dB gain, plus the two Loop
    substructures (sustainLoop, releaseLoop) which carry a decoded
    [aiff::PlayMode] (NoLooping / ForwardLooping /
    ForwardBackwardLooping) and the two MarkerIds referencing the
    FORM's MARK chunk. The parser enforces every §9 invariant — at
    most one INST chunk per FORM (rejected as
    AiffError::DuplicateChunk("INST")), exact 20-byte ckDataSize
    ("ckDataSize is always 20" — shorter is Truncated, longer is
    InvalidValue { what: "INST ckSize", ... }), MIDI-note range,
    detune range, velocity range, and a known playMode. The
    accompanying [aiff::InstrumentChunk::resolve_sustain_loop] /
    [aiff::InstrumentChunk::resolve_release_loop] helpers join the
    loop endpoints against the FORM's [aiff::MarkerChunk] and apply
    §9 ¶ "beginLoop and endLoop": "The begin position must be less
    than the end position so the loop segment will have a positive
    length. [If this is not the case, then ignore this loop segment.
    No looping takes place.]" — returning None whenever
    playMode == None, an endpoint id isn't a positive marker id,
    either id isn't present in the supplied MARK list, or the begin
    marker's frame position isn't strictly less than the end marker's.
    22 new tests across the aiff::instrument, aiff::form and
    tests/aiff_instrument.rs surfaces.

  • AIFF / AIFF-C MARK (Marker) chunk parsing. The FORM walker
    now decodes the MARK chunk into a structured
    [aiff::MarkerChunk] surfaced through [aiff::Form::markers].
    Each [aiff::Marker] carries the spec's id (big-endian i16,

    0, unique within the FORM), position (big-endian u32 sample
    frame; for compressed AIFF-C streams the spec defines this in
    expanded-domain frames per §6.0 ¶3), and pstring name
    (length-prefixed with pad-to-even total). The parser enforces
    every AIFF-C §6.0 invariant — at most one MARK chunk per FORM
    (rejected as AiffError::DuplicateChunk("MARK")), positive
    MarkerId (rejected as AiffError::InvalidValue { what: "MarkerId", ... }), and unique-id-within-chunk (rejected as
    AiffError::DuplicateMarkerId). Markers are exposed in document
    order; MarkerChunk::by_id provides the typical
    lookup-by-id needed when the AIFF-C INST (instrument) chunk
    references loop endpoints by marker id. 17 new tests across the
    aiff::marker, aiff::form and tests/aiff_markers.rs
    surfaces.

Changed

  • aiff::Form gains a markers: Option<MarkerChunk> field —
    None when the FORM has no MARK chunk, Some(MarkerChunk{ markers: vec![] }) for an empty marker list (the encoder
    declared markers but had none).
  • aiff::Form gains an instrument: Option<InstrumentChunk>
    field — None when the FORM has no INST chunk, Some(_)
    otherwise. New since the previous Unreleased entry.