// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework.
// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart:
// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved ->
// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the
// selected-sample instance-state (de)serialization.
//
// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built
// by serializing a real BankBook (so we exercise the shared parse, not a fixture string),
// and the selection / downmix / keymap / state values are checked against independently
// computed expectations.
//
// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal
// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob ->
// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C
// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/
// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono
// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single
// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection
// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3)
// round-trip + v1/v2 back-compat lift + empty/unknown -> empty.
// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave-
// stride contract across the seam (that the byte stride wav_trim reports matches the
// channel-count stride downmixToMono divides by).

#include "../src/vst/sample_map.h"

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

#include "../src/bank_book.h"
#include "../src/bank_model.h"

using namespace reasampler;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

// Build a Sample with the fields sample_map reads. Relative path is required by
// BankIndex::add (relative-only invariant); a content hash is set so dedup does not
// collapse distinct entries.
static Sample makeSample(const std::string& id, const std::string& name,
                         const std::string& rel, std::optional<int> root) {
    Sample s;
    s.id = id;
    s.displayName = name;
    s.relativePath = rel;
    s.contentHash = "hash-" + id;
    s.rootNote = root;
    return s;
}

// A serialized BankBook: the pool carries `poolSamples`, and one named bank "Drums"
// carries `drumSamples`. Returns the JSON the instrument would read from ext-state.
static std::string bookJson(const std::vector<Sample>& poolSamples,
                            const std::vector<Sample>& drumSamples) {
    BankBook book;
    for (const Sample& s : poolSamples) book.pool().index.add(s);
    if (!drumSamples.empty()) {
        book.createBank("drums-id", "Drums");
        BankIndex* di = book.index("drums-id");
        for (const Sample& s : drumSamples) di->add(s);
    }
    return book.serialize();
}

// --- selectSample -------------------------------------------------------------

static void testSelectByIdHit() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
        {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
    // A sample in the NAMED bank resolves by id (search spans every bank).
    auto sel = selectSample(json, "b");
    CHECK(sel.has_value());
    CHECK(sel && sel->relativePath == "reasampler_bank/b.wav");
    CHECK(sel && sel->rootNote == 38);
}

static void testSelectEmptyIdIsSilence() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
        {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
    // POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the
    // bank's first sample. A fresh instance plays nothing and shows the "pick a capture"
    // empty state — the deliberate reversal of the S4 first-sample auto-play.
    auto sel = selectSample(json, "");
    CHECK(!sel.has_value());
}

static void testSelectUnknownIdIsSilence() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
    // A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted
    // first sample — the editor reflects the missing pick with its empty state rather than
    // masking it with a mystery sample.
    auto sel = selectSample(json, "deleted-id");
    CHECK(!sel.has_value());
}

static void testSelectRootNoteDefault() {
    const std::string json = bookJson(
        {makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {});
    // A sample with no root-note intrinsic defaults to middle C (60).
    auto sel = selectSample(json, "a");
    CHECK(sel.has_value());
    CHECK(sel && sel->rootNote == 60);
}

static void testSelectLoopThreaded() {
    Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60);
    s.loop = LoopPoints{100, 500};
    const std::string json = bookJson({s}, {});
    auto sel = selectSample(json, "a");
    CHECK(sel.has_value());
    CHECK(sel && sel->loop.hasLoop);
    CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500);
}

static void testSelectNoLoopIsAbsent() {
    const std::string json = bookJson(
        {makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {});
    auto sel = selectSample(json, "a");
    CHECK(sel.has_value());
    CHECK(sel && !sel->loop.hasLoop);  // absent loop -> hasLoop false (not a zero loop)
}

static void testSelectEmptyBlob() {
    CHECK(!selectSample("", "a").has_value());
}

static void testSelectMalformedBlob() {
    CHECK(!selectSample("{not valid json", "a").has_value());
}

static void testSelectZeroSamples() {
    // A valid book with NO samples anywhere -> nothing to play.
    const std::string json = bookJson({}, {});
    CHECK(!selectSample(json, "").has_value());
    CHECK(!selectSample(json, "anything").has_value());
}

// --- listSamples --------------------------------------------------------------

static void testListSamplesOrdinalOrder() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "reasampler_bank/a.wav", 36),
         makeSample("c", "Hat", "reasampler_bank/c.wav", 42)},
        {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
    const std::vector<SampleChoice> list = listSamples(json);
    // Pool samples (insertion order) come before the named bank's.
    CHECK(list.size() == 3);
    CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick");
    CHECK(list.size() == 3 && list[1].id == "c");
    CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare");
}

static void testListSamplesCarriesCardMetadata() {
    // The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample
    // reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample
    // reports no rootNote (the badge shows "root —", never a guessed value).
    Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36);
    rooted.key = "Cm";
    Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt);
    const std::string json = bookJson({rooted, unrooted},
                                      {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
    const std::vector<SampleChoice> list = listSamples(json);
    CHECK(list.size() == 3);
    // Pool sample "a": rooted + keyed, pool bank id.
    CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36);
    CHECK(list[0].key.has_value() && *list[0].key == "Cm");
    CHECK(!list[0].bankId.empty());  // the pool has an id; the filter matches on it
    // Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —").
    CHECK(list[1].id == "u" && !list[1].rootNote.has_value());
    // Named-bank sample "b": its bank id distinguishes it from the pool for the filter.
    CHECK(list[2].id == "b" && list[2].bankId == "drums-id");
    CHECK(list[2].bankId != list[0].bankId);  // pool vs. named bank differ (filterable apart)
}

static void testListSamplesEmptyAndMalformed() {
    CHECK(listSamples("").empty());
    CHECK(listSamples("{garbage").empty());
    CHECK(listSamples(bookJson({}, {})).empty());
}

static void testListBanksOrdinalOrder() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
        {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
    const std::vector<BankChoice> banks = listBanks(json);
    // Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter
    // tab strip can key on them.
    CHECK(banks.size() == 2);
    CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums");
    CHECK(banks.size() == 2 && !banks[0].id.empty());  // the pool bank has an id too
}

static void testListBanksEmptyAndMalformed() {
    CHECK(listBanks("").empty());
    CHECK(listBanks("{garbage").empty());
    // A valid book with no samples still has the pool bank -> one entry.
    CHECK(listBanks(bookJson({}, {})).size() == 1);
}

// --- downmixToMono ------------------------------------------------------------

static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; }

static void testDownmixMonoPassthrough() {
    const std::vector<AudioSample> in{0.1f, -0.2f, 0.3f};
    const std::vector<AudioSample> out = downmixToMono(in, 1);
    CHECK(out.size() == 3);
    CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) &&
          approx(out[2], 0.3));
}

static void testDownmixStereoAverages() {
    // Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5.
    const std::vector<AudioSample> in{1.0f, 0.0f, 0.4f, 0.6f};
    const std::vector<AudioSample> out = downmixToMono(in, 2);
    CHECK(out.size() == 2);
    CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5));
}

static void testDownmixThreeChannelAverages() {
    // One 3-channel frame (0.3, 0.3, 0.6) -> 0.4.
    const std::vector<AudioSample> in{0.3f, 0.3f, 0.6f};
    const std::vector<AudioSample> out = downmixToMono(in, 3);
    CHECK(out.size() == 1);
    CHECK(out.size() == 1 && approx(out[0], 0.4));
}

