fix: close review findings on spline EGs — engine, codec, and popup/overlay UI grammar

Live pitch depth, Gate/Spline enable-rule agreement, inert kTrigLength, NaN wire guards, hard-flag-tail corruption no longer wipes the record, RT/cold spline tie-break, retired alt-click, marker-shadow fix, plus new test coverage.
This commit is contained in:
2026-07-31 22:27:41 -04:00
parent e44bd42dd9
commit 1c774226d3
22 changed files with 414 additions and 88 deletions
+138
View File
@@ -780,6 +780,141 @@ static void testNonFiniteAhdSecondsLiftToZero() {
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
// the record is disturbed. Corrupts only the AMP curve's tail; FILTER/PITCH follow at their
// normal, byte-precise offsets, proving a mismatch on one curve does not cascade to its
// neighbours.
static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 44;
in.params.keyTrack = 0.6;
in.params.play.adsr.attackSeconds = 0.12;
in.params.play.adsr.sustainLevel = 0.55;
in.params.play.filter.enabled = true;
in.params.play.filter.settings.cutoffNorm = 0.4f;
in.params.play.filter.modAmount = -0.3;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.peakSemitones = 5.0;
in.params.loopCrossfadeFrames = 777;
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, -0.4}, VelocityPoint{127.0, 0.4}},
reasampler::instrument::engine::CurveDomain::Bipolar);
// Every velocity curve left at its DEFAULT 2-point shape, so the v13 hard-flag tail's byte
// layout (three 4-byte-count + N-byte blocks, amp/filter/pitch order — putHardFlags' call
// order in params_payload.cpp) is deterministic and this test can splice it exactly.
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed 4+2-byte blocks
legacy::u32v(bytes, 5); // amp: bogus count...
for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts
legacy::u32v(bytes, 2); // filter: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// Every param preceding AND following the corrupted amp tail survives untouched.
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.keyTrack == 0.6);
CHECK(out.params.play.adsr.attackSeconds == 0.12);
CHECK(out.params.play.adsr.sustainLevel == 0.55);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.play.filter.settings.cutoffNorm == 0.4f);
CHECK(out.params.play.filter.modAmount == -0.3);
CHECK(out.params.play.pitchEnv.enabled);
CHECK(out.params.play.pitchEnv.peakSemitones == 5.0);
CHECK(out.params.loopCrossfadeFrames == 777);
CHECK(out.params.play.pitchVelocityCurve.eval(0.0) == -0.4);
CHECK(out.params.play.pitchVelocityCurve.eval(127.0) == 0.4);
// The mismatched (amp) curve keeps its points; the flags are dropped, never misapplied.
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
CHECK(!out.params.velocityCurve.points()[1].hard);
CHECK(out.params.velocityCurve.equals(reasampler::instrument::engine::VelocityCurve::flat()));
}
// A hard-flag COUNT that exceeds what its OWN tail carries — a genuinely corrupt/out-of-bounds
// count — must be BOUND-AND-SKIPPED without consuming any of the following bytes, so the
// FILTER/PITCH tails immediately after the AMP block still parse at their correct offset. The
// old behavior (r.ok = false) reset the ENTIRE params record to defaults on this path, which is
// strictly worse than the documented "drops only the hard points" promise.
static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 44;
in.params.play.adsr.releaseSeconds = 0.44;
in.params.play.filter.enabled = true;
in.params.play.filter.settings.resonanceNorm = 0.9f;
in.params.loopCrossfadeFrames = 321;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
// filter/pitch blocks right after it land exactly where they belong.
legacy::u32v(bytes, 2); // filter: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// The whole record survives — including everything the v13 section itself carries ahead of
// the hard-flag tail (the three spline EGs) and the two well-formed tails after the
// corrupted one — only the AMP curve's hard-flag application is lost.
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.play.adsr.releaseSeconds == 0.44);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.play.filter.settings.resonanceNorm == 0.9f);
CHECK(out.params.loopCrossfadeFrames == 321);
CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded
CHECK(!out.params.velocityCurve.points()[0].hard);
}
// A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and
// nothing else after) is a different failure shape than a declared-huge count: the u32 read
// itself fails, tripping r.ok inside readHardFlags rather than its own bound check. That must
// be revived the same way — the whole record survives, only the hard-flag applications (on
// all three curves, since the truncation strands every one of them) are lost.
static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 21;
in.params.play.adsr.decaySeconds = 0.08;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.peakSemitones = -3.0;
in.params.loopCrossfadeFrames = 5;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
legacy::u8v(bytes, 0x00);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 21);
CHECK(out.params.play.adsr.decaySeconds == 0.08);
CHECK(out.params.play.pitchEnv.enabled);
CHECK(out.params.play.pitchEnv.peakSemitones == -3.0);
CHECK(out.params.loopCrossfadeFrames == 5);
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
}
// --- The loop tail (payload v11) ---------------------------------------------
// The loop span and its crossfade survive a save/reload intact, alongside the two overrides
@@ -1605,6 +1740,9 @@ int main() {
testPitchVelocityCurveRoundTripsIndependently();
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
testNonFiniteAhdSecondsLiftToZero();
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;