// component_state_io unit tests — the codec's whole suite: the current envelope + params
// payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
// version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
// -> the one parameter set, adopting zone one). The codec's own executable is also the
// STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
// velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
// link is a regression.

#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/envelopes.h"    // AhdEnvelope (header-only: the codec
                                                        // links no engine, and this adds none)
#include "../src/core/instrument/engine/master_gain.h"  // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/instrument/engine/time_stretch.h"  // the rate bounds the codec clamps to
#include "../src/core/util/curve_law.h"              // kCurveNeutral (the migration neutral)

#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <vector>

using namespace reasampler;
using namespace reasampler::instrument::map;
namespace note = reasampler::instrument::note;  // the bake Hold's ladder

static int failures = 0;

#define CHECK(cond)                                                    \
    do {                                                               \
        if (!(cond)) {                                                 \
            std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
            ++failures;                                                \
        }                                                              \
    } while (0)

// --- A writer for the RETIRED zone-list payloads -----------------------------
//
// The shipping codec no longer EMITS a zone list, so the migration ladder can only be
// tested against bytes this suite lays out itself. These helpers mirror the frozen v1..v7
// record shapes documented in component_state_io.h; if they and the reader ever disagree,
// the migration tests below fail — which is the point.

namespace legacy {

static void u8v(std::vector<std::uint8_t>& out, std::uint8_t v) { out.push_back(v); }

static void u32v(std::vector<std::uint8_t>& out, std::uint32_t v) {
    for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
}

static void i64v(std::vector<std::uint8_t>& out, std::int64_t v) {
    const auto u = static_cast<std::uint64_t>(v);
    for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((u >> (8 * i)) & 0xFF));
}

static void f64v(std::vector<std::uint8_t>& out, double v) {
    std::uint64_t bits = 0;
    std::memcpy(&bits, &v, sizeof(bits));
    for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((bits >> (8 * i)) & 0xFF));
}

static void strv(std::vector<std::uint8_t>& out, const std::string& s) {
    u32v(out, static_cast<std::uint32_t>(s.size()));
    out.insert(out.end(), s.begin(), s.end());
}

// One zone's worth of the retired per-zone record, in the v7 (fullest) shape.
struct Zone {
    std::string sampleId;
    int lowNote = 0;
    int highNote = 127;
    int rootOverride = -1;          // < 0 = absent
    bool hasLoopOverride = false;
    // The override's OWN hasLoop bit — distinct from hasLoopOverride above. An override can
    // itself say "disable the loop" (loopOverrideHasLoop = false): the field is present but
    // sets no sustain loop, as opposed to no override at all (the sample's own intrinsic loop
    // applies). Defaults true so existing callers that only set hasLoopOverride keep writing
    // the enabled-loop shape they always did.
    bool loopOverrideHasLoop = true;
    std::int64_t loopStart = 0;
    std::int64_t loopEnd = 0;
    std::int64_t startPoint = -1;   // < 0 = absent
    bool trigger = false;
    double holdSeconds = 0.0;
    double lengthFraction = 1.0;
    std::int64_t fadeIn = 0;
    std::int64_t fadeOut = 0;
    bool preserve = false;
    bool pitchEnvEnabled = false;
    double pitchAttack = 0.0;
    double pitchDecay = 0.0;
    double peakSemis = 0.0;
    double attackSeconds = 0.003;
    double decaySeconds = 0.0;
    double sustainLevel = 1.0;
    double releaseSeconds = 0.060;
    double keyTrack = 1.0;
    std::vector<VelocityPoint> curve;  // empty -> the flat endpoints
};

// Everything after a zone's id and key range — which is EXACTLY the whole v8 single record,
// so the two shapes are written from one place here just as the codec writes them from one
// place (putOverrides + the shared play tail).
static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
    u8v(out, z.rootOverride >= 0 ? 1 : 0);
    if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
    if (pv >= 2) {
        u8v(out, z.hasLoopOverride ? 1 : 0);
        if (z.hasLoopOverride) {
            u8v(out, z.loopOverrideHasLoop ? 1 : 0);
            i64v(out, z.loopStart);
            i64v(out, z.loopEnd);
        }
        u8v(out, z.startPoint >= 0 ? 1 : 0);
        if (z.startPoint >= 0) i64v(out, z.startPoint);
    }
    if (pv >= 5) {
        u8v(out, z.trigger ? 1 : 0);
        f64v(out, z.holdSeconds);
        f64v(out, z.lengthFraction);
        i64v(out, z.fadeIn);
        i64v(out, z.fadeOut);
        u8v(out, z.preserve ? 1 : 0);
        u8v(out, z.pitchEnvEnabled ? 1 : 0);
        f64v(out, z.pitchAttack);
        f64v(out, z.pitchDecay);
        f64v(out, z.peakSemis);
        f64v(out, z.attackSeconds);
        f64v(out, z.decaySeconds);
        f64v(out, z.sustainLevel);
        f64v(out, z.releaseSeconds);
    }
    if (pv >= 6) f64v(out, z.keyTrack);
    if (pv >= 7) {
        const std::vector<VelocityPoint> pts =
            z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
        u32v(out, static_cast<std::uint32_t>(pts.size()));
        for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.value); }
    }
}

static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
    strv(out, z.sampleId);
    u32v(out, static_cast<std::uint32_t>(z.lowNote));
    u32v(out, static_cast<std::uint32_t>(z.highNote));
    putRecordBody(out, z, pv);
}

// The envelope fields, in wire order. A builder at version N emits only the prefix fields
// version N carried, so each lift can be asserted against a blob shaped exactly as that
// version's writer produced.
struct Envelope {
    std::uint32_t version = kComponentStateVersion;
    std::uint8_t modeByte = 0;                  // v4+   0 mono / 1 stereo
    std::int64_t assignGeneration = 0;          // v5+
    std::uint8_t previewVelocity = kPreviewVelocityDefault;  // v6+
    std::uint8_t voiceCount = static_cast<std::uint8_t>(kDefaultVoiceCount);  // v7+
    std::uint8_t voiceMode = 0;                 // v7+   0 poly / 1 mono
    std::uint8_t monoTrigger = 0;               // v7+   0 retrigger / 1 legato
    double masterGain = 1.0;                    // v8+
    std::uint8_t channelModeExplicit = 0;       // v9+
    std::string instanceGuid;                   // v11+
    std::string selectionId;                    // v3+
};

// A complete envelope at `env.version` whose tail is a RETIRED zone-list payload at version
// `pv`. `env.selectionId` is the envelope's own stored pick — which the adoption rule
// overrides when the payload carries a zone. The sample-refs table (v10+) is always empty:
// its own shape is covered by the round-trip test.
static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
                                                   const std::vector<Zone>& zones,
                                                   std::uint32_t pv) {
    std::vector<std::uint8_t> out;
    const std::uint32_t v = env.version;
    u32v(out, v);
    if (v >= 4) u8v(out, env.modeByte);
    if (v >= 5) i64v(out, env.assignGeneration);
    if (v >= 6) u8v(out, env.previewVelocity);
    if (v >= 7) { u8v(out, env.voiceCount); u8v(out, env.voiceMode); u8v(out, env.monoTrigger); }
    if (v >= 8) f64v(out, env.masterGain);
    if (v >= 9) u8v(out, env.channelModeExplicit);
    if (v >= 10) u32v(out, 0);                  // sample-refs: empty table
    if (v >= 11) strv(out, env.instanceGuid);
    if (v >= 3) strv(out, env.selectionId);     // v2 was zones-only, no selection
    if (pv >= 2) {
        u32v(out, kParamsFormatMarker);
        u32v(out, pv);
    }
    u32v(out, static_cast<std::uint32_t>(zones.size()));
    for (const Zone& z : zones) putZone(out, z, pv);
    return out;
}

// The CURRENT envelope carrying a payload-v8 SINGLE RECORD — the shape immediately before the
// filter tail. The shipping writer only emits v9, so a v8 blob can come from nowhere but
// bytes laid out here, which is what makes the off/neutral filter lift provable rather than
// assumed.
static std::vector<std::uint8_t> envelopeWithV8Record(const std::string& selectionId,
                                                      const Zone& record) {
    Envelope env;
    env.selectionId = selectionId;
    std::vector<std::uint8_t> out;
    u32v(out, env.version);
    u8v(out, env.modeByte);
    i64v(out, env.assignGeneration);
    u8v(out, env.previewVelocity);
    u8v(out, env.voiceCount);
    u8v(out, env.voiceMode);
    u8v(out, env.monoTrigger);
    f64v(out, env.masterGain);
    u8v(out, env.channelModeExplicit);
    u32v(out, 0);  // sample-refs: empty table
    strv(out, env.instanceGuid);
    strv(out, env.selectionId);
    u32v(out, kParamsFormatMarker);
    u32v(out, 8);
    putRecordBody(out, record, 7);  // the v7 zone tail IS the v8 single record's body
    return out;
}

// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
                                                   const std::vector<Zone>& zones,
                                                   std::uint32_t pv) {
    Envelope env;
    env.selectionId = selectionId;
    return envelopeWithZones(env, zones, pv);
}

}  // namespace legacy

// --- The current format -------------------------------------------------------

// Builds a SampleRefEntry with the intrinsics fields the refs-robustness tests below need to
// set individually (root/loop/channels), mirroring the codec's own field names.
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
                               bool hasLoop = false, std::int64_t loopStart = 0,
                               std::int64_t loopEnd = 0, int channels = 0,
                               const std::string& name = "") {
    SampleRefEntry e;
    e.sampleId = id;
    e.ref.relativePath = rel;
    e.ref.rootNote = root;
    e.ref.loop.hasLoop = hasLoop;
    e.ref.loop.start = loopStart;
    e.ref.loop.end = loopEnd;
    e.ref.channelCount = channels;
    e.displayName = name;
    return e;
}

// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
// survives. This is the "one parameter set round-trips save/reload intact" contract.
static void testComponentStateRoundTrip() {
    ComponentState in;
    in.selectionId = "smp-1";
    in.channelMode = ChannelMode::Stereo;
    in.channelModeExplicit = true;
    in.lastConsumedAssignGeneration = 42;
    in.previewVelocity = 99;
    in.voiceCount = 7;
    in.voiceMode = VoiceMode::Mono;
    in.monoTrigger = MonoTrigger::Legato;
    in.masterGainLinear = 0.5;
    in.instanceGuid = "0123456789abcdef0123456789abcdef";
    SampleRefEntry e;
    e.sampleId = "smp-1";
    e.ref.relativePath = "bank/smp-1.wav";
    e.ref.rootNote = 64;
    e.ref.loop.hasLoop = true;
    e.ref.loop.start = 100;
    e.ref.loop.end = 2000;
    e.ref.channelCount = 2;
    e.displayName = "My Capture";
    in.sampleRefs.push_back(e);
    in.params.rootOverride = 61;
    SampleLoop lp;
    lp.hasLoop = true;
    lp.start = 7;
    lp.end = 900;
    in.params.loopOverride = lp;
    in.params.startPoint = 5;
    in.params.keyTrack = 1.5;
    in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
        reasampler::instrument::engine::CurveDomain::Unipolar);
    in.params.play.playMode = PlayMode::Trigger;
    in.params.play.adsr.attackSeconds = 0.01;
    in.params.play.adsr.holdSeconds = 0.05;
    in.params.play.adsr.decaySeconds = 0.02;
    in.params.play.adsr.sustainLevel = 0.8;
    in.params.play.adsr.releaseSeconds = 0.15;
    in.params.play.trigger.lengthFraction = 0.75;
    in.params.play.trigAhd = AhdSeconds{0.011, 0.022, 0.65, 2.5, 0.4};
    in.params.play.adsr.attackCurve = 3.0;
    in.params.play.adsr.decayCurve = 0.3;
    in.params.play.adsr.releaseCurve = 6.0;
    in.params.play.filter.env.attackCurve = 1.25;
    in.params.play.filter.env.decayCurve = 0.75;
    in.params.play.filter.env.releaseCurve = 8.0;
    in.params.play.filter.trigEnv = AhdSeconds{0.033, 0.044, 0.15, 0.2, 9.0};
    in.params.play.pitchEngine = PitchEngine::Preserve;
    in.params.play.pitchEnv.enabled = true;
    in.params.play.pitchEnv.shape = AhdSeconds{0.02, 0.03, 0.45, 1.5, 0.6};
    in.params.play.pitchEnv.peakSemitones = 5.0;

    const std::vector<std::uint8_t> bytes = serializeComponentState(in);
    const ComponentState out = deserializeComponentState(bytes, 48000.0);

    CHECK(out.selectionId == "smp-1");
    CHECK(out.channelMode == ChannelMode::Stereo);
    CHECK(out.channelModeExplicit);
    CHECK(out.lastConsumedAssignGeneration == 42);
    CHECK(out.previewVelocity == 99);
    CHECK(out.voiceCount == 7);
    CHECK(out.voiceMode == VoiceMode::Mono);
    CHECK(out.monoTrigger == MonoTrigger::Legato);
    CHECK(out.masterGainLinear == 0.5);
    CHECK(out.instanceGuid == "0123456789abcdef0123456789abcdef");
    CHECK(out.sampleRefs.size() == 1);
    if (out.sampleRefs.size() == 1) {
        CHECK(out.sampleRefs[0].sampleId == "smp-1");
        CHECK(out.sampleRefs[0].ref.relativePath == "bank/smp-1.wav");
        CHECK(out.sampleRefs[0].ref.rootNote == 64);
        CHECK(out.sampleRefs[0].ref.loop.hasLoop);
        CHECK(out.sampleRefs[0].ref.loop.start == 100);
        CHECK(out.sampleRefs[0].ref.loop.end == 2000);
        CHECK(out.sampleRefs[0].ref.channelCount == 2);
        CHECK(out.sampleRefs[0].displayName == "My Capture");
    }
    const InstrumentParams& p = out.params;
    CHECK(p.rootOverride && *p.rootOverride == 61);
    CHECK(p.loopOverride && p.loopOverride->hasLoop);
    CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
    CHECK(p.startPoint && *p.startPoint == 5);
    CHECK(p.keyTrack == 1.5);
    CHECK(p.velocityCurve.points().size() == 3);
    CHECK(p.play.playMode == PlayMode::Trigger);
    CHECK(p.play.adsr.attackSeconds == 0.01);
    CHECK(p.play.adsr.holdSeconds == 0.05);
    CHECK(p.play.adsr.decaySeconds == 0.02);
    CHECK(p.play.adsr.sustainLevel == 0.8);
    CHECK(p.play.adsr.releaseSeconds == 0.15);
    CHECK(p.play.trigger.lengthFraction == 0.75);
    // Every curve exponent, hold fraction and Trigger AHD field survives the round trip
    // EXACTLY — the tail is doubles all the way down, so nothing quantizes.
    CHECK(p.play.adsr.attackCurve == 3.0);
    CHECK(p.play.adsr.decayCurve == 0.3);
    CHECK(p.play.adsr.releaseCurve == 6.0);
    CHECK(p.play.trigAhd.attackSeconds == 0.011);
    CHECK(p.play.trigAhd.decaySeconds == 0.022);
    CHECK(p.play.trigAhd.holdFraction == 0.65);
    CHECK(p.play.trigAhd.attackCurve == 2.5);
    CHECK(p.play.trigAhd.decayCurve == 0.4);
    CHECK(p.play.filter.env.attackCurve == 1.25);
    CHECK(p.play.filter.env.decayCurve == 0.75);
    CHECK(p.play.filter.env.releaseCurve == 8.0);
    CHECK(p.play.filter.trigEnv.attackSeconds == 0.033);
    CHECK(p.play.filter.trigEnv.decaySeconds == 0.044);
    CHECK(p.play.filter.trigEnv.holdFraction == 0.15);
    CHECK(p.play.filter.trigEnv.attackCurve == 0.2);
    CHECK(p.play.filter.trigEnv.decayCurve == 9.0);
    CHECK(p.play.pitchEngine == PitchEngine::Preserve);
    CHECK(p.play.pitchEnv.enabled);
    CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
    CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
    CHECK(p.play.pitchEnv.shape.holdFraction == 0.45);
    CHECK(p.play.pitchEnv.shape.attackCurve == 1.5);
    CHECK(p.play.pitchEnv.shape.decayCurve == 0.6);
    CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}

// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
// pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
// byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
// pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
// field family at once (a two-entry sample-refs table — one with a loop, one without — every
// optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
// and asserts the encoded bytes equal an EXACT expected vector, captured from the current
// writer's output and checked field-for-field against the v8/v11 layout documented in
// component_state_io.h.
static void testGoldenFullBlobFixture() {
    ComponentState in;
    in.selectionId = "kick";
    in.channelMode = ChannelMode::Stereo;
    in.channelModeExplicit = true;
    in.lastConsumedAssignGeneration = 12345;
    in.previewVelocity = 100;
    in.voiceCount = 24;
    in.voiceMode = VoiceMode::Mono;
    in.monoTrigger = MonoTrigger::Legato;
    in.masterGainLinear = 2.0;
    in.instanceGuid = "guid-1234-5678-abcd";

    SampleRefEntry kickRef;
    kickRef.sampleId = "kick";
    kickRef.ref.relativePath = "bank/kick.wav";
    kickRef.ref.rootNote = 36;
    kickRef.ref.loop.hasLoop = true;
    kickRef.ref.loop.start = 1000;
    kickRef.ref.loop.end = 5000;
    kickRef.ref.channelCount = 2;
    kickRef.displayName = "Kick Drum";
    in.sampleRefs.push_back(kickRef);

    SampleRefEntry snareRef;
    snareRef.sampleId = "snare";
    snareRef.ref.relativePath = "bank/snare.wav";
    snareRef.ref.rootNote = 38;
    snareRef.ref.loop.hasLoop = false;
    snareRef.ref.loop.start = 0;
    snareRef.ref.loop.end = 0;
    snareRef.ref.channelCount = 1;
    snareRef.displayName = "Snare";
    in.sampleRefs.push_back(snareRef);

    in.params.rootOverride = 36;
    SampleLoop loopA;
    loopA.hasLoop = true;
    loopA.start = 1000;
    loopA.end = 5000;
    in.params.loopOverride = loopA;
    in.params.loopCrossfadeFrames = 256;
    in.params.startPoint = 250;
    in.params.keyTrack = 0.5;
    in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
        reasampler::instrument::engine::CurveDomain::Unipolar);
    in.params.play.playMode = PlayMode::Trigger;
    in.params.play.adsr.attackSeconds = 0.01;
    in.params.play.adsr.holdSeconds = 0.05;
    in.params.play.adsr.decaySeconds = 0.02;
    in.params.play.adsr.sustainLevel = 0.8;
    in.params.play.adsr.releaseSeconds = 0.15;
    in.params.play.trigger.lengthFraction = 0.75;
    in.params.play.pitchEngine = PitchEngine::Preserve;
    in.params.play.pitchEnv.enabled = true;
    in.params.play.pitchEnv.shape.attackSeconds = 0.02;
    in.params.play.pitchEnv.shape.decaySeconds = 0.03;
    in.params.play.pitchEnv.peakSemitones = 5.0;

    const std::vector<std::uint8_t> bytes = serializeComponentState(in);
    // clang-format off
    static const std::uint8_t kGolden[] = {
        0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01,
        0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00,
        0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b,
        0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00,
        0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,
        0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00,
        0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73,
        0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
        0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
        0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
        0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x10,0x00,0x00,
        0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
        0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
        0xe8,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
        0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
        0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
        0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
        0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
        0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
        // --- payload v9 filter tail, at its OFF/NEUTRAL default (this fixture sets no
        // filter field), in the header's documented order ---
        0x00,                                            // enabled = false
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // cutoffNorm    1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // resonanceNorm 0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // morphNorm     1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // driveNorm     0.0
        0x00,                                            // morphLaw = HighBandLow
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // modAmount 0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // velAmount 0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // keyTrack  0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // env attack  0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // env hold    0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // env decay   0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // env sustain 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // env release 0.0
        0x02,0x00,0x00,0x00,                             // filter curve: 2 points (flat at zero)
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   velocity   0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   value      0.0
        0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,         //   velocity 127.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   value      0.0
        // --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
        // curve or AHD field), in the header's documented order ---
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // amp attack curve   1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // amp decay curve    1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // amp release curve  1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // trig AHD attack    0.0 s
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // trig AHD decay     0.0 s
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // trig AHD hold      1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // trig AHD att curve 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // trig AHD dec curve 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // pitch hold         0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // pitch attack curve 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // pitch decay curve  1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt attack curve  1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt decay curve   1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt release curve 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // filt AHD attack    0.0 s
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // filt AHD decay     0.0 s
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt AHD hold      1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt AHD att curve 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // filt AHD dec curve 1.0
        // --- payload v11 loop-crossfade tail ---
        0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,         // loopCrossfadeFrames 256
        // --- payload v12 velocity->pitch curve, at its off default (flat at zero) ---
        0x02,0x00,0x00,0x00,                             // 2 points
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   velocity   0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   value      0.0
        0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,         //   velocity 127.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   value      0.0
        // --- payload v13 dual Staged/Spline state. Three spline EGs (amp, pitch, filter),
        // each Staged with the y = 1 - x default contour, then the three velocity curves'
        // hard-flag tails, all flags clear ---
        0x00,                                            // amp: Staged
        0x02,0x00,0x00,0x00,                             // 2 points
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   x   0.0
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         //   y   1.0
        0x00,                                            //   smooth
        0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,         //   x 127.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         //   y   0.0
        0x00,                                            //   smooth
        0x00,                                            // pitch: Staged
        0x02,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
        0x00,
        0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,
        0x00,                                            // filter: Staged
        0x02,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
        0x00,
        0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,
        0x03,0x00,0x00,0x00, 0x00,0x00,0x00,             // amp curve hard flags (3 points)
        0x02,0x00,0x00,0x00, 0x00,0x00,                  // filter curve hard flags
        0x02,0x00,0x00,0x00, 0x00,0x00,                  // pitch curve hard flags
        // --- payload v14 bake Hold, at its one-bar default ---
        0x02,0x00,0x00,0x00,                             // quarterExponent 2 (== 1/1)
        0x00,                                            // Straight
        // --- payload v15 limiter enable ---
        0x00,                                            // bypassed (the default)
        // --- payload v16 rate + baseline pitch offset ---
        0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,         // playRate 1.0
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,         // pitchOffsetSemitones 0.0
    };
    // clang-format on
    CHECK(bytes.size() == sizeof(kGolden));
    if (bytes.size() == sizeof(kGolden)) {
        bool same = true;
        for (std::size_t i = 0; i < bytes.size(); ++i) {
            if (bytes[i] != kGolden[i]) {
                std::printf("  golden byte %zu: got 0x%02x, want 0x%02x\n", i,
                            bytes[i], kGolden[i]);
                same = false;
                break;
            }
        }
        CHECK(same);
    }
}

// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
// blob restores as the silent empty state, not as a set of accidental values.
static void testDefaultStateRoundTripsToDefaults() {
    const ComponentState out =
        deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
    CHECK(out.selectionId.empty());
    CHECK(!out.params.rootOverride);
    CHECK(!out.params.loopOverride);
    CHECK(!out.params.startPoint);
    CHECK(out.params.keyTrack == 1.0);
    CHECK(out.params.play.playMode == PlayMode::Gate);
    CHECK(out.params.play.pitchEngine == kDefaultPitchEngine);
    CHECK(!out.params.play.pitchEnv.enabled);
    CHECK(out.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
    CHECK(out.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}

// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in either is
// a byte-format break the round-trip alone can't prove (both sides could drift together).
// Also pins the payload version + marker as SEMANTIC constants, so a bump has to be
// deliberate rather than incidental.
static void testEnvelopePrefixBytesFrozen() {
    ComponentState in;  // defaults: mono, implicit, no refs, no selection, default params
    const std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() > 5);
    if (bytes.size() > 5) {
        CHECK(bytes[0] == 11 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0);
        CHECK(bytes[4] == 0);  // ChannelMode::Mono
    }
    CHECK(kComponentStateVersion == 11);
    CHECK(kParamsPayloadVersion == 16);
    CHECK(kParamsSingleRecordVersion == 8);
    CHECK(kParamsFormatMarker == 0xFFFFFF00u);
    // The filter, staged-curve, loop, velocity, spline, bake-Hold, limiter and rate tails rode
    // PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope
    // field cannot collide with any of them on one number. This pins the NUMBERS only; that
    // each tail's bytes sit in the order its number implies is
    // testAppendedTailsSitInVersionOrderOnTheWire's job.
    CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
    CHECK(kParamsCurveVersion > kParamsFilterVersion);
    CHECK(kParamsLoopVersion > kParamsCurveVersion);
    CHECK(kParamsVelocityVersion > kParamsLoopVersion);
    CHECK(kParamsSplineVersion > kParamsVelocityVersion);
    CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
    CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion);
    CHECK(kParamsRateVersion > kParamsLimiterVersion);
    CHECK(kParamsPayloadVersion == kParamsRateVersion);
}

// --- The filter tail (payload v9) --------------------------------------------

// A v8 blob is a strict prefix of v9, so it must lift to the OFF/NEUTRAL filter — the reason
// a project saved before the filter existed reopens sounding identical. Everything the v8
// record did carry must survive alongside it.
static void testV8RecordLiftsToTheOffNeutralFilter() {
    legacy::Zone rec;
    rec.rootOverride = 48;
    rec.startPoint = 512;
    rec.holdSeconds = 0.25;
    rec.attackSeconds = 0.011;
    rec.releaseSeconds = 0.222;
    rec.keyTrack = 0.75;
    rec.preserve = true;
    const ComponentState out =
        deserializeComponentState(envelopeWithV8Record("kick", rec), 48000.0);

    CHECK(out.selectionId == "kick");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 48);
    CHECK(out.params.startPoint && *out.params.startPoint == 512);
    CHECK(out.params.keyTrack == 0.75);
    CHECK(out.params.play.adsr.holdSeconds == 0.25);
    CHECK(out.params.play.adsr.releaseSeconds == 0.222);
    CHECK(out.params.play.pitchEngine == PitchEngine::Preserve);

    // The lift, field by field: nothing engaged, nothing modulating, a flat unity envelope.
    const FilterSeconds& f = out.params.play.filter;
    const FilterSeconds def;
    CHECK(!f.enabled);
    CHECK(f.settings.cutoffNorm == def.settings.cutoffNorm);
    CHECK(f.settings.resonanceNorm == def.settings.resonanceNorm);
    CHECK(f.settings.morphNorm == def.settings.morphNorm);
    CHECK(f.settings.driveNorm == def.settings.driveNorm);
    CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
    CHECK(f.modAmount == 0.0);
    for (int v = 0; v <= 127; ++v) CHECK(f.velocityCurve.eval(v) == 0.0);
    CHECK(f.keyTrack == 0.0);
    CHECK(f.env.sustainLevel == 1.0);
    CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
          f.env.releaseSeconds == 0.0 && f.env.holdSeconds == 0.0);

    // Re-saving lifts it into the current format, and that blob is what the writer would have
    // produced for the same state — so the lift is stable, not one-way lossy.
    ComponentState resaved = out;
    CHECK(serializeComponentState(resaved) ==
          serializeComponentState(deserializeComponentState(
              serializeComponentState(resaved), 48000.0)));
}

// The v9 tail round-trips losslessly, including the morph law's non-default leg and a filter
// velocity curve distinct from the amp's.
static void testFilterTailRoundTripsLosslessly() {
    ComponentState in;
    in.selectionId = "pad";
    FilterSeconds& f = in.params.play.filter;
    f.enabled = true;
    f.settings.cutoffNorm = 0.375f;
    f.settings.resonanceNorm = 0.8125f;
    f.settings.morphNorm = 0.25f;
    f.settings.driveNorm = 0.5f;
    f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
    f.modAmount = -0.625;
    f.velAmount = 0.875;
    f.keyTrack = 1.5;
    f.env.attackSeconds = 0.031;
    f.env.holdSeconds = 0.062;
    f.env.decaySeconds = 0.125;
    f.env.sustainLevel = 0.25;
    f.env.releaseSeconds = 0.5;
    // Bipolar, and reaching into the negative half the retired unipolar shape could not
    // express: a codec that read this back through the old domain would clamp it to 0.
    f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, -0.75}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}},
        reasampler::instrument::engine::CurveDomain::Bipolar);
    // The amp's own curve stays different, so a codec that read one into the other fails here.
    in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();

    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    const FilterSeconds& g = out.params.play.filter;
    CHECK(g.enabled);
    CHECK(g.settings.cutoffNorm == f.settings.cutoffNorm);
    CHECK(g.settings.resonanceNorm == f.settings.resonanceNorm);
    CHECK(g.settings.morphNorm == f.settings.morphNorm);
    CHECK(g.settings.driveNorm == f.settings.driveNorm);
    CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
    CHECK(g.modAmount == f.modAmount);
    CHECK(g.velAmount == f.velAmount);
    CHECK(g.keyTrack == f.keyTrack);
    CHECK(g.env.attackSeconds == f.env.attackSeconds);
    CHECK(g.env.holdSeconds == f.env.holdSeconds);
    CHECK(g.env.decaySeconds == f.env.decaySeconds);
    CHECK(g.env.sustainLevel == f.env.sustainLevel);
    CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
    CHECK(g.velocityCurve.size() == 3);
    CHECK(g.velocityCurve.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
    CHECK(g.velocityCurve.eval(0.0) == -0.75);   // the negative half survives the round trip
    CHECK(g.velocityCurve.eval(100.0) == 0.4);
    CHECK(g.velocityCurve.eval(127.0) == 0.9);
    CHECK(out.params.velocityCurve.equals(
        reasampler::instrument::engine::VelocityCurve::flat()));
}

// The velocity->PITCH curve (payload v12) is a third, independent slot: it round-trips whole,
// and neither of the other two leaks into it.
static void testPitchVelocityCurveRoundTripsIndependently() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, -1.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 0.5}},
        reasampler::instrument::engine::CurveDomain::Bipolar);
    in.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, 0.2}, VelocityPoint{127.0, -0.6}},
        reasampler::instrument::engine::CurveDomain::Bipolar);
    in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::linear();

    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    const reasampler::instrument::engine::VelocityCurve& p = out.params.play.pitchVelocityCurve;
    CHECK(p.size() == 3);
    CHECK(p.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
    CHECK(p.eval(0.0) == -1.0);
    CHECK(p.eval(64.0) == 0.25);
    CHECK(p.eval(127.0) == 0.5);
    // The other two slots kept their own values — no cross-talk between the three curves.
    CHECK(out.params.play.filter.velocityCurve.eval(127.0) == -0.6);
    CHECK(out.params.velocityCurve.eval(127.0) == 1.0);
    CHECK(out.params.velocityCurve.eval(0.0) == 0.0);
}

// A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the
// same guard the v8 master gain already applies) must lift to the neutral default rather than
// reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the
// static_cast<int> is UB. Mirrors testCorruptFieldsFallBackToDefaults' per-field precedent.
static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
    ComponentState in;
    in.selectionId = "pad";
    FilterSeconds& f = in.params.play.filter;
    f.enabled = true;
    f.modAmount = std::numeric_limits<double>::quiet_NaN();
    f.velAmount = std::numeric_limits<double>::infinity();
    f.keyTrack = -std::numeric_limits<double>::infinity();

    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    const FilterSeconds& g = out.params.play.filter;
    const FilterSeconds def;
    CHECK(g.modAmount == def.modAmount);
    CHECK(g.velAmount == def.velAmount);
    CHECK(g.keyTrack == def.keyTrack);
    // The fallback is per-field, not per-record: the untouched fields still round-trip.
    CHECK(g.enabled);
}

// A non-finite attackSeconds/decaySeconds on a stored AHD (a corrupt blob) must lift to 0
// seconds rather than reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp) — UB on
// NaN, and on a large-enough finite value. Mirrors
// testNonFiniteFilterFieldsLiftToTheNeutralDefault's per-field precedent, on the v10 AHD tail.
static void testNonFiniteAhdSecondsLiftToZero() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.play.trigAhd.attackSeconds = std::numeric_limits<double>::quiet_NaN();
    in.params.play.trigAhd.decaySeconds = std::numeric_limits<double>::infinity();
    in.params.play.filter.trigEnv.attackSeconds = -std::numeric_limits<double>::infinity();
    in.params.play.filter.trigEnv.decaySeconds = std::numeric_limits<double>::quiet_NaN();

    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.play.trigAhd.attackSeconds == 0.0);
    CHECK(out.params.play.trigAhd.decaySeconds == 0.0);
    CHECK(out.params.play.filter.trigEnv.attackSeconds == 0.0);
    CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}

// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------

// The three trailing blocks of a CURRENT blob, so the splice tests below can cut back to the
// hard flags and rewrite them without hand-counting the payload twice. Every velocity curve
// in those fixtures is at its default 2-point shape, which is what pins the flag block sizes.
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
static constexpr std::size_t kLimiterTailBytes = 1;
static constexpr std::size_t kRateTailBytes = 8 + 8;  // v16: rate + pitch offset, two doubles
// Everything past the hard flags, as ONE unit — the splice tests cut back over all of it, so a
// new rung is one edit here rather than a hand-counted sum at each of them.
static constexpr std::size_t kTrailingTailBytes =
    kBakeHoldTailBytes + kLimiterTailBytes + kRateTailBytes;

// The v14/v15/v16 tails, re-appended after a splice so the record still ends where the reader
// expects. They go back in wire order: Hold, then the limiter byte, then the rate pair.
static void putBakeHoldTail(std::vector<std::uint8_t>& out, int quarterExponent,
                            note::DivisionModifier modifier) {
    legacy::u32v(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(quarterExponent)));
    legacy::u8v(out, static_cast<std::uint8_t>(modifier));
}

static void putLimiterTail(std::vector<std::uint8_t>& out, bool enabled) {
    legacy::u8v(out, enabled ? 1 : 0);
}

static void putRateTail(std::vector<std::uint8_t>& out, double rate, double pitchOffset) {
    legacy::f64v(out, rate);
    legacy::f64v(out, pitchOffset);
}

static void putDefaultTrailingTails(std::vector<std::uint8_t>& out) {
    putBakeHoldTail(out, 2, note::DivisionModifier::Straight);  // 1/1, the field's default
    putLimiterTail(out, false);                                 // bypassed, the field's default
    putRateTail(out, 1.0, 0.0);                                 // unity rate, no offset
}

// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
// the record is disturbed. Corrupts only the AMP curve's tail; FILTER/PITCH follow at their
// normal, byte-precise offsets, proving a mismatch on one curve does not cascade to its
// neighbours.
//
// Companion, not a regression guard: this in-bounds-mismatch path was already non-wiping
// before the r.ok fix below — it pins the documented promise, not the fix. The two tests that
// follow (OUT-OF-BOUNDS count, and a mid-count truncation) are what actually guard it — both
// tripped the old "reset the whole record to defaults" behavior.
static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 44;
    in.params.keyTrack = 0.6;
    in.params.play.adsr.attackSeconds = 0.12;
    in.params.play.adsr.sustainLevel = 0.55;
    in.params.play.filter.enabled = true;
    in.params.play.filter.settings.cutoffNorm = 0.4f;
    in.params.play.filter.modAmount = -0.3;
    in.params.play.pitchEnv.enabled = true;
    in.params.play.pitchEnv.peakSemitones = 5.0;
    in.params.loopCrossfadeFrames = 777;
    in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, -0.4}, VelocityPoint{127.0, 0.4}},
        reasampler::instrument::engine::CurveDomain::Bipolar);
    // Every velocity curve left at its DEFAULT 2-point shape, so the v13 hard-flag tail's byte
    // layout (three 4-byte-count + N-byte blocks, amp/filter/pitch order — putHardFlags' call
    // order in params_payload.cpp) is deterministic and this test can splice it exactly.

    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
    bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
    legacy::u32v(bytes, 5);                    // amp: bogus count...
    for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0);  // ...with 5 REAL bytes, so nothing shifts
    legacy::u32v(bytes, 2);                    // filter: correct count, unchanged
    legacy::u8v(bytes, 0);
    legacy::u8v(bytes, 0);
    legacy::u32v(bytes, 2);                    // pitch: correct count, unchanged
    legacy::u8v(bytes, 0);
    legacy::u8v(bytes, 0);
    putDefaultTrailingTails(bytes);

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    // Every param preceding AND following the corrupted amp tail survives untouched.
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
    CHECK(out.params.keyTrack == 0.6);
    CHECK(out.params.play.adsr.attackSeconds == 0.12);
    CHECK(out.params.play.adsr.sustainLevel == 0.55);
    CHECK(out.params.play.filter.enabled);
    CHECK(out.params.play.filter.settings.cutoffNorm == 0.4f);
    CHECK(out.params.play.filter.modAmount == -0.3);
    CHECK(out.params.play.pitchEnv.enabled);
    CHECK(out.params.play.pitchEnv.peakSemitones == 5.0);
    CHECK(out.params.loopCrossfadeFrames == 777);
    CHECK(out.params.play.pitchVelocityCurve.eval(0.0) == -0.4);
    CHECK(out.params.play.pitchVelocityCurve.eval(127.0) == 0.4);
    // The mismatched (amp) curve keeps its points; the flags are dropped, never misapplied.
    CHECK(out.params.velocityCurve.size() == 2);
    CHECK(!out.params.velocityCurve.points()[0].hard);
    CHECK(!out.params.velocityCurve.points()[1].hard);
    CHECK(out.params.velocityCurve.equals(reasampler::instrument::engine::VelocityCurve::flat()));
}

// A hard-flag COUNT that exceeds what its own tail carries is a genuinely corrupt count: the
// record parsed AHEAD of it survives (never the old "reset everything to defaults"), and the
// stream is drained rather than guessed at — see the stranding test below for why guessing is
// worse. Here nothing follows that the drain can cost, so the two behaviours coincide.
static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 44;
    in.params.play.adsr.releaseSeconds = 0.44;
    in.params.play.filter.enabled = true;
    in.params.play.filter.settings.resonanceNorm = 0.9f;
    in.params.loopCrossfadeFrames = 321;

    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
    bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
    legacy::u32v(bytes, 1000);            // amp: a count its own tail cannot possibly carry
    // …and nothing at all after it, so the blob simply ends inside the v13 tail.

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    // The whole record survives — including everything the v13 section itself carries ahead of
    // the hard-flag tail (the three spline EGs). Only the hard-flag applications and the tail
    // that never arrived are lost.
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
    CHECK(out.params.play.adsr.releaseSeconds == 0.44);
    CHECK(out.params.play.filter.enabled);
    CHECK(out.params.play.filter.settings.resonanceNorm == 0.9f);
    CHECK(out.params.loopCrossfadeFrames == 321);
    CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
    CHECK(out.params.velocityCurve.size() == 2);  // unaffected: not misapplied, not discarded
    CHECK(!out.params.velocityCurve.points()[0].hard);
    CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
    CHECK(!out.params.limiterEnabled);
}

// The stranding case, and the reason a bogus count DRAINS rather than skipping in place: the
// blob keeps going after the corrupt block, so "skip nothing and read on" starts every later
// tail mid-block. The bake Hold is the tail that makes it visible — it CLAMPS whatever it
// reads, so a misaligned read installs a legal-looking division rather than failing loudly.
// The bar is that it degrades to ABSENT (the field's own default), never to a fabricated value
// — and in particular never to the top rung the misread count used to clamp to.
static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabricated() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 44;
    in.params.play.adsr.releaseSeconds = 0.44;
    in.params.loopCrossfadeFrames = 321;
    in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
    // Enabled on the in-state so the drain has something to cost on the LAST tail too: a drain
    // that stopped short of it would hand back the stored `true` off bytes it cannot trust.
    in.params.limiterEnabled = true;

    // A THREE-point amp curve, so its flag block is three bytes rather than two: the
    // misaligned reads below then land on bytes that decode to something other than the
    // default, which is what makes "absent" and "fabricated" distinguishable at all.
    in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.5, /*hard=*/true},
         VelocityPoint{127.0, 1.0}},
        reasampler::instrument::engine::CurveDomain::Unipolar);

    // A REAL blob with exactly ONE corrupt field: the amp hard-flag count, patched in place.
    // Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the
    // limiter byte — is exactly what the serializer wrote, which is the whole hazard.
    constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2);
    constexpr std::size_t kTrailingTails = kTrailingTailBytes;
    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails);
    const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails;
    for (std::size_t i = 0; i < 4; ++i) bytes[ampCountAt + i] = i == 0 ? 0x00 : 0xFF;

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
    CHECK(out.params.play.adsr.releaseSeconds == 0.44);
    CHECK(out.params.loopCrossfadeFrames == 321);
    CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
    // Absent, not the stored value (its tail is past the damage and cannot be trusted)…
    CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
    CHECK(out.params.bakeHold != note::makeDivision(-2, note::DivisionModifier::Triplet));
    // …and above all not the division a misaligned read manufactures: the flag bytes read as
    // the next count, and the next-but-one block's bytes read as the Hold, whose exponent
    // clamps to the top rung.
    CHECK(out.params.bakeHold !=
          note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
    // The drain reaches the last tail as well: the stored `true` is past the damage too.
    CHECK(!out.params.limiterEnabled);
}

// Numeric domains are established at the DOOR, not at each consumer. A NaN pitch depth reaches
// the bake's pow() and the voice's ratio multiply; a NaN %-length and a NaN stage time reach
// narrowing casts that are undefined on one; a NaN keyTrack reaches keyTrackedRatio ->
// baseRatio_ -> readPos_'s per-sample static_cast<std::int64_t> (voice.h); a NaN v9 filter
// env second reaches secToFrames the same way the trigAhd/filter.trigEnv pair already covered
// by testNonFiniteAhdSecondsLiftToZero do; and a root override outside MIDI range makes the
// bake's render note and the sample's own root disagree, which is a read rate other than 1 and
// therefore a window sized in the truncating direction.
static void testOutOfDomainWireValuesAreBoundedAtTheCodec() {
    const PlaySeconds defaults;
    const InstrumentParams paramDefaults;
    ComponentState in;
    in.selectionId = "pad";
    in.params.keyTrack = std::numeric_limits<double>::quiet_NaN();
    in.params.rootOverride = 9999;
    in.params.play.pitchEnv.peakSemitones = std::numeric_limits<double>::quiet_NaN();
    in.params.play.trigger.lengthFraction = std::numeric_limits<double>::quiet_NaN();
    in.params.play.adsr.releaseSeconds = std::numeric_limits<double>::infinity();
    in.params.play.filter.enabled = true;
    in.params.play.filter.env.attackSeconds = std::numeric_limits<double>::quiet_NaN();
    in.params.play.filter.env.holdSeconds = std::numeric_limits<double>::infinity();
    in.params.play.filter.env.decaySeconds = -std::numeric_limits<double>::infinity();
    in.params.play.filter.env.sustainLevel = std::numeric_limits<double>::quiet_NaN();
    in.params.play.filter.env.releaseSeconds = std::numeric_limits<double>::quiet_NaN();

    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.rootOverride && *out.params.rootOverride == 127);
    CHECK(out.params.play.pitchEnv.peakSemitones == defaults.pitchEnv.peakSemitones);
    CHECK(out.params.play.trigger.lengthFraction == defaults.trigger.lengthFraction);
    CHECK(out.params.play.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
    CHECK(out.params.keyTrack == paramDefaults.keyTrack);
    CHECK(out.params.play.filter.enabled);  // the fallback is per-field, not per-record
    CHECK(out.params.play.filter.env.attackSeconds == defaults.filter.env.attackSeconds);
    CHECK(out.params.play.filter.env.holdSeconds == defaults.filter.env.holdSeconds);
    CHECK(out.params.play.filter.env.decaySeconds == defaults.filter.env.decaySeconds);
    CHECK(out.params.play.filter.env.sustainLevel == defaults.filter.env.sustainLevel);
    CHECK(out.params.play.filter.env.releaseSeconds == defaults.filter.env.releaseSeconds);

    ComponentState low = in;
    low.params.rootOverride = -5;
    const ComponentState lowOut = deserializeComponentState(serializeComponentState(low), 48000.0);
    CHECK(lowOut.params.rootOverride && *lowOut.params.rootOverride == 0);
}

// The keyTrack guard's OTHER site: a pre-v7 (legacy zone-list) blob's own keyTrack field
// (params_payload.cpp's readLegacyZonePayload, pv >= 6) is a second, independent read of the
// same wire double — same hazard, same fallback, must not be missed just because the v8+
// single-record reader above was fixed.
static void testLegacyZoneKeyTrackNaNLiftsToDefault() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.keyTrack = std::numeric_limits<double>::quiet_NaN();

    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
    CHECK(out.params.keyTrack == InstrumentParams{}.keyTrack);
}

// A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and
// nothing else after) is a different failure shape than a declared-huge count: the u32 read
// itself fails, tripping r.ok inside readHardFlags rather than its own bound check. That must
// be revived the same way — the whole record survives, only the hard-flag applications (on
// all three curves, since the truncation strands every one of them) are lost.
static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 21;
    in.params.play.adsr.decaySeconds = 0.08;
    in.params.play.pitchEnv.enabled = true;
    in.params.play.pitchEnv.peakSemitones = -3.0;
    in.params.loopCrossfadeFrames = 5;

    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
    // Drops the bake-Hold and limiter tails with the flags: the truncation strands everything
    // after it, which is the whole point — both lift to their defaults alongside the flags.
    bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
    legacy::u8v(bytes, 0x02);         // half of the amp tail's 4-byte LE count, then nothing
    legacy::u8v(bytes, 0x00);

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 21);
    CHECK(out.params.play.adsr.decaySeconds == 0.08);
    CHECK(out.params.play.pitchEnv.enabled);
    CHECK(out.params.play.pitchEnv.peakSemitones == -3.0);
    CHECK(out.params.loopCrossfadeFrames == 5);
    CHECK(out.params.velocityCurve.size() == 2);
    CHECK(!out.params.velocityCurve.points()[0].hard);
    CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
}

// --- The loop tail (payload v11) ---------------------------------------------

// The loop span and its crossfade survive a save/reload intact, alongside the two overrides
// that share the record's head — a codec that read the crossfade into a neighbouring int64
// fails here rather than at the ear.
static void testLoopSpanAndCrossfadeRoundTrip() {
    ComponentState in;
    in.selectionId = "pad";
    SampleLoop lp;
    lp.hasLoop = true;
    lp.start = 4096;
    lp.end = 65536;
    in.params.loopOverride = lp;
    in.params.loopCrossfadeFrames = 1024;
    in.params.startPoint = 512;
    in.params.rootOverride = 55;

    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
    CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
    CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
    CHECK(out.params.loopCrossfadeFrames == 1024);
    CHECK(out.params.startPoint && *out.params.startPoint == 512);
    CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
}

// The loop ENABLE is `hasLoop`, and the block writes start/end unconditionally — so the wire
// already carries "off, with a span remembered." Nothing about the format changes to make the
// enable user-owned; this pins that the off state and its retained span both survive a reload,
// because an off that came back as a re-parked default would be a delete button, not a toggle.
static void testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained() {
    ComponentState in;
    in.selectionId = "pad";
    SampleLoop lp;
    lp.hasLoop = false;
    lp.start = 4096;
    lp.end = 65536;
    in.params.loopOverride = lp;
    in.params.loopCrossfadeFrames = 1024;

    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.loopOverride && !out.params.loopOverride->hasLoop);
    CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
    CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
    CHECK(out.params.loopCrossfadeFrames == 1024);
}

// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it
// at the wire keeps the parameter set the editor reads back sane.
static void testNegativeCrossfadeOnTheWireLiftsToZero() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.loopCrossfadeFrames = -4096;
    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.loopCrossfadeFrames == 0);
}

// Payload tails are strict SUFFIXES by construction, so a vN blob IS the current writer's
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
// being a pure suffix, these would decode as garbage instead of as the documented lift.
static const std::size_t kVelocityTailBytes = 4 + 2 * 2 * 8;  // v12: the 2-pt pitch curve
static const std::size_t kLoopTailBytes = 8;        // v11: crossfade, one int64
static const std::size_t kCurveTailBytes = 19 * 8;  // v10: nineteen doubles
static const std::size_t kFilterTailBytes =
    1 + 4 * 8 + 1 + 3 * 8 + 5 * 8 + (4 + 2 * 2 * 8);  // v9: the filter block + its 2-pt curve

static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state,
                                                     std::uint32_t pv, std::size_t cutBytes) {
    std::vector<std::uint8_t> bytes = serializeComponentState(state);
    bool stamped = false;
    for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
        const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
                                (static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
                                (static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
                                (static_cast<std::uint32_t>(bytes[i + 3]) << 24);
        if (m != kParamsFormatMarker) continue;
        // Guard the naive marker scan: a false positive inside payload data would not be
        // sitting in front of the CURRENT version.
        CHECK(bytes[i + 4] == static_cast<std::uint8_t>(kParamsPayloadVersion));
        bytes[i + 4] = static_cast<std::uint8_t>(pv);
        stamped = true;
        break;
    }
    CHECK(stamped);
    CHECK(bytes.size() > cutBytes);
    bytes.resize(bytes.size() - cutBytes);
    return bytes;
}

// Every knot of `lifted` equals `stored`'s BIT for bit — the whole claim of a domain re-tag,
// which is why this compares with == rather than a tolerance.
static bool knotsAreIdentical(const reasampler::instrument::engine::VelocityCurve& lifted,
                              const reasampler::instrument::engine::VelocityCurve& stored) {
    if (lifted.size() != stored.size()) return false;
    for (std::size_t i = 0; i < lifted.size(); ++i) {
        if (lifted.points()[i].velocity != stored.points()[i].velocity) return false;
        if (lifted.points()[i].value != stored.points()[i].value) return false;
    }
    return true;
}

// A project saved before this change reopens sounding identical: its loop span still applies,
// its seam is still hard, and velocity still modulates pitch not at all, at EVERY prior
// single-record version.
static void testPriorPayloadVersionsLiftToAHardSeam() {
    ComponentState in;
    in.selectionId = "pad";
    SampleLoop lp;
    lp.hasLoop = true;
    lp.start = 2000;
    lp.end = 9000;
    in.params.loopOverride = lp;
    in.params.startPoint = 128;
    in.params.keyTrack = 0.5;
    in.params.play.adsr.releaseSeconds = 0.25;
    // Set on the in-state only so a v10 lift can be checked to keep it and a v9 lift to drop
    // it — proving the cuts land where the ladder says they do.
    in.params.play.adsr.attackCurve = 4.0;
    in.params.loopCrossfadeFrames = 777;  // present in the bytes only at v11
    // Present in the bytes only at v12: an off-default pitch curve, so a lift that leaked one
    // in from anywhere else fails rather than coincidentally matching the default.
    in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
        {VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
        reasampler::instrument::engine::CurveDomain::Bipolar);
    // The filter's velocity pair, so EVERY version that carries a filter tail (v9..v11) walks
    // the v12 domain re-tag, not just v11 (see testPreV12FilterVelocityLiftsAsAPureDomainReTag
    // for the single-version proof). The knots stay inside [0,1] — what a pre-v12 unipolar
    // curve could actually hold.
    in.params.play.filter.enabled = true;
    in.params.play.filter.modAmount = -0.6251953125;
    in.params.play.filter.velAmount = -0.75;
    const reasampler::instrument::engine::VelocityCurve filterShape =
        reasampler::instrument::engine::VelocityCurve::fromPoints(
            {VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
            reasampler::instrument::engine::CurveDomain::Bipolar);
    in.params.play.filter.velocityCurve = filterShape;

    struct Case {
        std::uint32_t pv;
        std::size_t cut;
        bool keepsCurveTail;
        bool keepsFilterTail;
    };
    const Case cases[] = {
        {11, kVelocityTailBytes, true, true},
        {10, kVelocityTailBytes + kLoopTailBytes, true, true},
        {9, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes, false, true},
        {8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false},
    };
    for (const Case& c : cases) {
        const std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
        const ComponentState out = deserializeComponentState(bytes, 48000.0);
        // The span itself has been in the format since v2 and must survive untouched.
        CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
        CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000);
        CHECK(out.params.loopOverride && out.params.loopOverride->end == 9000);
        CHECK(out.params.startPoint && *out.params.startPoint == 128);
        CHECK(out.params.keyTrack == 0.5);
        CHECK(out.params.play.adsr.releaseSeconds == 0.25);
        // The documented pre-change behaviour: a hard seam and no velocity->pitch at all.
        CHECK(out.params.loopCrossfadeFrames == (c.pv >= 11 ? 777 : 0));
        for (int v = 0; v <= 127; ++v) {
            CHECK(out.params.play.pitchVelocityCurve.eval(v) == 0.0);
        }
        // And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
        CHECK(out.params.play.adsr.attackCurve ==
              (c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
        // The filter's velocity pair where the tail survives (v9..v11): the depth carries
        // forward untouched and the curve is re-tagged, not rescaled — so the cutoff
        // contribution, depth * curve(v), is exactly what the pre-change reader computed.
        // Where the tail was cut away entirely (v8) it is the off/neutral default.
        if (c.keepsFilterTail) {
            CHECK(out.params.play.filter.enabled);
            CHECK(out.params.play.filter.velAmount == in.params.play.filter.velAmount);
            CHECK(knotsAreIdentical(out.params.play.filter.velocityCurve, filterShape));
            for (int v = 0; v <= 127; ++v) {
                CHECK(out.params.play.filter.velAmount *
                          out.params.play.filter.velocityCurve.eval(v) ==
                      in.params.play.filter.velAmount * filterShape.eval(v));
            }
        } else {
            CHECK(!out.params.play.filter.enabled);
            CHECK(out.params.play.filter.velAmount == 0.0);
            for (int v = 0; v <= 127; ++v) {
                CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0);
            }
        }
    }
}

// The v15 rung. The limiter enable is a strict SUFFIX on v14, so a v14 blob is a valid prefix
// of it and lifts to BYPASSED — the migration bar for a project saved before the limiter
// existed: it reopens with the limiter off and therefore sounding identical.
//
// The v14 case is also the load-bearing ORDERING proof at the reader. A v14 blob is the
// current one with its last byte cut, so if the limiter byte were written AHEAD of the Hold
// the cut would take the Hold's modifier instead and the v14 read would resolve the Hold off
// the limiter byte — the stored division below would not survive. Transposing the two writes
// fails here, not merely in the byte fixture.
static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.limiterEnabled = true;
    in.params.keyTrack = 0.25;  // a neighbour ahead of the new byte, so a misread shows up here too
    // Ξ's v14 field, off its default, so the lift below can prove it came back untouched.
    in.params.bakeHold = note::makeDivision(-1, note::DivisionModifier::Dotted);

    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.limiterEnabled);
    CHECK(out.params.keyTrack == 0.25);
    CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));

    // The same state stamped v14, with the two rungs appended after it cut away: byte-for-byte
    // what the Ξ binary wrote. Its Hold must survive in full.
    const ComponentState v14 = deserializeComponentState(
        payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes + kRateTailBytes),
        48000.0);
    CHECK(!v14.params.limiterEnabled);
    CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));
    CHECK(v14.params.keyTrack == 0.25);

    // And a v13 blob, one rung further back, lifts to BOTH defaults.
    const ComponentState v13 = deserializeComponentState(
        payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes), 48000.0);
    CHECK(!v13.params.limiterEnabled);
    CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold);
    CHECK(v13.params.keyTrack == 0.25);

    // Bypassed is the default at the struct as well as on the wire.
    CHECK(!InstrumentParams{}.limiterEnabled);
    const ComponentState fresh =
        deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
    CHECK(!fresh.params.limiterEnabled);
}

// The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole
// discipline is that each version's fields are a strict suffix on the previous version's, so
// v14's Hold pair must be emitted BEFORE v15's limiter byte, and both before v16's rate pair,
// or every blob already saved at those rungs mis-parses. Asserted at absolute offsets from the
// end of the blob, with every field off its default, so transposing any two writes fails on the
// values and not just the layout.
static void testAppendedTailsSitInVersionOrderOnTheWire() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
    in.params.limiterEnabled = true;
    in.params.play.playRate = 2.0;                  // 0x4000000000000000 LE
    in.params.play.pitchOffsetSemitones = -12.0;    // 0xC028000000000000 LE

    const std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() > kTrailingTailBytes);

    // In order: the v14 Hold's 4-byte LE exponent, its 1-byte modifier, the v15 limiter byte,
    // then the v16 rate and pitch-offset doubles.
    const std::size_t holdAt = bytes.size() - kTrailingTailBytes;
    CHECK(bytes[holdAt + 0] == 0xfe);  // -2 as int32 LE two's-complement
    CHECK(bytes[holdAt + 1] == 0xff);
    CHECK(bytes[holdAt + 2] == 0xff);
    CHECK(bytes[holdAt + 3] == 0xff);
    CHECK(bytes[holdAt + 4] == static_cast<std::uint8_t>(note::DivisionModifier::Triplet));
    CHECK(bytes[holdAt + 5] == 0x01);  // the limiter enable
    const std::size_t rateAt = holdAt + kBakeHoldTailBytes + kLimiterTailBytes;
    const std::uint8_t wantRate[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40};
    const std::uint8_t wantOffset[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0xc0};
    for (std::size_t i = 0; i < 8; ++i) {
        CHECK(bytes[rateAt + i] == wantRate[i]);
        CHECK(bytes[rateAt + 8 + i] == wantOffset[i]);
    }

    // The same claim from the other side: flipping only the limiter changes only the byte the
    // limiter owns, so it cannot be one the Hold or the rate pair also writes.
    ComponentState off = in;
    off.params.limiterEnabled = false;
    const std::vector<std::uint8_t> offBytes = serializeComponentState(off);
    CHECK(offBytes.size() == bytes.size());
    if (offBytes.size() == bytes.size()) {
        for (std::size_t i = 0; i < bytes.size(); ++i) {
            if (i == holdAt + 5) CHECK(offBytes[i] == 0x00);
            else CHECK(offBytes[i] == bytes[i]);
        }
    }
}