static void testDownmixDegenerate() {
    CHECK(downmixToMono({}, 2).empty());          // empty input
    CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride
    CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride
}

// --- buildTier0Keymap ---------------------------------------------------------

static void testBuildKeymapSingleFullZone() {
    SampleLoop loop;
    loop.hasLoop = true;
    loop.start = 10;
    loop.end = 90;
    const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop);
    // One sample, one zone spanning the whole keyboard, rooted at 40.
    CHECK(km.samples.size() == 1);
    CHECK(km.zones.size() == 1);
    CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127);
    CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40);
    CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000);
    CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40);
    CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3);
    CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
          km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90);
    // Resolution: any note lands in the single zone.
    CHECK(km.resolve(0, 100).matched);
    CHECK(km.resolve(127, 100).matched);
}


// --- selection state (setState/getState) --------------------------------------

static void testSelectionStateRoundTrip() {
    const std::string id = "sample-guid-123";
    const std::vector<std::uint8_t> bytes = serializeSelection(id);
    // Versioned: 4-byte tag + the id bytes.
    CHECK(bytes.size() == 4 + id.size());
    CHECK(deserializeSelection(bytes) == id);
}


static void testSelectionStateEmptyId() {
    const std::vector<std::uint8_t> bytes = serializeSelection("");
    CHECK(bytes.size() == 4);            // just the version tag
    CHECK(deserializeSelection(bytes) == "");
}

static void testSelectionStateWrongVersion() {
    std::vector<std::uint8_t> bytes = serializeSelection("id");
    bytes[0] = 0xEE;  // corrupt the version tag
    CHECK(deserializeSelection(bytes) == "");  // unknown version -> no selection
}

static void testSelectionStateTruncated() {
    CHECK(deserializeSelection({}) == "");             // empty
    CHECK(deserializeSelection({1, 0, 0}) == "");      // fewer than 4 bytes (no tag)
}

// --- wav_trim -> extractFloatFrames -> downmixToMono integration ---------------
//
// Locks the interleave-stride contract at the seam between wav_trim and sample_map:
// wav_trim reports channelCount, extractFloatFrames yields interleaved samples with
// that stride, and downmixToMono divides by that same stride. If either module
// changed its understanding of the layout (e.g. extractFloatFrames started packing
// differently, or downmixToMono changed its stride divisor), this test catches it.

static void putU16sm(std::vector<std::uint8_t>& b, std::uint16_t v) {
    b.push_back(static_cast<std::uint8_t>(v & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
}
static void putU32sm(std::vector<std::uint8_t>& b, std::uint32_t v) {
    b.push_back(static_cast<std::uint8_t>(v & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
static void putTagsm(std::vector<std::uint8_t>& b, const char* t) {
    for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
}
static void putFloatsm(std::vector<std::uint8_t>& b, float f) {
    std::uint8_t tmp[4];
    std::memcpy(tmp, &f, 4);
    for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
}

// Build a 32-bit-float WAV byte buffer. Samples: frame f, channel c = value(f, c).
template <typename Fn>
static std::vector<std::uint8_t> buildWav(std::uint16_t channels,
                                          std::uint32_t sampleRate,
                                          std::size_t frames,
                                          Fn value) {
    const std::uint32_t dataBytes =
        static_cast<std::uint32_t>(frames * channels * 4u);
    std::vector<std::uint8_t> chunks;
    putTagsm(chunks, "fmt ");
    putU32sm(chunks, 16);
    putU16sm(chunks, 3);                                              // IEEE float
    putU16sm(chunks, channels);
    putU32sm(chunks, sampleRate);
    putU32sm(chunks, sampleRate * channels * 4u);
    putU16sm(chunks, static_cast<std::uint16_t>(channels * 4));
    putU16sm(chunks, 32);
    putTagsm(chunks, "data");
    putU32sm(chunks, dataBytes);
    for (std::size_t f = 0; f < frames; ++f)
        for (std::uint16_t c = 0; c < channels; ++c)
            putFloatsm(chunks, value(f, c));
    std::vector<std::uint8_t> wav;
    putTagsm(wav, "RIFF");
    putU32sm(wav, static_cast<std::uint32_t>(4 + chunks.size()));
    putTagsm(wav, "WAVE");
    wav.insert(wav.end(), chunks.begin(), chunks.end());
    return wav;
}

static void testWavTrimToDownmixPipelineStereo() {
    // Stereo WAV: frame f, L = f * 0.1f, R = f * 0.1f + 0.5f. Expected mono average:
    // (f * 0.1f + f * 0.1f + 0.5f) / 2 = f * 0.1f + 0.25f.
    const std::size_t kFrames = 4;
    auto wav = buildWav(2, 48000, kFrames,
                        [](std::size_t f, std::uint16_t c) {
                            return static_cast<float>(f) * 0.1f + (c == 1 ? 0.5f : 0.0f);
                        });
    WavLayout layout = parseWavLayout(wav);
    CHECK(layout.valid);
    CHECK(layout.channelCount == 2);
    CHECK(layout.frameCount() == kFrames);
    const std::vector<AudioSample> interleaved =
        extractFloatFrames(wav, layout, 0, layout.frameCount());
    CHECK(interleaved.size() == kFrames * 2);
    const std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
    CHECK(mono.size() == kFrames);
    for (std::size_t f = 0; f < kFrames; ++f) {
        const float expected = static_cast<float>(f) * 0.1f + 0.25f;
        CHECK(approx(mono[f], expected));
    }
}

static void testWavTrimToDownmixPipelineMono() {
    // Mono WAV: extractFloatFrames -> downmixToMono with channelCount==1 is a passthrough.
    const std::size_t kFrames = 3;
    auto wav = buildWav(1, 44100, kFrames,
                        [](std::size_t f, std::uint16_t) {
                            return static_cast<float>(f) * 0.5f;
                        });
    WavLayout layout = parseWavLayout(wav);
    CHECK(layout.valid);
    CHECK(layout.channelCount == 1);
    const std::vector<AudioSample> interleaved =
        extractFloatFrames(wav, layout, 0, layout.frameCount());
    CHECK(interleaved.size() == kFrames);
    const std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
    CHECK(mono.size() == kFrames);
    CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0));
}

// --- performance map: resolvePerformance --------------------------------------

static PerformanceZone zone(const std::string& id, int lo, int hi,
                            std::optional<int> rootOverride = std::nullopt) {
    PerformanceZone z;
    z.sampleId = id;
    z.lowNote = lo;
    z.highNote = hi;
    z.rootOverride = rootOverride;
    return z;
}

static void testResolveEmptyMap() {
    // An empty performance map resolves to nothing (the shell falls back to Tier 0).
    const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
    const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{});
    CHECK(r.zones.empty());
    CHECK(r.droppedSampleIds.empty());
}

static void testResolveEmptyBlob() {
    PerformanceMap m;
    m.zones.push_back(zone("a", 0, 127));
    CHECK(resolvePerformance("", m).zones.empty());          // no bank
    CHECK(resolvePerformance("{garbage", m).zones.empty());  // malformed
}

static void testResolveMultiZoneAcrossBanks() {
    const std::string json = bookJson(
        {makeSample("a", "Kick", "b/a.wav", 36)},
        {makeSample("b", "Snare", "b/b.wav", 38)});
    PerformanceMap m;
    m.zones.push_back(zone("a", 36, 47));
    m.zones.push_back(zone("b", 48, 59));
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 2);
    CHECK(r.droppedSampleIds.empty());
    // Order preserved; paths + ranges threaded.
    CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
    CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47);
    CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav");
    CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59);
}

