Fix embed use-after-free, restore golden fixture + refs tests, drop dead note_entry
This commit is contained in:
@@ -65,6 +65,12 @@ struct Zone {
|
||||
int highNote = 127;
|
||||
int rootOverride = -1; // < 0 = absent
|
||||
bool hasLoopOverride = false;
|
||||
// The override's OWN hasLoop bit — distinct from hasLoopOverride above. An override can
|
||||
// itself say "disable the loop" (loopOverrideHasLoop = false): the field is present but
|
||||
// sets no sustain loop, as opposed to no override at all (the sample's own intrinsic loop
|
||||
// applies). Defaults true so existing callers that only set hasLoopOverride keep writing
|
||||
// the enabled-loop shape they always did.
|
||||
bool loopOverrideHasLoop = true;
|
||||
std::int64_t loopStart = 0;
|
||||
std::int64_t loopEnd = 0;
|
||||
std::int64_t startPoint = -1; // < 0 = absent
|
||||
@@ -95,7 +101,7 @@ static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t
|
||||
if (pv >= 2) {
|
||||
u8v(out, z.hasLoopOverride ? 1 : 0);
|
||||
if (z.hasLoopOverride) {
|
||||
u8v(out, 1);
|
||||
u8v(out, z.loopOverrideHasLoop ? 1 : 0);
|
||||
i64v(out, z.loopStart);
|
||||
i64v(out, z.loopEnd);
|
||||
}
|
||||
@@ -185,6 +191,24 @@ static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionI
|
||||
|
||||
// --- The current format -------------------------------------------------------
|
||||
|
||||
// Builds a SampleRefEntry with the intrinsics fields the refs-robustness tests below need to
|
||||
// set individually (root/loop/channels), mirroring the codec's own field names.
|
||||
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
||||
bool hasLoop = false, std::int64_t loopStart = 0,
|
||||
std::int64_t loopEnd = 0, int channels = 0,
|
||||
const std::string& name = "") {
|
||||
SampleRefEntry e;
|
||||
e.sampleId = id;
|
||||
e.ref.relativePath = rel;
|
||||
e.ref.rootNote = root;
|
||||
e.ref.loop.hasLoop = hasLoop;
|
||||
e.ref.loop.start = loopStart;
|
||||
e.ref.loop.end = loopEnd;
|
||||
e.ref.channelCount = channels;
|
||||
e.displayName = name;
|
||||
return e;
|
||||
}
|
||||
|
||||
// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
|
||||
// survives. This is the "one parameter set round-trips save/reload intact" contract.
|
||||
static void testComponentStateRoundTrip() {
|
||||
@@ -281,6 +305,114 @@ static void testComponentStateRoundTrip() {
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
}
|
||||
|
||||
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
|
||||
// pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
|
||||
// byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
|
||||
// pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
|
||||
// field family at once (a two-entry sample-refs table — one with a loop, one without — every
|
||||
// optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
|
||||
// and asserts the encoded bytes equal an EXACT expected vector, captured from the current
|
||||
// writer's output and checked field-for-field against the v8/v11 layout documented in
|
||||
// component_state_io.h.
|
||||
static void testGoldenFullBlobFixture() {
|
||||
ComponentState in;
|
||||
in.selectionId = "kick";
|
||||
in.channelMode = ChannelMode::Stereo;
|
||||
in.channelModeExplicit = true;
|
||||
in.lastConsumedAssignGeneration = 12345;
|
||||
in.previewVelocity = 100;
|
||||
in.voiceCount = 24;
|
||||
in.voiceMode = VoiceMode::Mono;
|
||||
in.monoTrigger = MonoTrigger::Legato;
|
||||
in.masterGainLinear = 2.0;
|
||||
in.instanceGuid = "guid-1234-5678-abcd";
|
||||
|
||||
SampleRefEntry kickRef;
|
||||
kickRef.sampleId = "kick";
|
||||
kickRef.ref.relativePath = "bank/kick.wav";
|
||||
kickRef.ref.rootNote = 36;
|
||||
kickRef.ref.loop.hasLoop = true;
|
||||
kickRef.ref.loop.start = 1000;
|
||||
kickRef.ref.loop.end = 5000;
|
||||
kickRef.ref.channelCount = 2;
|
||||
kickRef.displayName = "Kick Drum";
|
||||
in.sampleRefs.push_back(kickRef);
|
||||
|
||||
SampleRefEntry snareRef;
|
||||
snareRef.sampleId = "snare";
|
||||
snareRef.ref.relativePath = "bank/snare.wav";
|
||||
snareRef.ref.rootNote = 38;
|
||||
snareRef.ref.loop.hasLoop = false;
|
||||
snareRef.ref.loop.start = 0;
|
||||
snareRef.ref.loop.end = 0;
|
||||
snareRef.ref.channelCount = 1;
|
||||
snareRef.displayName = "Snare";
|
||||
in.sampleRefs.push_back(snareRef);
|
||||
|
||||
in.params.rootOverride = 36;
|
||||
SampleLoop loopA;
|
||||
loopA.hasLoop = true;
|
||||
loopA.start = 1000;
|
||||
loopA.end = 5000;
|
||||
in.params.loopOverride = loopA;
|
||||
in.params.startPoint = 250;
|
||||
in.params.keyTrack = 0.5;
|
||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
||||
in.params.play.playMode = PlayMode::Trigger;
|
||||
in.params.play.adsr.attackSeconds = 0.01;
|
||||
in.params.play.adsr.holdSeconds = 0.05;
|
||||
in.params.play.adsr.decaySeconds = 0.02;
|
||||
in.params.play.adsr.sustainLevel = 0.8;
|
||||
in.params.play.adsr.releaseSeconds = 0.15;
|
||||
in.params.play.trigger.lengthFraction = 0.75;
|
||||
in.params.play.trigger.fadeInFrames = 100;
|
||||
in.params.play.trigger.fadeOutFrames = 200;
|
||||
in.params.play.pitchEngine = PitchEngine::Preserve;
|
||||
in.params.play.pitchEnv.enabled = true;
|
||||
in.params.play.pitchEnv.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.peakSemitones = 5.0;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
// clang-format off
|
||||
static const std::uint8_t kGolden[] = {
|
||||
0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01,
|
||||
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00,
|
||||
0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b,
|
||||
0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,
|
||||
0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00,
|
||||
0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73,
|
||||
0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
||||
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
||||
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
|
||||
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
|
||||
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
|
||||
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
|
||||
0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
|
||||
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
if (bytes.size() == sizeof(kGolden)) {
|
||||
bool same = true;
|
||||
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
||||
if (bytes[i] != kGolden[i]) { same = false; break; }
|
||||
}
|
||||
CHECK(same);
|
||||
}
|
||||
}
|
||||
|
||||
// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
|
||||
// blob restores as the silent empty state, not as a set of accidental values.
|
||||
static void testDefaultStateRoundTripsToDefaults() {
|
||||
@@ -398,6 +530,26 @@ static void testSingleZoneMigrationIsLossless() {
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
}
|
||||
|
||||
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
|
||||
// false — distinct from no override at all (which leaves the sample's own intrinsic loop in
|
||||
// force). The writer always emitted the override's inner hasLoop bit as true; this is the
|
||||
// disabled shape it never exercised.
|
||||
static void testSingleZoneMigrationLiftsLoopDisablingOverride() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.hasLoopOverride = true;
|
||||
z.loopOverrideHasLoop = false;
|
||||
z.loopStart = 1000;
|
||||
z.loopEnd = 5000;
|
||||
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
||||
|
||||
const InstrumentParams& p = out.params;
|
||||
CHECK(p.loopOverride.has_value());
|
||||
CHECK(p.loopOverride && !p.loopOverride->hasLoop);
|
||||
}
|
||||
|
||||
// A lifted single-zone instance RE-SAVES in the current format and survives a second
|
||||
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
|
||||
static void testLiftedStateReSavesInCurrentFormat() {
|
||||
@@ -628,6 +780,52 @@ static void testCorruptFieldsFallBackToDefaults() {
|
||||
CHECK(out.masterGainLinear == 1.0);
|
||||
}
|
||||
|
||||
// CORRUPT-BLOB posture for the refs-table intrinsics: the refs table is the ONLY copy on the
|
||||
// play path, so a bad field must degrade to its own default, never poison playback. An
|
||||
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a negative
|
||||
// channelCount falls back to 0 = unknown (the GA auto-default then skips it). The fallback is
|
||||
// per-field — in-range neighbours pass through untouched.
|
||||
static void testSampleRefsReaderRangeFallbacks() {
|
||||
ComponentState s;
|
||||
s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
|
||||
/*channels=*/-3));
|
||||
s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
|
||||
/*channels=*/1));
|
||||
s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
|
||||
/*channels=*/2));
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 3);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
|
||||
}
|
||||
|
||||
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest of
|
||||
// the state empty (the selection/params behind the cut are unreadable anyway) — the
|
||||
// established truncation posture, never a throw across the host boundary.
|
||||
static void testSampleRefsTruncatedMidEntry() {
|
||||
ComponentState s;
|
||||
s.selectionId = "kick";
|
||||
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
|
||||
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
||||
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
||||
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes;
|
||||
// entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0).
|
||||
// Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 178);
|
||||
bytes.resize(bytes.size() - 178);
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||
CHECK(back.selectionId.empty());
|
||||
CHECK(!back.params.rootOverride);
|
||||
}
|
||||
|
||||
// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
|
||||
// produced always re-reads as itself.
|
||||
static void testWriterClampsOutOfRangeFields() {
|
||||
@@ -669,11 +867,16 @@ static void testV1SelectionLift() {
|
||||
|
||||
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
|
||||
// over BOTH the current format and a retired zone-list blob, since the migration path has
|
||||
// its own bounded-read walk.
|
||||
// its own bounded-read walk. Beyond mere survival, a cut read must never RETAIN more refs
|
||||
// than the blob actually carried (the "keep what parsed, drop the rest" contract could not
|
||||
// silently start fabricating entries) — see testSampleRefsTruncatedMidEntry for the exact
|
||||
// mid-entry retention case this bounds only loosely across every cut point.
|
||||
static void testTruncationDegradesCleanly() {
|
||||
ComponentState in;
|
||||
in.selectionId = "smp-2";
|
||||
in.params.rootOverride = 61;
|
||||
in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
|
||||
in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
|
||||
const std::vector<std::uint8_t> current = serializeComponentState(in);
|
||||
|
||||
legacy::Zone z;
|
||||
@@ -685,18 +888,19 @@ static void testTruncationDegradesCleanly() {
|
||||
const std::vector<std::uint8_t> part(blob->begin(),
|
||||
blob->begin() + static_cast<long>(cut));
|
||||
const ComponentState out = deserializeComponentState(part, 48000.0);
|
||||
(void)out; // reaching here without UB/throw is the contract under test
|
||||
CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
|
||||
}
|
||||
}
|
||||
CHECK(true);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testComponentStateRoundTrip();
|
||||
testGoldenFullBlobFixture();
|
||||
testDefaultStateRoundTripsToDefaults();
|
||||
testEnvelopePrefixBytesFrozen();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
||||
testLiftedStateReSavesInCurrentFormat();
|
||||
testMultiZoneMigrationAdoptsFirstZone();
|
||||
testFirstZoneSupersedesStoredSelection();
|
||||
@@ -706,6 +910,8 @@ int main() {
|
||||
testEnvelopeLadderLiftsEachVersion();
|
||||
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
|
||||
testCorruptFieldsFallBackToDefaults();
|
||||
testSampleRefsReaderRangeFallbacks();
|
||||
testSampleRefsTruncatedMidEntry();
|
||||
testWriterClampsOutOfRangeFields();
|
||||
testUnknownEnvelopeVersionIsEmpty();
|
||||
testV1SelectionLift();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Standalone tests for reasampler::instrument::map::note_entry — no VST3, no REAPER, no framework.
|
||||
// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note.
|
||||
//
|
||||
// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the
|
||||
// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to
|
||||
// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt;
|
||||
// the integer path taking precedence over the note-name path for a leading digit.
|
||||
|
||||
#include "../src/core/instrument/map/note_entry.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static void testPlainIntegers() {
|
||||
CHECK(parseNoteEntry("60") == 60);
|
||||
CHECK(parseNoteEntry("0") == 0);
|
||||
CHECK(parseNoteEntry("127") == 127);
|
||||
CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored
|
||||
CHECK(parseNoteEntry("+5") == 5);
|
||||
}
|
||||
|
||||
static void testIntegerClamps() {
|
||||
CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling
|
||||
CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor
|
||||
CHECK(parseNoteEntry("99999") == 127);
|
||||
}
|
||||
|
||||
static void testNoteNames() {
|
||||
// C4 == 60 (MIDI 0 == C-1).
|
||||
CHECK(parseNoteEntry("C4") == 60);
|
||||
CHECK(parseNoteEntry("c4") == 60); // case-insensitive
|
||||
CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A)
|
||||
CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note
|
||||
CHECK(parseNoteEntry("G9") == 127); // G9 = 127
|
||||
}
|
||||
|
||||
static void testAccidentals() {
|
||||
CHECK(parseNoteEntry("C#4") == 61);
|
||||
CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4
|
||||
CHECK(parseNoteEntry("F#3") == 54);
|
||||
CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58
|
||||
}
|
||||
|
||||
static void testNoteNameClamps() {
|
||||
CHECK(parseNoteEntry("C10") == 127); // above the range clamps
|
||||
CHECK(parseNoteEntry("C-5") == 0); // below the range clamps
|
||||
}
|
||||
|
||||
static void testRejects() {
|
||||
CHECK(parseNoteEntry("") == std::nullopt);
|
||||
CHECK(parseNoteEntry(" ") == std::nullopt);
|
||||
CHECK(parseNoteEntry("hello") == std::nullopt);
|
||||
CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous
|
||||
CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter
|
||||
CHECK(parseNoteEntry("+") == std::nullopt);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPlainIntegers();
|
||||
testIntegerClamps();
|
||||
testNoteNames();
|
||||
testAccidentals();
|
||||
testNoteNameClamps();
|
||||
testRejects();
|
||||
|
||||
if (g_fail == 0) std::printf("note_entry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
Reference in New Issue
Block a user