// params_payload.cpp — see params_payload.h. The format ladder it implements is documented
// in component_state_io.h; every wire format below is FROZEN.

#include "core/instrument/map/params_payload.h"

#include <algorithm>  // std::min (bounded curve-point reserve)
#include <cassert>    // assert (v3-lift projectRate guard)
#include <cmath>      // std::isfinite (wire-value validation)
#include <utility>    // std::move

#include "core/instrument/engine/time_stretch.h"  // clampStretchRate (THE rate bound)
#include "core/util/curve_law.h"  // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h"      // putLE / ByteReader / doubleToBits (the ONE LE codec)

namespace reasampler::instrument::map {

using reasampler::wire::ByteReader;
using reasampler::wire::asU64;
using reasampler::wire::bitsToDouble;
using reasampler::wire::doubleToBits;
using reasampler::wire::putLE;

namespace {

// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// the two shapes cannot drift byte-for-byte.
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
    out.push_back(p.rootOverride ? 1 : 0);
    if (p.rootOverride) {
        putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
    }
    out.push_back(p.loopOverride ? 1 : 0);
    if (p.loopOverride) {
        out.push_back(p.loopOverride->hasLoop ? 1 : 0);
        putLE(out, asU64(p.loopOverride->start));
        putLE(out, asU64(p.loopOverride->end));
    }
    out.push_back(p.startPoint ? 1 : 0);
    if (p.startPoint) putLE(out, asU64(*p.startPoint));
}

// A velocity curve: 4-byte LE control-point count, then per point velocity + value as doubles.
// The amp curve (v7), the filter's own curve (v9) and the pitch curve (v12) share this shape;
// the y DOMAIN is not on the wire — it is a property of the slot, so the reader supplies it.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
    const std::vector<VelocityPoint>& pts = curve.points();
    putLE(out, static_cast<std::uint32_t>(pts.size()));
    for (const VelocityPoint& pt : pts) {
        putLE(out, doubleToBits(pt.velocity));
        putLE(out, doubleToBits(pt.value));
    }
}

// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
// the hard flag; this block was born with it.
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
    out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
    const std::vector<VelocityPoint>& pts = s.contour.points();
    putLE(out, static_cast<std::uint32_t>(pts.size()));
    for (const VelocityPoint& pt : pts) {
        putLE(out, doubleToBits(pt.velocity));
        putLE(out, doubleToBits(pt.value));
        out.push_back(pt.hard ? 1 : 0);
    }
}

// The hard flags of an already-written velocity curve: count + one byte per point.
void putHardFlags(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
    const std::vector<VelocityPoint>& pts = curve.points();
    putLE(out, static_cast<std::uint32_t>(pts.size()));
    for (const VelocityPoint& pt : pts) out.push_back(pt.hard ? 1 : 0);
}

// A stored AHD's five doubles, in one order shared by every AHD on the wire.
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
    putLE(out, doubleToBits(a.attackSeconds));
    putLE(out, doubleToBits(a.decaySeconds));
    putLE(out, doubleToBits(a.holdFraction));
    putLE(out, doubleToBits(a.attackCurve));
    putLE(out, doubleToBits(a.decayCurve));
}
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
// and the abrupt end an old instance could express stays representable. The seconds conversion
// and its rate-mismatch bound, and the two fitted exponents, are documented in
// component_state_io.h. A v10-or-newer blob overwrites all five fields from its own tail.
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
                      AhdSeconds& out) {
    const double rate = projectRate > 0.0 ? projectRate : 1.0;
    out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
    out.decaySeconds  = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
    out.holdFraction  = 1.0;
    out.attackCurve   = kTriggerFadeLiftAttackCurve;
    out.decayCurve    = kTriggerFadeLiftDecayCurve;
}

// A wire double whose consumers assume a domain they cannot check: the seconds fields reach
// resolvePlay's static_cast<std::int64_t> and peakSemitones reaches the bake's pow() and the
// voice's ratio multiply — both undefined or poisoning on NaN. Degrades to the field's own
// construction default, so a damaged blob loses that field rather than the record.
double finiteOr(double v, double fallback) { return std::isfinite(v) ? v : fallback; }

// A root-note override off the wire. Clamped HERE and not only where it is consumed: planBake
// clamps the note it renders at into MIDI range while the SampleData keeps the raw override as
// its root, and the two disagreeing makes the read rate something other than 1 — which
// mis-sizes the bake's window in the truncating direction.
int clampMidiNote(int note) { return (std::max)(0, (std::min)(127, note)); }

// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// v8 single-record reader so the two can never disagree about field order.
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) {
    const PlaySeconds fallback;  // the construction defaults, read rather than restated
    p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
    p.play.adsr.holdSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.holdSeconds);
    p.play.trigger.lengthFraction =
        finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
    const std::int64_t fadeIn = r.i64();
    const std::int64_t fadeOut = r.i64();
    liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd);
    p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
    p.play.pitchEnv.enabled = (r.u8() != 0);
    p.play.pitchEnv.shape.attackSeconds =
        finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.attackSeconds);
    p.play.pitchEnv.shape.decaySeconds =
        finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.decaySeconds);
    p.play.pitchEnv.peakSemitones =
        finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
    p.play.adsr.attackSeconds  = finiteOr(bitsToDouble(r.u64()), fallback.adsr.attackSeconds);
    p.play.adsr.decaySeconds   = finiteOr(bitsToDouble(r.u64()), fallback.adsr.decaySeconds);
    p.play.adsr.sustainLevel   = finiteOr(bitsToDouble(r.u64()), fallback.adsr.sustainLevel);
    p.play.adsr.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.releaseSeconds);
}

// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
// is not on the wire, it is a property of the slot. fromPoints repairs the X-order/endpoint
// invariant defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve,
                   reasampler::instrument::engine::CurveDomain domain) {
    const std::uint32_t ptCount = r.u32();
    std::vector<VelocityPoint> pts;
    // Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
    // can't trigger a giant allocation before the bounded reads fail.
    const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
    pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
    for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
        const double vel = bitsToDouble(r.u64());
        const double value = bitsToDouble(r.u64());
        // A NaN velocity breaks fromPoints' stable_sort (not a strict weak ordering with NaN
        // present); a NaN value reaches the RT eval's multiply. Same non-finite-falls-back-to-0
        // guard as every other wire double this codec reads.
        pts.push_back(VelocityPoint{std::isfinite(vel) ? vel : 0.0,
                                    std::isfinite(value) ? value : 0.0});
    }
    if (r.ok) {
        curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
    }
}

// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
// value, which is what makes a pre-v13 blob play exactly as it did.
void readSplineEnv(ByteReader& r, SplineEnv& s) {
    const bool spline = (r.u8() != 0);
    const std::uint32_t ptCount = r.u32();
    std::vector<VelocityPoint> pts;
    // Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
    // can't trigger a giant allocation before the bounded reads fail.
    const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
    pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 17));
    for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
        const double x = bitsToDouble(r.u64());
        const double y = bitsToDouble(r.u64());
        const bool hard = (r.u8() != 0);
        // Same NaN guard as readCurveTail: an x NaN breaks fromPoints' sort, a y NaN reaches
        // SplineCursor::eval's multiply into the per-sample amp gain.
        pts.push_back(VelocityPoint{std::isfinite(x) ? x : 0.0, std::isfinite(y) ? y : 0.0, hard});
    }
    if (!r.ok) return;
    s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
    if (pts.size() < 2) {
        // fromPoints' own sub-2-point fallback is flat()/zero() by DOMAIN — the neutral velocity
        // curve response (a full-open gate). A spline EG's documented neutral is y = 1 - x
        // instead, so a malformed/short block substitutes that rather than fromPoints' default.
        s.contour = VelocityCurve::rampDown();
        return;
    }
    s.contour = VelocityCurve::fromPoints(std::move(pts),
                                          reasampler::instrument::engine::CurveDomain::Unipolar);
}

// A block whose declared length overruns what the blob still holds has no realignment point:
// every byte after it belongs to a block that was truncated, so a later tail that reads them
// as its own gets an ARBITRARY value — and a tail that clamps (the bake Hold does) turns that
// into a legal-looking fabrication rather than an obvious one. Draining is what makes the
// stream's end honest: each later tail then reads past it and degrades to absent through its
// own revive, while the record that parsed cleanly ahead of the damage survives.
void drainUnaligned(ByteReader& r) { r.pos = r.bytes.size(); }

// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
// curve fromPoints actually produced — including an out-of-bounds or truncated one — is
// dropped rather than applied to shifted knots, and the whole params record parsed ahead of
// this tail survives (component_state_io.h's documented promise): if THIS call is what tripped
// r.ok (a truncated count field), it is revived before returning. An r.ok already false on
// entry (an earlier, unrelated field genuinely truncated) is left alone — that failure is not
// this tail's to forgive.
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
    const bool enteredOk = r.ok;
    const std::uint32_t count = r.u32();
    if (!r.ok) {
        if (enteredOk) r.ok = true;  // a truncated count field: nothing to apply
        return;
    }
    const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
    if (count > remaining) {
        drainUnaligned(r);  // the flags this count promised are not all there
        return;
    }
    std::vector<std::uint8_t> flags;
    flags.reserve(count);
    for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8());
    if (flags.size() != curve.size()) return;
    for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}

// THE shared ending for every appended tail past the hard flags: revive, then DRAIN. Both
// halves are load-bearing and neither is optional.
//
// Revive, because these tails reach no audio path — a blob truncated inside one must cost
// that field alone and not reset the whole record that parsed cleanly ahead of it. An r.ok
// already false on entry (an earlier, unrelated field genuinely truncated) is left alone;
// that failure is not this tail's to forgive.
//
// Drain, because a FAILED read does not advance the cursor. The bytes it rejected are still
// sitting there for the NEXT tail to consume as its own — a truncated Hold whose two
// surviving exponent bytes arrive at the limiter byte reads back as ENABLED. Reviving without
// draining does not degrade to absent; it fabricates. Every tail added after this one must
// end here too.
//
// Returns true when the caller must abandon its field.
bool reviveTruncatedTail(ByteReader& r, bool enteredOk) {
    if (r.ok) return false;
    if (enteredOk) r.ok = true;
    drainUnaligned(r);
    return true;
}

// Read the v14 bake Hold.
void readBakeHold(ByteReader& r, InstrumentParams& p) {
    const bool enteredOk = r.ok;
    const std::int32_t exponent = r.i32();
    const std::uint8_t modifier = r.u8();
    if (reviveTruncatedTail(r, enteredOk)) return;
    // makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung
    // rather than an unrepresentable one — never a memcpy into the type.
    p.bakeHold = note::makeDivision(exponent, static_cast<note::DivisionModifier>(modifier));
}

// Read the v15 limiter enable. Bypassed is what a truncation means and what the field already
// holds, so a missing byte costs nothing beyond the enable itself.
void readLimiterEnable(ByteReader& r, InstrumentParams& p) {
    const bool enteredOk = r.ok;
    const std::uint8_t flag = r.u8();
    if (reviveTruncatedTail(r, enteredOk)) return;
    p.limiterEnabled = (flag != 0);
}

// Read the v16 rate + pitch-offset pair. A truncation, or either value unusable, leaves the
// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16
// blob means and what every instance before them played.
//
// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather
// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could
// disagree with it — but a value that only playback clamped would re-serialize out of range and
// leave the stored value disagreeing with the needle, and with the host normalization once the
// instrument reports parameters. Finiteness stays a separate test in front of it, because
// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a
// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because
// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild
// exponent there is UB on the per-sample path.
void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
    const bool enteredOk = r.ok;
    const double rate = bitsToDouble(r.u64());
    const double offset = bitsToDouble(r.u64());
    if (reviveTruncatedTail(r, enteredOk)) return;
    if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate);
    // The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and
    // the velocity->pitch curve speak (play_params.h), reached directly rather than through the
    // deck's alias of it.
    if (std::isfinite(offset) && offset >= -kVelocityPitchRangeSemitones &&
        offset <= kVelocityPitchRangeSemitones) {
        p.play.pitchOffsetSemitones = offset;
    }
}

// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
// v12 domain re-tag needs no version branch (see component_state_io.h).
void readFilterTail(ByteReader& r, InstrumentParams& p) {
    const FilterSeconds fallback;  // the construction defaults, read rather than restated
    FilterSeconds& f = p.play.filter;
    f.enabled = (r.u8() != 0);
    f.settings.cutoffNorm    = static_cast<float>(bitsToDouble(r.u64()));
    f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
    f.settings.morphNorm     = static_cast<float>(bitsToDouble(r.u64()));
    f.settings.driveNorm     = static_cast<float>(bitsToDouble(r.u64()));
    f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
                                        : engine::filter::MorphLaw::HighBandLow;
    // Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
    // reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
    double modAmount = bitsToDouble(r.u64());
    double velAmount = bitsToDouble(r.u64());
    double keyTrack  = bitsToDouble(r.u64());
    f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
    f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
    f.keyTrack  = std::isfinite(keyTrack) ? keyTrack : 0.0;
    f.env.attackSeconds  = finiteOr(bitsToDouble(r.u64()), fallback.env.attackSeconds);
    f.env.holdSeconds    = finiteOr(bitsToDouble(r.u64()), fallback.env.holdSeconds);
    f.env.decaySeconds   = finiteOr(bitsToDouble(r.u64()), fallback.env.decaySeconds);
    f.env.sustainLevel   = finiteOr(bitsToDouble(r.u64()), fallback.env.sustainLevel);
    f.env.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.releaseSeconds);
    readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
}

// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
// rather than to an endpoint: neutral is the one exponent that cannot change how a stage
// sounds, so a damaged blob loses the shaping instead of inventing one.
double readCurveExponent(ByteReader& r) {
    const double v = bitsToDouble(r.u64());
    return std::isfinite(v) ? reasampler::util::clampCurve(v) : reasampler::util::kCurveNeutral;
}

void readAhd(ByteReader& r, AhdSeconds& a) {
    // attackSeconds/decaySeconds reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp)
    // unguarded — UB on NaN, and on a large-enough finite value — so a corrupt/non-finite wire
    // value degrades to 0 seconds rather than reaching that cast, the same guard readSecondsPlayTail
    // and the v9 filter tail already apply to their own wall-clock fields.
    const double attack = bitsToDouble(r.u64());
    const double decay  = bitsToDouble(r.u64());
    a.attackSeconds = std::isfinite(attack) ? attack : 0.0;
    a.decaySeconds  = std::isfinite(decay) ? decay : 0.0;
    const double frac = bitsToDouble(r.u64());
    a.holdFraction  = std::isfinite(frac) ? frac : 0.0;
    a.attackCurve   = readCurveExponent(r);
    a.decayCurve    = readCurveExponent(r);
}

// Read the v10 staged-curve tail into `p`. A blob that stops short leaves the neutral
// exponents and the fade-lifted Trigger AHD, which is what makes a v9 blob play as before.
void readCurveStageTail(ByteReader& r, InstrumentParams& p) {
    PlaySeconds& pp = p.play;
    pp.adsr.attackCurve  = readCurveExponent(r);
    pp.adsr.decayCurve   = readCurveExponent(r);
    pp.adsr.releaseCurve = readCurveExponent(r);
    readAhd(r, pp.trigAhd);
    const double pitchHold = bitsToDouble(r.u64());
    pp.pitchEnv.shape.holdFraction = std::isfinite(pitchHold) ? pitchHold : 0.0;
    pp.pitchEnv.shape.attackCurve  = readCurveExponent(r);
    pp.pitchEnv.shape.decayCurve   = readCurveExponent(r);
    pp.filter.env.attackCurve  = readCurveExponent(r);
    pp.filter.env.decayCurve   = readCurveExponent(r);
    pp.filter.env.releaseCurve = readCurveExponent(r);
    readAhd(r, pp.filter.trigEnv);
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
// no file and no bank entry.
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
// blobs carry seconds directly and need no rate.
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
    PayloadRead out;
    const bool extended      = (pv >= 2);  // v2+: the loop/start tail is present
    const bool legacyV3Play  = (pv == 3);  // legacy play tail, wall-clock in nominal frames
    const bool secondsPlay   = (pv >= 5);  // v5+: full play params, wall-clock in seconds
    const bool keyTrackTail  = (pv >= 6);  // v6+: keyTrack scalar
    const bool curveTail     = (pv >= 7);  // v7+: velocity->amp curve, appended last
    const std::uint32_t count = r.u32();
    bool adopted = false;
    for (std::uint32_t i = 0; i < count && r.ok; ++i) {
        // A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
        // tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
        InstrumentParams p;
        std::string sampleId;
        const std::uint32_t idLen = r.u32();
        sampleId = r.str(idLen);
        r.i32();  // lowNote  — the retired key range; read to keep the record walk aligned
        r.i32();  // highNote
        const std::uint8_t hasOverride = r.u8();
        if (hasOverride) p.rootOverride = clampMidiNote(r.i32());
        if (extended) {
            const std::uint8_t hasLoop = r.u8();
            if (hasLoop) {
                SampleLoop lp;
                lp.hasLoop = (r.u8() != 0);
                lp.start = r.i64();
                lp.end = r.i64();
                p.loopOverride = lp;
            }
            const std::uint8_t hasStart = r.u8();
            if (hasStart) p.startPoint = r.i64();
        }
        if (legacyV3Play) {
            // LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
            // frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
            // are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
            assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
            const double liftRate = projectRate > 0.0 ? projectRate : 1.0;  // avoids div-by-zero; assert fires first
            const PlaySeconds fallback;  // same guard as readSecondsPlayTail's peer fields
            p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
            p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
            p.play.trigger.lengthFraction =
                finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
            const std::int64_t fadeIn = r.i64();
            const std::int64_t fadeOut = r.i64();
            liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd);
            p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
            p.play.pitchEnv.enabled = (r.u8() != 0);
            p.play.pitchEnv.shape.attackSeconds = static_cast<double>(r.i64()) / liftRate;
            p.play.pitchEnv.shape.decaySeconds  = static_cast<double>(r.i64()) / liftRate;
            p.play.pitchEnv.peakSemitones =
                finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
        } else if (secondsPlay) {
            readSecondsPlayTail(r, p, projectRate);
        }
        // A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
        // repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat(). A NaN
        // reaches keyTrackedRatio -> baseRatio_ -> readPos_'s per-sample cast (voice.h) — the
        // same guard the v8+ single-record reader applies to its own keyTrack below.
        if (keyTrackTail) p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
        if (curveTail) {
            readCurveTail(r, p.velocityCurve,
                          reasampler::instrument::engine::CurveDomain::Unipolar);
        }
        // Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
        // leave the seconds product defaults on p.play.
        if (!r.ok) break;  // truncated mid-record -> keep what parsed cleanly, drop the rest
        if (!adopted) {
            out.params = std::move(p);
            out.adoptedSampleId = std::move(sampleId);
            adopted = true;
        }
    }
    return out;
}

}  // namespace


// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
    putLE(out, kParamsFormatMarker);
    putLE(out, kParamsPayloadVersion);
    putOverrides(out, p);

    // Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
    // source frames/fraction. Field order matches the header's v5 tail spec verbatim.
    const PlaySeconds& pp = p.play;
    out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
    putLE(out, doubleToBits(pp.adsr.holdSeconds));            // wall-clock seconds
    putLE(out, doubleToBits(pp.trigger.lengthFraction));      // fraction
    // The retired fade pair's two frozen slots (see the header): the shape stays, the values
    // moved into the Trigger AHD tail below.
    putLE(out, asU64(std::int64_t{0}));
    putLE(out, asU64(std::int64_t{0}));
    out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
    out.push_back(pp.pitchEnv.enabled ? 1 : 0);
    putLE(out, doubleToBits(pp.pitchEnv.shape.attackSeconds));  // wall-clock seconds
    putLE(out, doubleToBits(pp.pitchEnv.shape.decaySeconds));   // wall-clock seconds
    putLE(out, doubleToBits(pp.pitchEnv.peakSemitones));        // depth
    // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
    putLE(out, doubleToBits(pp.adsr.attackSeconds));
    putLE(out, doubleToBits(pp.adsr.decaySeconds));
    putLE(out, doubleToBits(pp.adsr.sustainLevel));
    putLE(out, doubleToBits(pp.adsr.releaseSeconds));
    // Key-tracking scalar (1.0 = 100% ET).
    putLE(out, doubleToBits(p.keyTrack));
    // The velocity->amp transfer curve: 4-byte LE control-point count, then per point
    // velocity + amp as doubles (endpoints included, so N >= 2).
    putCurve(out, p.velocityCurve);
    // v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
    // whole payload stays one numeric shape.
    const FilterSeconds& f = pp.filter;
    out.push_back(f.enabled ? 1 : 0);
    putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
    putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
    putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
    putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
    out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
    putLE(out, doubleToBits(f.modAmount));
    putLE(out, doubleToBits(f.velAmount));
    putLE(out, doubleToBits(f.keyTrack));
    putLE(out, doubleToBits(f.env.attackSeconds));
    putLE(out, doubleToBits(f.env.holdSeconds));
    putLE(out, doubleToBits(f.env.decaySeconds));
    putLE(out, doubleToBits(f.env.sustainLevel));
    putLE(out, doubleToBits(f.env.releaseSeconds));
    putCurve(out, f.velocityCurve);
    // v10: the staged-curve tail.
    putLE(out, doubleToBits(pp.adsr.attackCurve));
    putLE(out, doubleToBits(pp.adsr.decayCurve));
    putLE(out, doubleToBits(pp.adsr.releaseCurve));
    putAhd(out, pp.trigAhd);
    putLE(out, doubleToBits(pp.pitchEnv.shape.holdFraction));
    putLE(out, doubleToBits(pp.pitchEnv.shape.attackCurve));
    putLE(out, doubleToBits(pp.pitchEnv.shape.decayCurve));
    putLE(out, doubleToBits(f.env.attackCurve));
    putLE(out, doubleToBits(f.env.decayCurve));
    putLE(out, doubleToBits(f.env.releaseCurve));
    putAhd(out, f.trigEnv);
    // v11: the loop crossfade, in SOURCE frames.
    putLE(out, asU64(p.loopCrossfadeFrames));
    // v12: the velocity->pitch curve.
    putCurve(out, pp.pitchVelocityCurve);
    // v13: the dual Staged/Spline state — the three contours, then the hard flags the three
    // frozen velocity-curve blocks above had no room for.
    putSplineEnv(out, pp.ampSpline);
    putSplineEnv(out, pp.pitchSpline);
    putSplineEnv(out, pp.filterSpline);
    putHardFlags(out, p.velocityCurve);
    putHardFlags(out, f.velocityCurve);
    putHardFlags(out, pp.pitchVelocityCurve);
    // v14: the bake's Hold division, as its {quarterExponent, modifier} pair — never its
    // picker index, which the ladder gaining a rung would silently re-map.
    putLE(out, static_cast<std::uint32_t>(
                   static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
    out.push_back(static_cast<std::uint8_t>(p.bakeHold.modifier()));
    // v15: the master-bus limiter enable.
    out.push_back(p.limiterEnabled ? 1 : 0);
    // v16: the playback rate (a ratio) and the baseline pitch offset (semitones), both rate-free.
    putLE(out, doubleToBits(pp.playRate));
    putLE(out, doubleToBits(pp.pitchOffsetSemitones));
}

// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
    std::uint32_t pv = 0;  // 0 = v1, no marker
    if (r.peekU32() == kParamsFormatMarker) {
        r.u32();       // consume the marker
        pv = r.u32();  // payload version
    }
    if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);

    PayloadRead out;
    InstrumentParams& p = out.params;
    const std::uint8_t hasRoot = r.u8();
    if (hasRoot) p.rootOverride = clampMidiNote(r.i32());
    const std::uint8_t hasLoop = r.u8();
    if (hasLoop) {
        SampleLoop lp;
        lp.hasLoop = (r.u8() != 0);
        lp.start = r.i64();
        lp.end = r.i64();
        p.loopOverride = lp;
    }
    const std::uint8_t hasStart = r.u8();
    if (hasStart) p.startPoint = r.i64();
    readSecondsPlayTail(r, p, projectRate);
    // NaN reaches keyTrackedRatio (voice.h) -> baseRatio_ -> readPos_'s per-sample
    // static_cast<std::int64_t> — UB on the per-sample path. Guarded here, codec-side, so
    // that path needs no check of its own.
    p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
    readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
    if (pv >= kParamsFilterVersion) readFilterTail(r, p);
    if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
    if (pv >= kParamsLoopVersion) {
        // A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
        // it here keeps the parameter set itself sane for the editor that reads it back.
        const std::int64_t xf = r.i64();
        p.loopCrossfadeFrames = xf > 0 ? xf : 0;
    }
    if (pv >= kParamsVelocityVersion) {
        readCurveTail(r, p.play.pitchVelocityCurve,
                      reasampler::instrument::engine::CurveDomain::Bipolar);
    }
    if (pv >= kParamsSplineVersion) {
        readSplineEnv(r, p.play.ampSpline);
        readSplineEnv(r, p.play.pitchSpline);
        readSplineEnv(r, p.play.filterSpline);
        readHardFlags(r, p.velocityCurve);
        readHardFlags(r, p.play.filter.velocityCurve);
        readHardFlags(r, p.play.pitchVelocityCurve);
    }
    if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
    if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p);
    if (pv >= kParamsRateVersion) readRateAndPitchOffset(r, p);
    // A truncated record leaves whatever parsed plus construction defaults for the rest —
    // the same degrade-don't-throw contract the zone ladder always had.
    if (!r.ok) return PayloadRead{};
    return out;
}

}  // namespace reasampler::instrument::map