static void testResolveStaleIdDropsZone() {
    // STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the
    // surviving zone still resolves (the whole map is NOT abandoned).
    const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
    PerformanceMap m;
    m.zones.push_back(zone("a", 0, 59));
    m.zones.push_back(zone("ghost", 60, 127));  // no such sample
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 1);
    CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
    CHECK(r.droppedSampleIds.size() == 1);
    CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
}

static void testResolveRootPrecedence() {
    // Override beats bank intrinsic beats middle-C default.
    const std::string json = bookJson(
        {makeSample("rooted", "R", "b/r.wav", 40),          // bank intrinsic 40
         makeSample("unrooted", "U", "b/u.wav", std::nullopt)},  // no intrinsic
        {});
    PerformanceMap m;
    m.zones.push_back(zone("rooted", 0, 42));                    // no override -> 40
    m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72
    m.zones.push_back(zone("unrooted", 85, 127));               // no intrinsic -> 60
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 3);
    CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40);  // bank intrinsic
    CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72);  // override wins
    CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60);  // middle-C default
}

static void testResolveLoopThreaded() {
    Sample s = makeSample("a", "Pad", "b/a.wav", 60);
    s.loop = LoopPoints{200, 800};
    const std::string json = bookJson({s}, {});
    PerformanceMap m;
    m.zones.push_back(zone("a", 0, 127));
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 1);
    CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
    CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800);
    // No loop override + no startPoint -> effective start is 0 (S11 default).
    CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0);
}

static void testResolveLoopOverrideWins() {
    // S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the
    // startPoint feeds the effective startFrame — without mutating the bank.
    Sample s = makeSample("a", "Pad", "b/a.wav", 60);
    s.loop = LoopPoints{200, 800};                 // bank intrinsic
    const std::string json = bookJson({s}, {});
    PerformanceMap m;
    PerformanceZone z = zone("a", 0, 127);
    SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000;
    z.loopOverride = over;                          // instrument override
    z.startPoint = 512;                             // start offset
    m.zones.push_back(z);
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
    CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000);
    CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512);
}

static void testResolveLoopOverrideDisablesLoop() {
    // A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance
    // (override present-but-empty wins over the intrinsic — a deliberate "no loop here").
    Sample s = makeSample("a", "Pad", "b/a.wav", 60);
    s.loop = LoopPoints{200, 800};
    const std::string json = bookJson({s}, {});
    PerformanceMap m;
    PerformanceZone z = zone("a", 0, 127);
    z.loopOverride = SampleLoop{};                  // hasLoop=false, start=end=0
    m.zones.push_back(z);
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop);
}

// --- performance map: buildZonedKeymap ----------------------------------------

static void testBuildZonedKeymapMultiZone() {
    std::vector<ResolvedZone> zones;
    ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0);
    ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1);
    std::vector<DecodedZonePcm> decoded;
    decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
    decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000});
    const Keymap km = buildZonedKeymap(zones, decoded);
    CHECK(km.samples.size() == 2);
    CHECK(km.zones.size() == 2);
    // Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48.
    CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36);
    CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47);
    CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48);
    CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000);
    CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3);
    // Resolution: a note in each range lands in the right zone.
    CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
    CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
    // A note outside every zone does not match (no-play, not zone 0).
    CHECK(!km.resolve(24, 100).matched);
}

static void testBuildZonedKeymapThreadsLoopAndStart() {
    // S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the
    // voice honors them at note-on.
    std::vector<ResolvedZone> zones;
    ResolvedZone z0;
    z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60;
    z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7;
    z0.startFrame = 2;
    zones.push_back(z0);
    std::vector<DecodedZonePcm> decoded;
    decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100});
    const Keymap km = buildZonedKeymap(zones, decoded);
    CHECK(km.samples.size() == 1);
    CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
          km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7);
    CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2);
}

static void testBuildZonedKeymapDropsEmptyPcm() {
    // A zone whose decoded WAV is empty is dropped; the other zone survives, and the
    // survivor's sampleIndex points at ITS sample (not the dropped one's slot).
    std::vector<ResolvedZone> zones;
    ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0);
    ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1);
    std::vector<DecodedZonePcm> decoded;
    decoded.push_back(DecodedZonePcm{{}, 44100});          // empty -> dropped
    decoded.push_back(DecodedZonePcm{{0.9f}, 44100});      // survives
    const Keymap km = buildZonedKeymap(zones, decoded);
    CHECK(km.samples.size() == 1);
    CHECK(km.zones.size() == 1);
    CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0);  // remapped to slot 0
    CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72);
}

static void testBuildZonedKeymapOverlapFirstWins() {
    // OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note
    // (mirrors the S3 core's first-match resolve).
    std::vector<ResolvedZone> zones;
    ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0);
    ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1);
    std::vector<DecodedZonePcm> decoded;
    decoded.push_back(DecodedZonePcm{{0.1f}, 44100});
    decoded.push_back(DecodedZonePcm{{0.2f}, 44100});
    const Keymap km = buildZonedKeymap(zones, decoded);
    CHECK(km.zones.size() == 2);
    // Note 64 is in both zones; first-match resolves to zone 0.
    CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0);
}

static void testBuildZonedKeymapEmpty() {
    // No zones -> empty keymap (silence).
    const Keymap km = buildZonedKeymap({}, {});
    CHECK(km.samples.empty() && km.zones.empty());
    CHECK(!km.resolve(60, 100).matched);
}

// --- performance-map state: serialize / deserialize ---------------------------

static void testPerformanceStateRoundTrip() {
    PerformanceMap m;
    m.zones.push_back(zone("kick", 36, 47));                 // no override
    m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
    const std::vector<std::uint8_t> bytes = serializePerformance(m);
    const PerformanceMap back = deserializePerformance(bytes, 44100.0);
    CHECK(back.zones.size() == 2);
    CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
    CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
    CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value());
    CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare");
    CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() &&
          *back.zones[1].rootOverride == 50);
}

static void testPerformanceStateEmpty() {
    const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
    // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
    CHECK(bytes.size() == 16);
    CHECK(deserializePerformance(bytes, 44100.0).zones.empty());
}

static void testPerformanceStateLoopStartRoundTrip() {
    // S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip.
    PerformanceMap m;
    PerformanceZone z = zone("pad", 24, 96, /*override=*/64);
    SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890;
    z.loopOverride = lp;
    z.startPoint = 4096;
    m.zones.push_back(z);
    // A second zone with NO overrides proves the optional tail is per-record.
    m.zones.push_back(zone("kick", 0, 23));
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 2);
    CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
          *back.zones[0].rootOverride == 64);
    CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() &&
          back.zones[0].loopOverride->hasLoop &&
          back.zones[0].loopOverride->start == 12345 &&
          back.zones[0].loopOverride->end == 67890);
    CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() &&
          *back.zones[0].startPoint == 4096);
    // Zone 1: no overrides -> all optionals absent after round trip.
    CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value());
    CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value());
}

