feat(S9/S8): bank-generation change-detection + instrument-side assignment reader

Extension stamps a monotonic bank_generation counter, bumped at content
mutations; the VST3 instrument polls it off-thread on an editor timer and
consumes assignment requests, refreshing playback hands-free.
This commit is contained in:
2026-07-26 23:46:06 -04:00
parent 725f3e7d3c
commit 66d47fb410
21 changed files with 838 additions and 36 deletions
+56
View File
@@ -979,6 +979,58 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
!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));
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{}));
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);
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);
CHECK(back.lastConsumedAssignGeneration == 0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -1049,6 +1101,10 @@ int main() {
testComponentStateV1V2LiftToMono();
testComponentStateV4TruncatedModeByte();
testComponentStateV4StereoWithZoneOverridesRoundTrip();
testComponentStateV5MarkerRoundTrip();
testComponentStateDefaultMarkerIsZero();
testComponentStateV4LiftsMarkerToZero();
testComponentStateV5TruncatedMarker();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;