// The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift
// is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in
// [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside
// it untouched, and the cutoff contribution equal to the pre-change product at every velocity.
static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
    // A shape confined to [0,1] — what a pre-v12 unipolar curve could actually store. The
    // reference reads the SAME knots through the old domain, so the comparison is against what
    // the pre-change reader built, not against another read of the new one.
    const std::vector<VelocityPoint> knots = {VelocityPoint{0.0, 0.0}, VelocityPoint{40.0, 0.125},
                                              VelocityPoint{64.0, 0.75}, VelocityPoint{127.0, 1.0}};
    const reasampler::instrument::engine::VelocityCurve asStored =
        reasampler::instrument::engine::VelocityCurve::fromPoints(
            knots, reasampler::instrument::engine::CurveDomain::Unipolar);

    for (const double depth : {-0.75, 0.5, 0.0}) {
        ComponentState in;
        in.selectionId = "pad";
        FilterSeconds& f = in.params.play.filter;
        f.enabled = true;
        f.modAmount = -0.6251953125;
        f.velAmount = depth;
        f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
            knots, reasampler::instrument::engine::CurveDomain::Bipolar);

        const std::vector<std::uint8_t> bytes =
            payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes);
        const ComponentState out = deserializeComponentState(bytes, 48000.0);
        const reasampler::instrument::engine::VelocityCurve& lifted =
            out.params.play.filter.velocityCurve;

        CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
        CHECK(knotsAreIdentical(lifted, asStored));       // bit-identical, not within a tolerance
        CHECK(out.params.play.filter.velAmount == depth);  // the depth carries forward untouched
        CHECK(out.params.play.filter.modAmount == f.modAmount);
        // Sound-identical, stated as the product the voice actually computes.
        for (int v = 0; v <= 127; ++v) {
            CHECK(out.params.play.filter.velAmount * lifted.eval(v) == depth * asStored.eval(v));
        }
    }

    // The v12 blob of the same state reads back the same way — the re-tag is what the reader
    // does at EVERY version, so the pre-v12 and current paths cannot diverge.
    ComponentState now;
    now.selectionId = "pad";
    now.params.play.filter.enabled = true;
    now.params.play.filter.velAmount = -0.75;
    now.params.play.filter.velocityCurve =
        reasampler::instrument::engine::VelocityCurve::fromPoints(
            knots, reasampler::instrument::engine::CurveDomain::Bipolar);
    const ComponentState back =
        deserializeComponentState(serializeComponentState(now), 48000.0);
    CHECK(knotsAreIdentical(back.params.play.filter.velocityCurve, asStored));
    CHECK(back.params.play.filter.velAmount == -0.75);
}

// --- The bake Hold tail (payload v14) -----------------------------------------

// Hold survives a save/reload as its {rung, modifier} pair, and it is the ONLY field the v14
// bump touches — everything either side of it in the record comes back untouched.
static void testBakeHoldRoundTripsAndDisturbsNothingElse() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 33;
    in.params.keyTrack = 0.25;
    in.params.loopCrossfadeFrames = 64;
    in.params.play.adsr.releaseSeconds = 0.31;
    in.params.play.ampSpline.mode = EnvMode::Spline;
    // A triplet on a rung well away from the default, so neither field can be read off the
    // other's default and still pass.
    in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);

    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.bakeHold == note::makeDivision(-2, note::DivisionModifier::Triplet));
    CHECK(out.params.bakeHold != InstrumentParams{}.bakeHold);
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 33);
    CHECK(out.params.keyTrack == 0.25);
    CHECK(out.params.loopCrossfadeFrames == 64);
    CHECK(out.params.play.adsr.releaseSeconds == 0.31);
    CHECK(out.params.play.ampSpline.mode == EnvMode::Spline);
}

// A v13 blob is a strict PREFIX of v14, so it must lift to the one-bar Hold default with
// every other field intact — the reason a project saved before Hold existed reopens the same.
static void testV13BlobLiftsToTheDefaultHold() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 55;
    in.params.play.adsr.decaySeconds = 0.09;
    in.params.play.filter.enabled = true;
    in.params.loopCrossfadeFrames = 128;
    in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);

    // Stamp the payload back to v13 and drop every tail appended since: byte-for-byte what the
    // v13 binary would have written.
    const std::vector<std::uint8_t> v13 =
        payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes);
    const ComponentState out = deserializeComponentState(v13, 48000.0);
    CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
    CHECK(out.params.play.adsr.decaySeconds == 0.09);
    CHECK(out.params.play.filter.enabled);
    CHECK(out.params.loopCrossfadeFrames == 128);
}

// A corrupt rung/modifier pair clamps to the nearest legal division rather than being held as
// an unrepresentable one — makeDivision is the only door, and the codec goes through it.
static void testBakeHoldCorruptPairClampsToTheLadder() {
    ComponentState in;
    in.selectionId = "pad";
    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kTrailingTailBytes);
    bytes.resize(bytes.size() - kTrailingTailBytes);
    legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
    legacy::u8v(bytes, 200);  // an unnamed modifier byte
    // Well-formed, off-default tails after it, so the clamp is the only fault in the blob.
    putLimiterTail(bytes, true);
    putRateTail(bytes, 0.5, 7.0);

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    CHECK(out.params.bakeHold ==
          note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
    // The tails behind the corrupt pair still land on their own fields: the clamp consumed
    // exactly the five bytes it was owed, so nothing after it was read out of alignment.
    CHECK(out.params.limiterEnabled);
    CHECK(out.params.play.playRate == 0.5);
    CHECK(out.params.play.pitchOffsetSemitones == 7.0);
}

// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded
// behind it, the limiter's revive is what stops that truncation resetting the record anyway.
static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.rootOverride = 71;
    in.params.play.adsr.attackSeconds = 0.017;
    in.params.loopCrossfadeFrames = 96;
    in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
    in.params.limiterEnabled = true;
    in.params.play.playRate = 0.75;
    in.params.play.pitchOffsetSemitones = -5.0;

    std::vector<std::uint8_t> bytes = serializeComponentState(in);
    CHECK(bytes.size() >= kTrailingTailBytes);
    bytes.resize(bytes.size() - kTrailingTailBytes);
    legacy::u8v(bytes, 0x02);  // two of the exponent's four bytes, then nothing
    legacy::u8v(bytes, 0x00);

    const ComponentState out = deserializeComponentState(bytes, 48000.0);
    CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
    CHECK(!out.params.limiterEnabled);  // stranded behind the Hold, and revived not wiped
    // The rate pair is stranded two rungs behind the damage and must reach its own neutral
    // rather than fabricating one out of the drained bytes.
    CHECK(out.params.play.playRate == 1.0);
    CHECK(out.params.play.pitchOffsetSemitones == 0.0);
    CHECK(out.selectionId == "pad");
    CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
    CHECK(out.params.play.adsr.attackSeconds == 0.017);
    CHECK(out.params.loopCrossfadeFrames == 96);
}

// --- The rate + pitch-offset tail (payload v16) ------------------------------

// The rung's whole contract in one test: a v16 blob round-trips BOTH fields exactly, and a v15
// blob — a strict prefix of it, byte-for-byte what the shipped binary wrote — lifts to unity
// rate and zero offset, which is what every instance before them played. The neighbours ahead of
// the pair are checked too, so a misread that shifted the record shows up here rather than as a
// silent retune.
static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() {
    ComponentState in;
    in.selectionId = "pad";
    in.params.keyTrack = 0.75;
    in.params.limiterEnabled = true;
    in.params.bakeHold = note::makeDivision(3, note::DivisionModifier::Dotted);
    // Both off their defaults, and both exactly representable, so == is the right comparison:
    // the codec stores raw doubles and must not round either one.
    in.params.play.playRate = 0.75;
    in.params.play.pitchOffsetSemitones = -7.5;

    const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.params.play.playRate == 0.75);
    CHECK(out.params.play.pitchOffsetSemitones == -7.5);
    CHECK(out.params.limiterEnabled);
    CHECK(out.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
    CHECK(out.params.keyTrack == 0.75);

    const ComponentState v15 = deserializeComponentState(
        payloadDowngradedTo(in, kParamsLimiterVersion, kRateTailBytes), 48000.0);
    CHECK(v15.params.play.playRate == 1.0);
    CHECK(v15.params.play.pitchOffsetSemitones == 0.0);
    // Everything the v15 binary DID write survives the lift untouched.
    CHECK(v15.params.limiterEnabled);
    CHECK(v15.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
    CHECK(v15.params.keyTrack == 0.75);

    // Unity/zero is the default at the struct as well as on the wire, so a fresh instance and a
    // lifted v15 one are the same sound.
    CHECK(PlaySeconds{}.playRate == 1.0);
    CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0);
}

// Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's
// own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value
// that did not would leave the needle — and the host normalization, once the instrument reports
// parameters — disagreeing with what is actually played. The offset has no such downstream clamp
// at all (it feeds a 2^(x/12) that reaches a per-sample cast), so it gets a real range test and
// degrades whole.
static void testCorruptRateOrOffsetDegradesToTheNeutral() {
    const double nan = std::numeric_limits<double>::quiet_NaN();
    const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = {
        {nan, 3.0, 1.0, 3.0},
        {0.75, nan, 0.75, 0.0},
        {0.0, 3.0, 1.0, 3.0},                      // a zero rate would stall the read head
        {-1.0, 3.0, 1.0, 3.0},                     // and a negative one would run it backwards
        {std::numeric_limits<double>::infinity(), 3.0, 1.0, 3.0},
        // Finite but out of the stretcher's range — reachable from a downgrade, not corruption.
        // Clamped to the bound the engine would have played, not left to re-serialize.
        {10.0, 3.0, instrument::engine::kStretchRateMax, 3.0},
        {0.01, 3.0, instrument::engine::kStretchRateMin, 3.0},
        {instrument::engine::kStretchRateMin, 3.0, instrument::engine::kStretchRateMin, 3.0},   // the bounds themselves
        {instrument::engine::kStretchRateMax, 3.0, instrument::engine::kStretchRateMax, 3.0},   // survive untouched
        {0.75, 1e9, 0.75, 0.0},                    // past the +/-24 st throw
        {0.75, -1e9, 0.75, 0.0},
        {0.75, 24.0, 0.75, 24.0},                  // the throw itself is IN range
        {0.75, -24.0, 0.75, -24.0},
    };
    for (const auto& c : cases) {
        ComponentState in;
        in.selectionId = "pad";
        std::vector<std::uint8_t> bytes = serializeComponentState(in);
        CHECK(bytes.size() >= kRateTailBytes);
        bytes.resize(bytes.size() - kRateTailBytes);
        putRateTail(bytes, c.rate, c.offset);
        const ComponentState out = deserializeComponentState(bytes, 48000.0);
        CHECK(out.params.play.playRate == c.wantRate);
        CHECK(out.params.play.pitchOffsetSemitones == c.wantOffset);
    }
}

// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
static void testWriterEmitsCurrentPayloadVersion() {
    ComponentState in;
    in.selectionId = "id";
    const std::vector<std::uint8_t> bytes = serializeComponentState(in);
    // Scan for the marker; the four bytes after it are the payload version.
    bool found = false;
    for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
        const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
                                (static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
                                (static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
                                (static_cast<std::uint32_t>(bytes[i + 3]) << 24);
        if (m != kParamsFormatMarker) continue;
        const std::uint32_t v = static_cast<std::uint32_t>(bytes[i + 4]) |
                                (static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
                                (static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
                                (static_cast<std::uint32_t>(bytes[i + 7]) << 24);
        CHECK(v == kParamsPayloadVersion);
        found = true;
        break;
    }
    CHECK(found);
}

// --- The retired-zone-payload migration ladder --------------------------------

// SINGLE-ZONE LIFT IS LOSSLESS: a single-capture instance saved under the zone model
// restores with the same capture, the same root, and the same parameters.
static void testSingleZoneMigrationIsLossless() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.lowNote = 0;
    z.highNote = 127;
    z.rootOverride = 36;
    z.hasLoopOverride = true;
    z.loopStart = 1000;
    z.loopEnd = 5000;
    z.startPoint = 250;
    z.trigger = true;
    z.holdSeconds = 0.05;
    z.lengthFraction = 0.75;
    z.fadeIn = 100;
    z.fadeOut = 200;
    z.preserve = true;
    z.pitchEnvEnabled = true;
    z.pitchAttack = 0.02;
    z.pitchDecay = 0.03;
    z.peakSemis = 5.0;
    z.attackSeconds = 0.01;
    z.decaySeconds = 0.02;
    z.sustainLevel = 0.8;
    z.releaseSeconds = 0.15;
    z.keyTrack = 0.5;
    z.curve = {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}};

    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);

    CHECK(out.selectionId == "kick");                 // same capture
    const InstrumentParams& p = out.params;
    CHECK(p.rootOverride && *p.rootOverride == 36);   // same root
    CHECK(p.loopOverride && p.loopOverride->start == 1000 && p.loopOverride->end == 5000);
    CHECK(p.startPoint && *p.startPoint == 250);
    CHECK(p.keyTrack == 0.5);
    CHECK(p.velocityCurve.points().size() == 3);
    CHECK(p.play.playMode == PlayMode::Trigger);
    CHECK(p.play.adsr.attackSeconds == 0.01);
    CHECK(p.play.adsr.holdSeconds == 0.05);
    CHECK(p.play.adsr.decaySeconds == 0.02);
    CHECK(p.play.adsr.sustainLevel == 0.8);
    CHECK(p.play.adsr.releaseSeconds == 0.15);
    CHECK(p.play.trigger.lengthFraction == 0.75);
    // The retired fade pair lifts onto the AHD that replaced it: attack <- fade-in, decay <-
    // fade-out (source frames over the project rate), hold <- the whole remainder.
    CHECK(p.play.trigAhd.attackSeconds == 100.0 / 48000.0);
    CHECK(p.play.trigAhd.decaySeconds == 200.0 / 48000.0);
    CHECK(p.play.trigAhd.holdFraction == 1.0);
    CHECK(p.play.pitchEngine == PitchEngine::Preserve);
    CHECK(p.play.pitchEnv.enabled);
    CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
    CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
    CHECK(p.play.pitchEnv.peakSemitones == 5.0);
    // Everything the change added lifts to its own neutral, so the loaded instance plays as
    // the saved one did: every exponent linear, and the pitch envelope with no hold stage.
    CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
    CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
    CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
    CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
    CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
    // The ONE exception, and the reason it is one: the fades had a prior SHAPE to reproduce,
    // so they lift to the fitted exponents rather than to the neutral (see the contour test).
    CHECK(p.play.trigAhd.attackCurve == kTriggerFadeLiftAttackCurve);
    CHECK(p.play.trigAhd.decayCurve == kTriggerFadeLiftDecayCurve);
}