static void testPerformanceStateV1PayloadBackCompat() {
    // A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records)
    // parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact
    // shipped shape to prove the reader still accepts the marker-less payload.
    std::vector<std::uint8_t> b;
    auto u32 = [&](std::uint32_t v) {
        b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
        b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
    };
    u32(2);                       // envelope version 2
    u32(1);                       // zone count 1 (NOT the marker -> payload v1)
    const std::string id = "legacy";
    u32(static_cast<std::uint32_t>(id.size()));
    b.insert(b.end(), id.begin(), id.end());
    u32(10);                      // lowNote
    u32(40);                      // highNote
    b.push_back(0);               // hasRootOverride = 0 (record ends here in v1)
    const PerformanceMap back = deserializePerformance(b, 44100.0);
    CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
    CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
    CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
    CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value());
}

static void testComponentStateLoopStartRoundTrip() {
    // The overrides also round-trip through the v3 ComponentState envelope (zones nest inside
    // it), so the processor's live getState/setState preserves them — the composition property.
    ComponentState s;
    s.selectionId = "pick";
    PerformanceZone z = zone("pick", 0, 127);
    SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000;
    z.loopOverride = lp;
    z.startPoint = 128;
    s.map.zones.push_back(z);
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId == "pick");
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
          back.map.zones[0].loopOverride->start == 500 &&
          back.map.zones[0].loopOverride->end == 9000);
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() &&
          *back.map.zones[0].startPoint == 128);
}

static void testPerformanceStateV1BackCompat() {
    // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
    const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
    const PerformanceMap back = deserializePerformance(v1, 44100.0);
    CHECK(back.zones.size() == 1);
    CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
    CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
    CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
    // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
    CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty());
}

static void testPerformanceStateGarbage() {
    // Unknown version / truncated / empty -> empty map (never throws).
    CHECK(deserializePerformance({}, 44100.0).zones.empty());
    CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty());  // unknown version
    // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
    std::vector<std::uint8_t> t;
    t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0);  // version 2
    t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0);  // count 1
    // (no zone payload)
    CHECK(deserializePerformance(t, 44100.0).zones.empty());
}

static void testPerformanceStateNegativeNotesRoundTrip() {
    // Notes are clamped in the UI, but the wire format must survive the full int range so
    // a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
    PerformanceMap m;
    m.zones.push_back(zone("s", 0, 127, /*override=*/0));
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
          *back.zones[0].rootOverride == 0);
}

// --- Combined component state (v3, S10) --------------------------------------

static void testComponentStateRoundTrip() {
    // The v3 state carries the single-capture selection AND the opt-in zones, distinctly.
    ComponentState s;
    s.selectionId = "picked-capture";
    s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
    s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId == "picked-capture");
    CHECK(back.map.zones.size() == 2);
    CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
          back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value());
    CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" &&
          back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48);
}

static void testComponentStateSelectionOnlyNoZones() {
    // A single-capture instance: a pick, no zones. Must restore the pick with an empty map
    // (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
    ComponentState s;
    s.selectionId = "just-a-pick";
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId == "just-a-pick");
    CHECK(back.map.zones.empty());
}

static void testComponentStateEmptyIsEmpty() {
    // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
    const ComponentState s;  // selectionId "", empty map
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId.empty());
    CHECK(back.map.zones.empty());
}

static void testComponentStateV1BackCompat() {
    // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick
    // survives as BOTH the selection and a one-zone map.
    const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
    const ComponentState back = deserializeComponentState(v1, 44100.0);
    CHECK(back.selectionId == "legacy-id");
    CHECK(back.map.zones.size() == 1);
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" &&
          back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127);
    // A v1 blob with an EMPTY id -> empty state (no selection, no zone).
    const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0);
    CHECK(empty.selectionId.empty() && empty.map.zones.empty());
}

static void testComponentStateV2BackCompat() {
    // A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate
    // single-capture selection.
    PerformanceMap m;
    m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
    const std::vector<std::uint8_t> v2 = serializePerformance(m);
    const ComponentState back = deserializeComponentState(v2, 44100.0);
    CHECK(back.selectionId.empty());
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
          back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
}

static void testComponentStateGarbage() {
    // Empty / unknown version -> empty (never throws across the host).
    CHECK(deserializeComponentState({}, 44100.0).selectionId.empty());
    CHECK(deserializeComponentState({}, 44100.0).map.zones.empty());
    const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
    CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty());
    CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty());
    // A v3 header claiming a longer id than the blob holds -> empty (bounded read).
    std::vector<std::uint8_t> t;
    t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0);      // version 3
    t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0);    // id length 200 (absent)
    CHECK(deserializeComponentState(t, 44100.0).selectionId.empty());
    CHECK(deserializeComponentState(t, 44100.0).map.zones.empty());
}

// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------

static void testExtractChannelStereo() {
    // Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's.
    const std::vector<AudioSample> in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f};
    const std::vector<AudioSample> l = extractChannel(in, 2, 0);
    const std::vector<AudioSample> r = extractChannel(in, 2, 1);
    CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3));
    CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7));
}

static void testExtractChannelClampsToLast() {
    // A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block.
    const std::vector<AudioSample> mono{0.1f, 0.2f, 0.3f};
    const std::vector<AudioSample> ch1 = extractChannel(mono, 1, 1);
    CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3));  // == channel 0
    CHECK(extractChannel({}, 2, 0).empty());       // empty in
    CHECK(extractChannel({0.1f}, 0, 0).empty());   // zero stride
}

static void testDecodeChannelsMonoModeDownmixes() {
    // MONO mode: a stereo source averages to one channel (the existing policy), framesR empty.
    const std::vector<AudioSample> stereo{1.0f, 0.0f, 0.4f, 0.6f};  // frames (1,0) and (0.4,0.6)
    const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000);
    CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5));
    CHECK(d.framesR.empty());          // mono mode -> single channel
    CHECK(d.sampleRate == 48000);
}

static void testDecodeChannelsStereoModeStereoSource() {
    // STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct.
    const std::vector<AudioSample> stereo{0.1f, 0.9f, 0.2f, 0.8f};
    const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100);
    CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2));
    CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8));
}

static void testDecodeChannelsStereoModeMonoSourceDualMono() {
    // STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent).
    const std::vector<AudioSample> mono{0.3f, 0.6f, 0.9f};
    const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100);
    CHECK(d.monoFrames.size() == 3);
    CHECK(d.framesR.size() == 3);
    for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i]));  // R == L
}

// --- S7: buildTier0Keymap stereo threading ------------------------------------

static void testBuildKeymapStereoCarriesSecondChannel() {
    const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f});
    CHECK(km.samples.size() == 1);
    CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
    CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
          approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8));
}

static void testBuildKeymapMonoWhenNoSecondChannel() {
    // No framesR passed -> mono SampleData (byte-identical to the pre-S7 build).
    const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{});
    CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
    CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty());
}

static void testBuildKeymapDropsMismatchedSecondChannel() {
    // A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays).
    const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f});
    CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
}

static void testBuildZonedKeymapCarriesSecondChannel() {
    // The zoned build threads each zone's framesR when it length-matches channel 0.
    std::vector<ResolvedZone> zones;
    ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z);
    std::vector<DecodedZonePcm> decoded;
    DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f};
    decoded.push_back(d);
    const Keymap km = buildZonedKeymap(zones, decoded);
    CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
    CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
          approx(km.samples[0].framesR[1], 0.8));
}

// --- S7: component state v4 (channel mode) ------------------------------------

static void testComponentStateV4RoundTripStereo() {
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Stereo;
    s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId == "pick");
    CHECK(back.channelMode == ChannelMode::Stereo);       // mode round-trips
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}

static void testComponentStateV4RoundTripMono() {
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Mono;
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.selectionId == "pick");
    CHECK(back.channelMode == ChannelMode::Mono);
}

static void testComponentStateV4DefaultIsMono() {
    // A default-constructed state serializes with mono and restores mono (preserves behavior).
    const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
    CHECK(back.channelMode == ChannelMode::Mono);
    CHECK(back.selectionId.empty() && back.map.zones.empty());
}

static void testComponentStateV3LiftsToMono() {
    // A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the
    // selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload.
    std::vector<std::uint8_t> v3;
    v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0);   // version 3
    const std::string id = "legacy";
    v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
    v3.insert(v3.end(), id.begin(), id.end());
    v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0);   // zone count 0
    const ComponentState back = deserializeComponentState(v3, 44100.0);
    CHECK(back.selectionId == "legacy");
    CHECK(back.channelMode == ChannelMode::Mono);   // pre-S7 default
    CHECK(back.map.zones.empty());
}

static void testComponentStateV1V2LiftToMono() {
    // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
    const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0);
    CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
    PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
    const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0);
    CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
}

static void testComponentStateV4TruncatedModeByte() {
    // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
    std::vector<std::uint8_t> t{4, 0, 0, 0};  // version 4, nothing after
    const ComponentState back = deserializeComponentState(t, 44100.0);
    CHECK(back.channelMode == ChannelMode::Mono);
    CHECK(back.selectionId.empty() && back.map.zones.empty());
}

static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
    // The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying
    // channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it
    // losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in
    // the self-versioned zones payload — the two tracks are orthogonal, so both survive one
    // serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip
    // covers overrides at the default mono mode; this asserts them TOGETHER.)
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Stereo;
    PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36);
    SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000;
    z0.loopOverride = lp0;
    z0.startPoint = 128;
    PerformanceZone z1 = zone("z1", 49, 127);  // second zone: no overrides (mixed payload)
    s.map.zones.push_back(z0);
    s.map.zones.push_back(z1);

    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.channelMode == ChannelMode::Stereo);   // envelope field survives
    CHECK(back.selectionId == "pick");
    CHECK(back.map.zones.size() == 2);
    CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
          back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48);
    CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() &&
          *back.map.zones[0].rootOverride == 36);
    CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() &&
          back.map.zones[0].loopOverride->hasLoop &&
          back.map.zones[0].loopOverride->start == 500 &&
          back.map.zones[0].loopOverride->end == 9000);
    CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() &&
          *back.map.zones[0].startPoint == 128);
    // The override-free second zone stays override-free (the payload framing per zone is intact).
    CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" &&
          !back.map.zones[1].loopOverride.has_value() &&
          !back.map.zones[1].startPoint.has_value());
}

// --- v5 component state: the S8/S9 last-consumed-assignment marker -------------

static void testComponentStateV5MarkerRoundTrip() {
    // The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope
    // alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field.
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Stereo;
    s.lastConsumedAssignGeneration = 1700000123456LL;  // > INT32_MAX
    s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.lastConsumedAssignGeneration == 1700000123456LL);  // marker survives
    CHECK(back.channelMode == ChannelMode::Stereo);
    CHECK(back.selectionId == "pick");
    CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}

static void testComponentStateDefaultMarkerIsZero() {
    // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
    // assign (generation >= 1) must not be swallowed by a non-zero default.
    const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
    CHECK(back.lastConsumedAssignGeneration == 0);
}

static void testComponentStateV4LiftsMarkerToZero() {
    // A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift
    // with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand
    // (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves
    // an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies.
    std::vector<std::uint8_t> v4;
    v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0);   // version 4
    v4.push_back(1);                                                       // channel mode = stereo
    const std::string id = "saved";
    v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
    v4.insert(v4.end(), id.begin(), id.end());
    v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0);   // zone count 0
    const ComponentState back = deserializeComponentState(v4, 44100.0);
    CHECK(back.lastConsumedAssignGeneration == 0);         // no marker in v4 -> default 0
    CHECK(back.channelMode == ChannelMode::Stereo);        // v4 mode byte still honored
    CHECK(back.selectionId == "saved");
    CHECK(back.map.zones.empty());
}

static void testComponentStateV5TruncatedMarker() {
    // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
    // mono + marker 0 default holds (bounded read, never throws across the host).
    std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB};  // version 5, mode byte, 2 marker bytes
    const ComponentState back = deserializeComponentState(t, 44100.0);
    CHECK(back.lastConsumedAssignGeneration == 0);
    CHECK(back.selectionId.empty() && back.map.zones.empty());
}

// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
//
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first
// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together.

static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
    // (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying
    // S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions
    // sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with
    // no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes.
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Stereo;
    s.lastConsumedAssignGeneration = 1700000123456LL;  // > INT32_MAX -> exercises the full 8-byte field
    PerformanceZone z = zone("z0", 0, 127, /*override=*/48);
    z.play.playMode = PlayMode::Trigger;
    z.play.adsr.holdSeconds = 0.093;
    z.play.trigger.lengthFraction = 0.625;
    z.play.trigger.fadeInFrames = 32;
    z.play.trigger.fadeOutFrames = 96;
    z.play.pitchEngine = PitchEngine::Varispeed;
    z.play.pitchEnv.enabled = true;
    z.play.pitchEnv.attackSeconds = 0.00018;
    z.play.pitchEnv.decaySeconds = 0.0145;
    z.play.pitchEnv.peakSemitones = 12.5;
    s.map.zones.push_back(z);

    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.channelMode == ChannelMode::Stereo);                     // envelope: mode
    CHECK(back.lastConsumedAssignGeneration == 1700000123456LL);        // envelope: marker
    CHECK(back.selectionId == "pick");
    CHECK(back.map.zones.size() == 1);
    if (back.map.zones.size() != 1) return;
    const ZonePlaySeconds& p = back.map.zones[0].play;                  // payload: play params
    CHECK(p.playMode == PlayMode::Trigger);
    CHECK(p.adsr.holdSeconds == 0.093);
    CHECK(p.trigger.lengthFraction == 0.625);
    CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96);
    CHECK(p.pitchEngine == PitchEngine::Varispeed);
    CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 &&
          p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5);
}

// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the
// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/
// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs
// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain
// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer.
static std::vector<std::uint8_t> handBuildV3PayloadOneZone(const std::string& id) {
    std::vector<std::uint8_t> b;
    auto u32 = [&](std::uint32_t v) {
        b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
        b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
    };
    auto u64 = [&](std::uint64_t v) {
        for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((v >> (i * 8)) & 0xFF));
    };
    auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); };
    u32(kZonesFormatMarker);
    u32(3);                                    // PAYLOAD VERSION 3 (S15/S16 play tail present)
    u32(1);                                    // zone count 1
    u32(static_cast<std::uint32_t>(id.size()));
    b.insert(b.end(), id.begin(), id.end());
    u32(10);                                   // lowNote
    u32(70);                                   // highNote
    b.push_back(0);                            // hasRootOverride = 0
    b.push_back(0);                            // hasLoopOverride = 0
    b.push_back(0);                            // hasStartPoint = 0
    // Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off.
    b.push_back(1);                            // playMode = Trigger
    u64(static_cast<std::uint64_t>(2048));     // adsr.holdFrames
    dbl(0.5);                                  // trigger.lengthFraction
    u64(static_cast<std::uint64_t>(16));       // trigger.fadeInFrames
    u64(static_cast<std::uint64_t>(48));       // trigger.fadeOutFrames
    b.push_back(0);                            // pitchEngine = Varispeed
    b.push_back(0);                            // pitchEnv.enabled = 0
    u64(0);                                    // pitchEnv.attackFrames
    u64(0);                                    // pitchEnv.decayFrames
    dbl(0.0);                                  // pitchEnv.peakSemitones
    return b;
}

static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
    // (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones
    // payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build
    // save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to
    // 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who
    // saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates
    // zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so
    // the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished
    // inside the payload, not on the envelope.
    std::vector<std::uint8_t> v4;
    v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0);   // ENVELOPE version 4
    v4.push_back(1);                                                       // channel mode = stereo
    const std::string id = "s15saved";
    v4.push_back(static_cast<std::uint8_t>(id.size()));
    v4.push_back(0); v4.push_back(0); v4.push_back(0);                     // idLen (LE)
    v4.insert(v4.end(), id.begin(), id.end());
    const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
    v4.insert(v4.end(), payload.begin(), payload.end());

    const ComponentState back = deserializeComponentState(v4, 44100.0);
    CHECK(back.lastConsumedAssignGeneration == 0);   // (b) no marker in v4 -> default 0
    CHECK(back.channelMode == ChannelMode::Stereo);  // v4 envelope mode honored
    CHECK(back.selectionId == "s15saved");
    CHECK(back.map.zones.size() == 1);               // (c) payload-v3 zone parsed under widened check
    if (back.map.zones.size() != 1) return;
    CHECK(back.map.zones[0].sampleId == "zv3");
    CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
    const ZonePlaySeconds& p = back.map.zones[0].play;
    CHECK(p.playMode == PlayMode::Trigger);          // (c) play params survive the v4 envelope
    // Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here).
    CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0));
    CHECK(p.trigger.lengthFraction == 0.5);
    CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48);  // source frames, as-is
    CHECK(p.pitchEngine == PitchEngine::Varispeed);
    CHECK(p.pitchEnv.enabled == false);
}

// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------

static void testPlayParamsRoundTrip() {
    // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades,
    // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail.
    PerformanceMap m;
    PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
    z.play.playMode = PlayMode::Trigger;
    z.play.adsr.holdSeconds = 0.028;                   // wall-clock seconds
    z.play.trigger.lengthFraction = 0.375;
    z.play.trigger.fadeInFrames = 64;                  // source frames
    z.play.trigger.fadeOutFrames = 128;
    z.play.pitchEngine = PitchEngine::Varispeed;
    z.play.pitchEnv.enabled = true;
    z.play.pitchEnv.attackSeconds = 0.0002;            // wall-clock seconds
    z.play.pitchEnv.decaySeconds = 0.011;
    z.play.pitchEnv.peakSemitones = -7.5;
    m.zones.push_back(z);
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    const ZonePlaySeconds& p = back.zones[0].play;
    CHECK(p.playMode == PlayMode::Trigger);
    CHECK(p.adsr.holdSeconds == 0.028);                // exact double round-trip
    CHECK(p.trigger.lengthFraction == 0.375);          // exact double round-trip
    CHECK(p.trigger.fadeInFrames == 64);
    CHECK(p.trigger.fadeOutFrames == 128);
    CHECK(p.pitchEngine == PitchEngine::Varispeed);
    CHECK(p.pitchEnv.enabled == true);
    CHECK(p.pitchEnv.attackSeconds == 0.0002);
    CHECK(p.pitchEnv.decaySeconds == 0.011);
    CHECK(p.pitchEnv.peakSemitones == -7.5);           // exact double round-trip
}

static void testPlayParamsComposeWithLoopStart() {
    // S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together.
    PerformanceMap m;
    PerformanceZone z = zone("pad", 0, 60);
    SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222;
    z.loopOverride = lp;
    z.startPoint = 333;
    z.play.playMode = PlayMode::Gate;
    z.play.adsr.holdSeconds = 0.0225;
    z.play.pitchEngine = PitchEngine::Preserve;
    m.zones.push_back(z);
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    CHECK(back.zones[0].loopOverride.has_value() &&
          back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
    CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
    CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225);
    CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
}

// --- S-VIEW-6 key-tracking scalar: v6 round-trip + resolve-through + back-compat lift ----------

static void testKeyTrackRoundTrip() {
    // A per-zone keyTrack survives the payload-v6 round trip losslessly (exact double). A second
    // zone left at the default proves the field is per-record and the default is 1.0.
    PerformanceMap m;
    PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
    z.keyTrack = 0.5;
    m.zones.push_back(z);
    m.zones.push_back(zone("pad", 0, 19));  // default keyTrack (1.0)
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 2);
    if (back.zones.size() != 2) return;
    CHECK(back.zones[0].keyTrack == 0.5);   // exact double round-trip
    CHECK(back.zones[1].keyTrack == 1.0);   // untouched zone keeps the 100% default
}

static void testKeyTrackThroughComponentEnvelope() {
    // keyTrack round-trips through the ComponentState envelope too (the composition property:
    // the zones payload is envelope-independent, so it carries the v6 tail unchanged).
    ComponentState s;
    s.selectionId = "pick";
    PerformanceZone z = zone("pick", 0, 127);
    z.keyTrack = 2.0;
    s.map.zones.push_back(z);
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.map.zones.size() == 1);
    if (back.map.zones.size() != 1) return;
    CHECK(back.map.zones[0].keyTrack == 2.0);
}

static void testKeyTrackResolvesToZone() {
    // resolvePerformance carries keyTrack from the PerformanceZone through to the ResolvedZone,
    // so the keymap build (and thus the repitch engine) sees the authored scalar.
    const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
    PerformanceMap m;
    PerformanceZone z = zone("a", 0, 127);
    z.keyTrack = 0.0;   // no tracking
    m.zones.push_back(z);
    const ResolvedPerformance r = resolvePerformance(json, m);
    CHECK(r.zones.size() == 1);
    if (r.zones.size() != 1) return;
    CHECK(r.zones[0].keyTrack == 0.0);
}