// The migrated Trigger amp shape against the retired EQUAL-POWER fade pair it replaced. The
// AHD's law is phi^p and cannot reproduce sin/cos at any exponent, so the claim is a bound —
// and the bound the fitted exponents reach is several times tighter than the linear neutral's,
// which is what makes the fit worth a constant.
static void testMigratedFadeContourTracksTheRetiredEqualPowerShape() {
    const double rate = 48000.0;
    const std::int64_t fadeIn = 200;
    const std::int64_t fadeOut = 300;
    const std::int64_t span = 1000;

    legacy::Zone z;
    z.sampleId = "kick";
    z.trigger = true;
    z.lengthFraction = 1.0;
    z.fadeIn = fadeIn;
    z.fadeOut = fadeOut;
    const ComponentState st =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), rate);
    const AhdSeconds& lifted = st.params.play.trigAhd;

    // Resolve the lifted seconds back to frames at the SAME rate the lift used, which is the
    // matched-rate case (the mismatched one is asserted in sample_map_tests).
    const auto toFrames = [rate](double sec) {
        return static_cast<std::int64_t>(sec * rate + 0.5);
    };
    AhdParams migrated;
    migrated.attackFrames = toFrames(lifted.attackSeconds);
    migrated.decayFrames  = toFrames(lifted.decaySeconds);
    migrated.holdFraction = lifted.holdFraction;
    migrated.attackCurve  = lifted.attackCurve;
    migrated.decayCurve   = lifted.decayCurve;
    // Stage LENGTHS are exact: the fades land on the same frames they always did.
    AhdEnvelope ahd;
    ahd.configure(span, migrated);
    CHECK(ahd.stages().attack == fadeIn);
    CHECK(ahd.stages().decay == fadeOut);
    CHECK(ahd.stages().total == span);

    // The pre-change evaluator, written out so the comparison is against a stated reference
    // rather than against whatever the code now does.
    const double pi = 3.14159265358979323846;
    const auto retired = [&](double off) {
        if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
        if (off < static_cast<double>(fadeIn)) {
            return std::sin(off / static_cast<double>(fadeIn) * (pi / 2.0));
        }
        const double foStart = static_cast<double>(span - fadeOut);
        if (off >= foStart) {
            return std::cos((off - foStart) / static_cast<double>(fadeOut) * (pi / 2.0));
        }
        return 1.0;
    };
    const auto worstAgainstRetired = [&](AhdEnvelope& env) {
        double worst = 0.0;
        for (std::int64_t i = 0; i < span; ++i) {
            const double d = env.amplitudeAt(static_cast<double>(i)) -
                             retired(static_cast<double>(i));
            worst = worst > std::fabs(d) ? worst : std::fabs(d);
        }
        return worst;
    };

    const double fitted = worstAgainstRetired(ahd);
    CHECK(fitted <= 0.0876);   // the measured minimax bound of phi^p against sin(pi*phi/2)

    // The rejected alternative, evaluated rather than asserted about: the same lift at the
    // linear neutral. If the fitted exponents were ever dropped this comparison inverts.
    AhdParams neutralLift = migrated;
    neutralLift.attackCurve = util::kCurveNeutral;
    neutralLift.decayCurve  = util::kCurveNeutral;
    AhdEnvelope neutral;
    neutral.configure(span, neutralLift);
    const double neutralWorst = worstAgainstRetired(neutral);
    CHECK(neutralWorst > 0.21);
    CHECK(fitted < neutralWorst * 0.5);

    // Both agree exactly where it matters structurally: the onset, the plateau, and the end.
    CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
    CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
    CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
}

// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
// stays representable under the AHD, which is what makes the consolidation lossless rather
// than merely close.
static void testZeroFadeOutMigratesToZeroDecay() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.lowNote = 0;
    z.highNote = 127;
    z.trigger = true;
    z.lengthFraction = 1.0;
    z.fadeIn = 441;
    z.fadeOut = 0;
    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 44100.0);
    const PlaySeconds& play = out.params.play;
    CHECK(play.playMode == PlayMode::Trigger);
    CHECK(play.trigAhd.attackSeconds == 441.0 / 44100.0);
    CHECK(play.trigAhd.decaySeconds == 0.0);
    CHECK(play.trigAhd.holdFraction == 1.0);
}

// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
// false — distinct from no override at all (which leaves the sample's own intrinsic loop in
// force). The writer always emitted the override's inner hasLoop bit as true; this is the
// disabled shape it never exercised.
static void testSingleZoneMigrationLiftsLoopDisablingOverride() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.hasLoopOverride = true;
    z.loopOverrideHasLoop = false;
    z.loopStart = 1000;
    z.loopEnd = 5000;

    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);

    const InstrumentParams& p = out.params;
    CHECK(p.loopOverride.has_value());
    CHECK(p.loopOverride && !p.loopOverride->hasLoop);
}

// A lifted single-zone instance RE-SAVES in the current format and survives a second
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
static void testLiftedStateReSavesInCurrentFormat() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.rootOverride = 36;
    z.keyTrack = 0.5;
    z.releaseSeconds = 0.4;
    const ComponentState lifted =
        deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
    const ComponentState again =
        deserializeComponentState(serializeComponentState(lifted), 48000.0);
    CHECK(again.selectionId == "kick");
    CHECK(again.params.rootOverride && *again.params.rootOverride == 36);
    CHECK(again.params.keyTrack == 0.5);
    CHECK(again.params.play.adsr.releaseSeconds == 0.4);
}

// MULTI-ZONE LIFT ADOPTS ZONE ONE: its capture AND its parameters win; every later zone
// drops. No error, no empty state.
static void testMultiZoneMigrationAdoptsFirstZone() {
    legacy::Zone first;
    first.sampleId = "kick";
    first.lowNote = 0;
    first.highNote = 59;
    first.rootOverride = 36;
    first.keyTrack = 0.5;
    first.releaseSeconds = 0.4;

    legacy::Zone second;
    second.sampleId = "snare";
    second.lowNote = 60;
    second.highNote = 127;
    second.rootOverride = 38;
    second.keyTrack = 2.0;
    second.releaseSeconds = 0.9;

    legacy::Zone third;
    third.sampleId = "hat";
    third.rootOverride = 42;

    const ComponentState out = deserializeComponentState(
        legacy::envelopeWithZones("", {first, second, third}, 7), 48000.0);

    CHECK(out.selectionId == "kick");                 // zone one's capture
    CHECK(out.params.rootOverride && *out.params.rootOverride == 36);
    CHECK(out.params.keyTrack == 0.5);                // zone one's parameters
    CHECK(out.params.play.adsr.releaseSeconds == 0.4);
    // Zones two and three left no trace anywhere.
    CHECK(out.selectionId != "snare" && out.selectionId != "hat");
    CHECK(out.params.keyTrack != 2.0);
}

// The FIRST zone supersedes the envelope's own stored selection — that zone is what
// first-match resolve actually played, so adopting it is what keeps the sound identical.
static void testFirstZoneSupersedesStoredSelection() {
    legacy::Zone z;
    z.sampleId = "actually-playing";
    const ComponentState out = deserializeComponentState(
        legacy::envelopeWithZones("stale-selection", {z}, 7), 48000.0);
    CHECK(out.selectionId == "actually-playing");
}

// An EMPTY zone list leaves the envelope's selection alone (a picked-but-never-edited
// instance) and yields default parameters.
static void testEmptyZoneListKeepsTheStoredSelection() {
    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones("picked", {}, 7), 48000.0);
    CHECK(out.selectionId == "picked");
    CHECK(!out.params.rootOverride);
    CHECK(out.params.keyTrack == 1.0);
}

// EVERY older payload version takes the migration path, and each lifts the fields its own
// shape carries while defaulting the ones it predates.
static void testEveryOlderPayloadVersionMigrates() {
    for (std::uint32_t pv : {1u, 2u, 5u, 6u, 7u}) {
        legacy::Zone z;
        z.sampleId = "kick";
        z.rootOverride = 36;
        z.keyTrack = 0.5;
        z.releaseSeconds = 0.4;
        const ComponentState out =
            deserializeComponentState(legacy::envelopeWithZones("", {z}, pv), 48000.0);
        CHECK(out.selectionId == "kick");                                 // every version
        CHECK(out.params.rootOverride && *out.params.rootOverride == 36); // v1 onward
        // keyTrack arrived at v6; older payloads lift to 100% ET (bit-identical repitch).
        CHECK(out.params.keyTrack == (pv >= 6 ? 0.5 : 1.0));
        // The full A/D/S/R tail arrived at v5; older payloads keep the tier-0 defaults.
        CHECK(out.params.play.adsr.releaseSeconds ==
              (pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
    }
    // And the CURRENT version does NOT take the migration path: it reads its own record.
    CHECK(kParamsPayloadVersion >= kParamsSingleRecordVersion);
}

// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
// using the project rate threaded in — no baked constant.
static void testLegacyV3FramesConvertAtTheProjectRate() {
    // v3's own tail shape differs from v5's, so lay it out directly here.
    std::vector<std::uint8_t> out;
    legacy::u32v(out, kComponentStateVersion);
    legacy::u8v(out, 0);
    legacy::i64v(out, 0);
    legacy::u8v(out, kPreviewVelocityDefault);
    legacy::u8v(out, static_cast<std::uint8_t>(kDefaultVoiceCount));
    legacy::u8v(out, 0);
    legacy::u8v(out, 0);
    legacy::f64v(out, 1.0);
    legacy::u8v(out, 0);
    legacy::u32v(out, 0);
    legacy::strv(out, "");
    legacy::strv(out, "");
    legacy::u32v(out, kParamsFormatMarker);
    legacy::u32v(out, 3);
    legacy::u32v(out, 1);          // one zone
    legacy::strv(out, "kick");
    legacy::u32v(out, 0);          // lowNote
    legacy::u32v(out, 127);        // highNote
    legacy::u8v(out, 0);           // no root override
    legacy::u8v(out, 0);           // no loop override
    legacy::u8v(out, 0);           // no start point
    legacy::u8v(out, 0);           // playMode: Gate
    legacy::i64v(out, 2400);       // holdFrames -> 0.05 s at 48 kHz
    legacy::f64v(out, 1.0);        // lengthFraction
    legacy::i64v(out, 0);          // fadeIn
    legacy::i64v(out, 0);          // fadeOut
    legacy::u8v(out, 1);           // pitchEngine: Preserve
    legacy::u8v(out, 1);           // pitchEnv enabled
    legacy::i64v(out, 960);        // pitchEnv attackFrames -> 0.02 s
    legacy::i64v(out, 1440);       // pitchEnv decayFrames  -> 0.03 s
    legacy::f64v(out, 5.0);        // peakSemitones

    const ComponentState st = deserializeComponentState(out, 48000.0);
    CHECK(st.selectionId == "kick");
    CHECK(st.params.play.adsr.holdSeconds == 0.05);
    CHECK(st.params.play.pitchEnv.shape.attackSeconds == 0.02);
    CHECK(st.params.play.pitchEnv.shape.decaySeconds == 0.03);
    CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
    // A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
    CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
    CHECK(st.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);

    // The SAME bytes at a different project rate convert to different seconds — proof the
    // rate is a read-time parameter, not a baked constant.
    const ComponentState at96k = deserializeComponentState(out, 96000.0);
    CHECK(at96k.params.play.adsr.holdSeconds == 0.025);
}

// --- The ENVELOPE ladder (v2..v11) -------------------------------------------

// EVERY envelope version restores the fields it carried and lifts the ones it predates to
// their documented defaults. One table over the whole ladder, so a new envelope field
// cannot be added without deciding what each older version lifts it to.
static void testEnvelopeLadderLiftsEachVersion() {
    for (std::uint32_t v : {3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u}) {
        legacy::Envelope env;
        env.version = v;
        env.selectionId = "kick";
        env.modeByte = 1;                 // stereo
        env.assignGeneration = 4242;
        env.previewVelocity = 99;
        env.voiceCount = 7;
        env.voiceMode = 1;                // mono
        env.monoTrigger = 1;              // legato
        env.masterGain = 0.5;
        env.channelModeExplicit = 1;
        env.instanceGuid = "guid-abc";
        const ComponentState out =
            deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);

        CHECK(out.selectionId == "kick");   // v3 onward all carry the selection
        // v4 added the channel mode; older blobs lift to MONO.
        CHECK(out.channelMode == (v >= 4 ? ChannelMode::Stereo : ChannelMode::Mono));
        // v5 added the consumed-assignment marker; older blobs lift to 0, so a genuinely
        // new first assign (generation >= 1) still applies to a pre-marker instance.
        CHECK(out.lastConsumedAssignGeneration == (v >= 5 ? 4242 : 0));
        // v6 added the preview velocity; older blobs lift to the mid default.
        CHECK(out.previewVelocity == (v >= 6 ? 99 : kPreviewVelocityDefault));
        // v7 added the voice system; older blobs lift to {16, Poly, Retrigger} — the
        // pre-voice-system behavior, byte-identically.
        CHECK(out.voiceCount == (v >= 7 ? 7 : kDefaultVoiceCount));
        CHECK(out.voiceMode == (v >= 7 ? VoiceMode::Mono : VoiceMode::Poly));
        CHECK(out.monoTrigger == (v >= 7 ? MonoTrigger::Legato : MonoTrigger::Retrigger));
        // v8 added the master gain; older blobs lift to unity.
        CHECK(out.masterGainLinear == (v >= 8 ? 0.5 : 1.0));
        // v9 added the channel-mode EXPLICIT flag; older blobs lift to implicit, so the
        // auto-default may follow the loaded capture.
        CHECK(out.channelModeExplicit == (v >= 9 ? true : false));
        // v10 added the refs table (always empty here), v11 the instance guid; a pre-v11
        // blob lifts to an empty guid, which the processor mints on first publish.
        CHECK(out.instanceGuid == (v >= 11 ? "guid-abc" : ""));
        CHECK(out.sampleRefs.empty());
    }
}

// A v2 blob is ZONES-ONLY — no stored selection at all — so the adopted first zone supplies
// BOTH the capture and the parameters.
static void testV2ZonesOnlyBlobAdoptsBothFromZoneOne() {
    legacy::Zone z;
    z.sampleId = "kick";
    z.rootOverride = 36;
    std::vector<std::uint8_t> out;
    legacy::u32v(out, kPerformanceStateVersion);  // == 2, the zones-only envelope
    legacy::u32v(out, kParamsFormatMarker);
    legacy::u32v(out, 7);
    legacy::u32v(out, 1);
    legacy::putZone(out, z, 7);

    const ComponentState st = deserializeComponentState(out, 48000.0);
    CHECK(st.selectionId == "kick");
    CHECK(st.params.rootOverride && *st.params.rootOverride == 36);
    CHECK(st.channelMode == ChannelMode::Mono);  // a v2 blob predates the mode byte
}

// A CORRUPT field falls back to its own DEFAULT rather than clamping to an edge the user
// never chose (or, for the gain, silencing/blasting the instance).
static void testCorruptFieldsFallBackToDefaults() {
    legacy::Envelope env;
    env.selectionId = "kick";
    env.previewVelocity = 0;      // 0 is a note-off by convention — out of the 1..127 spec
    env.voiceCount = 200;         // past kMaxVoiceCount
    env.masterGain = 1e9;         // far past the +24 dB cap
    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
    CHECK(out.previewVelocity == kPreviewVelocityDefault);
    CHECK(out.voiceCount == kDefaultVoiceCount);
    CHECK(out.masterGainLinear == 1.0);
}

// CORRUPT-BLOB posture for the refs-table intrinsics: the refs table is the ONLY copy on the
// play path, so a bad field must degrade to its own default, never poison playback. An
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a negative
// channelCount falls back to 0 = unknown (the GA auto-default then skips it). The fallback is
// per-field — in-range neighbours pass through untouched.
static void testSampleRefsReaderRangeFallbacks() {
    ComponentState s;
    s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
                                    /*channels=*/-3));
    s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
                                    /*channels=*/1));
    s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
                                    /*channels=*/2));
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.sampleRefs.size() == 3);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
    CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
}

// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest of
// the state empty (the selection/params behind the cut are unreadable anyway) — the
// established truncation posture, never a throw across the host boundary.
static void testSampleRefsTruncatedMidEntry() {
    ComponentState s;
    s.selectionId = "kick";
    s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
    s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
    std::vector<std::uint8_t> bytes = serializeComponentState(s);
    // The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
    // the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
    // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
    // v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
    // 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
    // three 6-byte hard-flag tails + the 5-byte v14 bake-Hold tail) = 628 bytes; entry two is
    // 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 648 keeps
    // the first 27 of entry two's 47 — mid loop.start (offset 23..31).
    CHECK(bytes.size() > 648);
    bytes.resize(bytes.size() - 648);
    const ComponentState back = deserializeComponentState(bytes, 44100.0);
    CHECK(back.sampleRefs.size() == 1);
    CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
    CHECK(back.selectionId.empty());
    CHECK(!back.params.rootOverride);
}

// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
// produced always re-reads as itself.
static void testWriterClampsOutOfRangeFields() {
    ComponentState in;
    in.voiceCount = 999;
    in.masterGainLinear = 1e9;
    const ComponentState out =
        deserializeComponentState(serializeComponentState(in), 48000.0);
    CHECK(out.voiceCount >= kMinVoiceCount && out.voiceCount <= kMaxVoiceCount);
    CHECK(out.masterGainLinear <=
          reasampler::instrument::engine::masterGainMaxLinear() * (1.0 + 1e-9));

    // Zero gain is TRUE silence and a legal stored value — it must not be "corrected".
    ComponentState silent;
    silent.masterGainLinear = 0.0;
    CHECK(deserializeComponentState(serializeComponentState(silent), 48000.0)
              .masterGainLinear == 0.0);
}

// An UNKNOWN envelope version yields the empty state rather than a misparse.
static void testUnknownEnvelopeVersionIsEmpty() {
    legacy::Envelope env;
    env.version = 99;
    env.selectionId = "kick";
    const ComponentState out =
        deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
    CHECK(out.selectionId.empty());
    CHECK(!out.params.rootOverride);
}

// A v1 selection blob lifts to {id, default params} — the oldest live lift.
static void testV1SelectionLift() {
    const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
    const ComponentState out = deserializeComponentState(v1, 48000.0);
    CHECK(out.selectionId == "old-pick");
    CHECK(!out.params.rootOverride);
    CHECK(out.params.keyTrack == 1.0);
}

// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
// over BOTH the current format and a retired zone-list blob, since the migration path has
// its own bounded-read walk. Beyond mere survival, a cut read must never RETAIN more refs
// than the blob actually carried (the "keep what parsed, drop the rest" contract could not
// silently start fabricating entries) — see testSampleRefsTruncatedMidEntry for the exact
// mid-entry retention case this bounds only loosely across every cut point.
static void testTruncationDegradesCleanly() {
    ComponentState in;
    in.selectionId = "smp-2";
    in.params.rootOverride = 61;
    in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
    in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
    const std::vector<std::uint8_t> current = serializeComponentState(in);

    legacy::Zone z;
    z.sampleId = "smp-2";
    const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);

    for (const std::vector<std::uint8_t>* blob : {&current, &retired}) {
        for (std::size_t cut = 0; cut < blob->size(); ++cut) {
            const std::vector<std::uint8_t> part(blob->begin(),
                                                 blob->begin() + static_cast<long>(cut));
            const ComponentState out = deserializeComponentState(part, 48000.0);
            CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
        }
    }
}

int main() {
    testComponentStateRoundTrip();
    testGoldenFullBlobFixture();
    testDefaultStateRoundTripsToDefaults();
    testEnvelopePrefixBytesFrozen();
    testLoopSpanAndCrossfadeRoundTrip();
    testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained();
    testNegativeCrossfadeOnTheWireLiftsToZero();
    testPriorPayloadVersionsLiftToAHardSeam();
    testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact();
    testAppendedTailsSitInVersionOrderOnTheWire();
    testPreV12FilterVelocityLiftsAsAPureDomainReTag();
    testWriterEmitsCurrentPayloadVersion();
    testSingleZoneMigrationIsLossless();
    testMigratedFadeContourTracksTheRetiredEqualPowerShape();
    testZeroFadeOutMigratesToZeroDecay();
    testSingleZoneMigrationLiftsLoopDisablingOverride();
    testLiftedStateReSavesInCurrentFormat();
    testMultiZoneMigrationAdoptsFirstZone();
    testFirstZoneSupersedesStoredSelection();
    testEmptyZoneListKeepsTheStoredSelection();
    testEveryOlderPayloadVersionMigrates();
    testLegacyV3FramesConvertAtTheProjectRate();
    testEnvelopeLadderLiftsEachVersion();
    testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
    testCorruptFieldsFallBackToDefaults();
    testSampleRefsReaderRangeFallbacks();
    testSampleRefsTruncatedMidEntry();
    testWriterClampsOutOfRangeFields();
    testUnknownEnvelopeVersionIsEmpty();
    testV1SelectionLift();
    testTruncationDegradesCleanly();
    testV8RecordLiftsToTheOffNeutralFilter();
    testFilterTailRoundTripsLosslessly();
    testPitchVelocityCurveRoundTripsIndependently();
    testNonFiniteFilterFieldsLiftToTheNeutralDefault();
    testNonFiniteAhdSecondsLiftToZero();
    testV13HardFlagInBoundsMismatchDropsFlagsOnly();
    testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
    testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabricated();
    testOutOfDomainWireValuesAreBoundedAtTheCodec();
    testLegacyZoneKeyTrackNaNLiftsToDefault();
    testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
    testBakeHoldRoundTripsAndDisturbsNothingElse();
    testV13BlobLiftsToTheDefaultHold();
    testBakeHoldCorruptPairClampsToTheLadder();
    testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
    testRateAndPitchOffsetRoundTripAndV15LiftsToUnity();
    testCorruptRateOrOffsetDegradesToTheNeutral();
    if (failures == 0) {
        std::printf("component_state_io_tests: all tests passed\n");
        return 0;
    }
    std::printf("component_state_io_tests: %d FAILURE(S)\n", failures);
    return 1;
}