static void testKeyTrackV5BackCompatLiftsToUnity() {
    // A v5 PAYLOAD blob (marker + version 5 + full play tail but NO keyTrack field) lifts every
    // zone to keyTrack == 1.0 (the PerformanceZone default) — so an instance saved BEFORE S-VIEW-6
    // repitches BIT-IDENTICALLY (100% ET). Hand-build the exact v5 record shape.
    std::vector<std::uint8_t> b;
    auto u32 = [&](std::uint32_t v) {
        b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
        b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
    };
    auto f64 = [&](double d) {
        std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits));
        for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
    };
    auto i64 = [&](std::int64_t v) {
        std::uint64_t bits = static_cast<std::uint64_t>(v);
        for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
    };
    u32(kPerformanceStateVersion);   // envelope version (2)
    u32(kZonesFormatMarker);         // marker -> a versioned payload
    u32(5);                          // PAYLOAD VERSION 5 (pre-S-VIEW-6, no keyTrack tail)
    u32(1);                          // zone count 1
    const std::string id = "v5saved";
    u32(static_cast<std::uint32_t>(id.size()));
    b.insert(b.end(), id.begin(), id.end());
    u32(10); u32(70);                // low/high
    b.push_back(0);                  // hasRootOverride = 0
    b.push_back(0);                  // hasLoopOverride = 0
    b.push_back(0);                  // hasStartPoint = 0
    // v5 play tail (order matches putZonesPayload): playMode, hold, len, fadeIn, fadeOut, engine,
    // envEnabled, envAttack, envDecay, peak, attack, decay, sustain, release.
    b.push_back(0);                  // playMode = Gate
    f64(0.0);                        // adsr.holdSeconds
    f64(1.0);                        // trigger.lengthFraction
    i64(0); i64(0);                  // trigger fades (source frames)
    b.push_back(1);                  // pitchEngine = Preserve
    b.push_back(0);                  // pitchEnv.enabled = false
    f64(0.0); f64(0.0); f64(0.0);    // pitchEnv attack/decay/peak
    f64(0.003); f64(0.0); f64(1.0); f64(0.060);  // adsr A/D/S/R (tier-0 seconds)
    const PerformanceMap back = deserializePerformance(b, 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    CHECK(back.zones[0].sampleId == "v5saved");
    CHECK(back.zones[0].keyTrack == 1.0);  // no keyTrack tail -> default 1.0 (bit-identical repitch)
}

static void testPlayParamsV2BackCompatLiftsToDefaults() {
    // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail)
    // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the
    // deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly.
    std::vector<std::uint8_t> b;
    auto u32 = [&](std::uint32_t v) {
        b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
        b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
    };
    u32(kPerformanceStateVersion);   // envelope version (2)
    u32(kZonesFormatMarker);         // marker -> a versioned payload
    u32(2);                          // PAYLOAD VERSION 2 (S11, no play tail)
    u32(1);                          // zone count 1
    const std::string id = "old";
    u32(static_cast<std::uint32_t>(id.size()));
    b.insert(b.end(), id.begin(), id.end());
    u32(5);                          // lowNote
    u32(80);                         // highNote
    b.push_back(0);                  // hasRootOverride = 0
    b.push_back(0);                  // hasLoopOverride = 0
    b.push_back(0);                  // hasStartPoint = 0  (record ends here in v2)
    const PerformanceMap back = deserializePerformance(b, 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    CHECK(back.zones[0].sampleId == "old");
    // Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off.
    CHECK(back.zones[0].play.playMode == PlayMode::Gate);
    CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine);  // == Preserve
    CHECK(back.zones[0].play.pitchEnv.enabled == false);
    CHECK(back.zones[0].play.adsr.holdSeconds == 0.0);
}

static void testPlayParamsThroughComponentEnvelope() {
    // The play params round-trip through the v4 COMPONENT envelope too (the composition property:
    // the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them).
    ComponentState s;
    s.selectionId = "pick";
    s.channelMode = ChannelMode::Stereo;
    PerformanceZone z = zone("z", 0, 127);
    z.play.playMode = PlayMode::Trigger;
    z.play.trigger.lengthFraction = 0.9;
    z.play.pitchEngine = PitchEngine::Varispeed;
    s.map.zones.push_back(z);
    const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
    CHECK(back.channelMode == ChannelMode::Stereo);
    CHECK(back.map.zones.size() == 1);
    if (back.map.zones.size() != 1) return;
    CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger);
    CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9);
    CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
}

// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. ---
//
// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free);
// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift ->
// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker).

// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip).
static void testFullAdsrSecondsRoundTrip() {
    PerformanceMap m;
    PerformanceZone z = zone("pad", 0, 127);
    z.play.playMode = PlayMode::Gate;
    z.play.adsr.attackSeconds  = 0.01;
    z.play.adsr.holdSeconds    = 0.02;
    z.play.adsr.decaySeconds   = 0.1;
    z.play.adsr.sustainLevel   = 0.7;
    z.play.adsr.releaseSeconds = 0.2;
    z.play.pitchEngine = PitchEngine::Preserve;
    m.zones.push_back(z);
    const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    const AdsrSeconds& a = back.zones[0].play.adsr;
    CHECK(a.attackSeconds  == 0.01);
    CHECK(a.holdSeconds    == 0.02);
    CHECK(a.decaySeconds   == 0.1);
    CHECK(a.sustainLevel   == 0.7);     // exact double round-trip via bit-cast
    CHECK(a.releaseSeconds == 0.2);
    CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
}

// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the
// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they
// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count.
static void testV3BlobLiftsAdsrToSecondsDefaults() {
    std::vector<std::uint8_t> blob;
    auto u32 = [&](std::uint32_t v) {
        blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
        blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
    };
    u32(kPerformanceStateVersion);  // envelope version 2 header
    const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old");
    blob.insert(blob.end(), payload.begin(), payload.end());
    const PerformanceMap back = deserializePerformance(blob, 44100.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    const AdsrSeconds& a = back.zones[0].play.adsr;
    // hold converts from the v3 record's frames at the passed project rate (44100.0 here).
    CHECK(approx(a.holdSeconds, 2048.0 / 44100.0));  // from the hand-built v3 record
    CHECK(a.attackSeconds  == AdsrSeconds{}.attackSeconds);       // 0.003 (tier-0 default seconds)
    CHECK(a.decaySeconds   == AdsrSeconds{}.decaySeconds);        // 0.0
    CHECK(a.sustainLevel   == AdsrSeconds{}.sustainLevel);        // 1.0
    CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds);      // 0.060
}

// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the
// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000.
static void testV3BlobLiftsAdsrAt96k() {
    std::vector<std::uint8_t> blob;
    auto u32 = [&](std::uint32_t v) {
        blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
        blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
    };
    u32(kPerformanceStateVersion);  // envelope version 2 header
    const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old96k");
    blob.insert(blob.end(), payload.begin(), payload.end());
    const PerformanceMap back = deserializePerformance(blob, 96000.0);
    CHECK(back.zones.size() == 1);
    if (back.zones.size() != 1) return;
    const AdsrSeconds& a = back.zones[0].play.adsr;
    // 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100).
    CHECK(approx(a.holdSeconds, 2048.0 / 96000.0));
    CHECK(a.attackSeconds  == AdsrSeconds{}.attackSeconds);
    CHECK(a.decaySeconds   == AdsrSeconds{}.decaySeconds);
    CHECK(a.sustainLevel   == AdsrSeconds{}.sustainLevel);
    CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}

// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
    const auto rnd = [](double s, int rate) {
        return static_cast<std::int64_t>(s * static_cast<double>(rate) + 0.5);
    };
    for (int rate : {44100, 48000, 96000}) {
        // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds.
        {
            PerformanceMap m;
            m.zones.push_back(zone("def", 0, 127));    // product-default play (tier-0 AHDSR seconds)
            const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
            CHECK(back.zones.size() == 1);
            if (back.zones.size() != 1) continue;
            ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
            rz.play = back.zones[0].play;
            const DecodedZonePcm pcm{{0.5f}, rate};
            const Keymap km = buildZonedKeymap({rz}, {pcm});
            CHECK(km.samples.size() == 1);
            if (km.samples.empty()) continue;
            const AdsrParams& a = km.samples[0].play.adsr;
            CHECK(a.attackFrames  == rnd(0.003, rate));  // tier-0 attack at this rate
            CHECK(a.decayFrames   == 0);
            CHECK(a.sustainLevel  == 1.0);               // level, never rate-scaled
            CHECK(a.releaseFrames == rnd(0.060, rate));  // tier-0 release at this rate
        }
        // (b) AUTHORED zone -> round(seconds * rate) at this rate.
        {
            PerformanceMap m;
            PerformanceZone z = zone("auth", 0, 127);
            z.play.adsr.attackSeconds  = 0.01;
            z.play.adsr.decaySeconds   = 0.1;
            z.play.adsr.sustainLevel   = 0.7;
            z.play.adsr.releaseSeconds = 0.2;
            m.zones.push_back(z);
            const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
            CHECK(back.zones.size() == 1);
            if (back.zones.size() != 1) continue;
            ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
            rz.play = back.zones[0].play;
            const DecodedZonePcm pcm{{0.5f}, rate};
            const Keymap km = buildZonedKeymap({rz}, {pcm});
            CHECK(km.samples.size() == 1);
            if (km.samples.empty()) continue;
            const AdsrParams& a = km.samples[0].play.adsr;
            CHECK(a.attackFrames  == rnd(0.01, rate));
            CHECK(a.decayFrames   == rnd(0.1, rate));
            CHECK(a.sustainLevel  == 0.7);               // level, never rate-scaled
            CHECK(a.releaseFrames == rnd(0.2, rate));
        }
    }
}

// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the
// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release
// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant.
static void testBuildTier0KeymapResolvesSecondsAt48k() {
    const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{});
    CHECK(km.samples.size() == 1);
    if (km.samples.empty()) return;
    const AdsrParams& a = km.samples[0].play.adsr;
    CHECK(a.attackFrames  == 144);   // round(0.003 * 48000)
    CHECK(a.decayFrames   == 0);
    CHECK(a.sustainLevel  == 1.0);   // level, not a time
    CHECK(a.releaseFrames == 2880);  // round(0.060 * 48000)
}

int main() {
    testSelectByIdHit();
    testSelectEmptyIdIsSilence();
    testSelectUnknownIdIsSilence();
    testSelectRootNoteDefault();
    testSelectLoopThreaded();
    testSelectNoLoopIsAbsent();
    testSelectEmptyBlob();
    testSelectMalformedBlob();
    testSelectZeroSamples();
    testListSamplesOrdinalOrder();
    testListSamplesCarriesCardMetadata();
    testListSamplesEmptyAndMalformed();
    testListBanksOrdinalOrder();
    testListBanksEmptyAndMalformed();
    testDownmixMonoPassthrough();
    testDownmixStereoAverages();
    testDownmixThreeChannelAverages();
    testDownmixDegenerate();
    testBuildKeymapSingleFullZone();
    testSelectionStateRoundTrip();
    testSelectionStateEmptyId();
    testSelectionStateWrongVersion();
    testSelectionStateTruncated();
    testWavTrimToDownmixPipelineStereo();
    testWavTrimToDownmixPipelineMono();
    testResolveEmptyMap();
    testResolveEmptyBlob();
    testResolveMultiZoneAcrossBanks();
    testResolveStaleIdDropsZone();
    testResolveRootPrecedence();
    testResolveLoopThreaded();
    testResolveLoopOverrideWins();
    testResolveLoopOverrideDisablesLoop();
    testBuildZonedKeymapMultiZone();
    testBuildZonedKeymapThreadsLoopAndStart();
    testBuildZonedKeymapDropsEmptyPcm();
    testBuildZonedKeymapOverlapFirstWins();
    testBuildZonedKeymapEmpty();
    testPerformanceStateRoundTrip();
    testPerformanceStateEmpty();
    testPerformanceStateLoopStartRoundTrip();
    testPerformanceStateV1PayloadBackCompat();
    testPerformanceStateV1BackCompat();
    testPerformanceStateGarbage();
    testPerformanceStateNegativeNotesRoundTrip();
    testPlayParamsRoundTrip();
    testPlayParamsComposeWithLoopStart();
    testKeyTrackRoundTrip();
    testKeyTrackThroughComponentEnvelope();
    testKeyTrackResolvesToZone();
    testKeyTrackV5BackCompatLiftsToUnity();
    testPlayParamsV2BackCompatLiftsToDefaults();
    testPlayParamsThroughComponentEnvelope();
    testFullAdsrSecondsRoundTrip();
    testV3BlobLiftsAdsrToSecondsDefaults();
    testV3BlobLiftsAdsrAt96k();
    testKeymapBuildResolvesSecondsToFramesAtEachRate();
    testBuildTier0KeymapResolvesSecondsAt48k();
    testComponentStateRoundTrip();
    testComponentStateLoopStartRoundTrip();
    testComponentStateSelectionOnlyNoZones();
    testComponentStateEmptyIsEmpty();
    testComponentStateV1BackCompat();
    testComponentStateV2BackCompat();
    testComponentStateGarbage();
    testExtractChannelStereo();
    testExtractChannelClampsToLast();
    testDecodeChannelsMonoModeDownmixes();
    testDecodeChannelsStereoModeStereoSource();
    testDecodeChannelsStereoModeMonoSourceDualMono();
    testBuildKeymapStereoCarriesSecondChannel();
    testBuildKeymapMonoWhenNoSecondChannel();
    testBuildKeymapDropsMismatchedSecondChannel();
    testBuildZonedKeymapCarriesSecondChannel();
    testComponentStateV4RoundTripStereo();
    testComponentStateV4RoundTripMono();
    testComponentStateV4DefaultIsMono();
    testComponentStateV3LiftsToMono();
    testComponentStateV1V2LiftToMono();
    testComponentStateV4TruncatedModeByte();
    testComponentStateV4StereoWithZoneOverridesRoundTrip();
    testComponentStateV5MarkerRoundTrip();
    testComponentStateDefaultMarkerIsZero();
    testComponentStateV4LiftsMarkerToZero();
    testComponentStateV5TruncatedMarker();
    testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
    testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();

    if (g_fail == 0) std::printf("sample_map: all tests passed\n");
    return g_fail != 0;
}
