diff --git a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef
index 41e66be90..825a6f6e8 100644
--- a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef
+++ b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef
@@ -5,6 +5,8 @@
"ProjectM.Simulation",
"ProjectM.Server",
"ProjectM.Client",
+ "ProjectM.Authoring",
+ "Unity.Entities.Hybrid",
"Unity.Entities",
"Unity.Transforms",
"Unity.Collections",
diff --git a/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs
new file mode 100644
index 000000000..b2611dd72
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using System.Reflection;
+using NUnit.Framework;
+using ProjectM.Simulation;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Invariants over the tuning surface, from the 2026-08-06 audit.
+ ///
+ /// 1. THE SIX-SITE KNOB DRIFT. Adding a knob means touching six places (the struct field, Defaults(), the
+ /// TuningKnob byte const, and the ClampKnob / Apply / Get switches). Nothing in the build caught a knob
+ /// wired into Apply but forgotten in ClampKnob — which matters because the clamp is the only thing
+ /// stopping a 0 reaching the i-frame divide, documented in TuningConfig's own header as "NaNs the
+ /// kinematic body permanently".
+ ///
+ /// 2. MELEE TESTS PINNED THE WRONG NUMBERS. MeleeComboTests pins fixture values that differ from the SHIPPED
+ /// defaults on nine of ten knobs, and MeleeTiming — which produces the locked 20/12/25 contact ticks — had
+ /// zero test references. These assert RELATIONSHIPS against Defaults(), so a deliberate retune stays green
+ /// and only a broken coupling fails.
+ ///
+ public class TuningInvariantTests
+ {
+ /// Knob indices that actually exist, read off the TuningKnob consts rather than assumed to be
+ /// 0..Count. CLAUDE.md's rule is that a RETIRED knob's byte value stays reserved and is never renumbered
+ /// (20-23 today), so the index space is deliberately full of holes.
+ static byte[] LiveKnobIndices()
+ {
+ var list = new List();
+ foreach (var f in typeof(TuningKnob).GetFields(BindingFlags.Public | BindingFlags.Static))
+ {
+ if (f.FieldType != typeof(byte) || f.Name == "Count") continue;
+ list.Add((byte)f.GetRawConstantValue());
+ }
+ return list.ToArray();
+ }
+
+ [Test]
+ public void EveryLiveKnob_RoundTrips_Through_Apply_And_Get()
+ {
+ var knobs = LiveKnobIndices();
+ Assert.Greater(knobs.Length, 20, "reflection should have found the whole live knob set");
+
+ foreach (byte knob in knobs)
+ {
+ var c = TuningConfig.Defaults();
+ const float probe = 7f; // inside every knob's clamp band
+ float expected = TuningConfig.ClampKnob(knob, probe);
+ TuningConfig.Apply(ref c, knob, probe);
+ float actual = TuningConfig.Get(in c, knob);
+
+ Assert.AreEqual(expected, actual, 1e-4f,
+ $"knob {knob} does not round-trip: Apply/Get disagree with ClampKnob. A knob added to one " +
+ "switch and forgotten in another silently reads 0 in the overlay or bypasses its safety floor.");
+ }
+ }
+
+ [Test]
+ public void RetiredKnobIndices_StayReserved()
+ {
+ var live = new HashSet(LiveKnobIndices());
+ foreach (byte retired in new byte[] { 20, 21, 22, 23 })
+ Assert.IsFalse(live.Contains(retired),
+ $"knob index {retired} is RETIRED and reserved (CLAUDE.md): re-using it would silently re-mean " +
+ "a value in saved feel profiles and in the DebugOp wire payload.");
+ }
+
+ [Test]
+ public void IFrameWindow_ClampsAboveZero()
+ {
+ Assert.Greater(TuningConfig.ClampKnob(TuningKnob.IFrameWindowTicks, 0f), 0f,
+ "IFrameWindowTicks must clamp above zero — a zero reaches a divide and NaNs the character body.");
+ }
+
+ [Test]
+ public void ShippedMeleeCadence_ContactLandsInsideTheCastAndRecoverWindows()
+ {
+ var d = TuningConfig.Defaults();
+ uint step2 = MeleeTiming.ContactTicks(2, d.MeleeContactTicks);
+ uint step3 = MeleeTiming.ContactTicks(3, d.MeleeContactTicks);
+
+ // MeleeTiming's header states this coupling as documented-but-unclamped; it is otherwise enforced only
+ // by a [MenuItem] audit tool a human has to remember to run. This is the mechanical version.
+ Assert.Less(step3, (uint)PlayerAimSystem.CastFacingTicks,
+ "finisher contact must land INSIDE the cast-facing window — at the 07-21 feel lock it sits one " +
+ "tick under, so raising contact without raising the window silently breaks aim-at-contact.");
+ Assert.Less(step3, (uint)d.MeleeRecoverTicks,
+ "finisher contact must land before recover ends, or the hit resolves after the swing is over.");
+ Assert.Less(step2, step3,
+ "step 2 is the quick contact (x0.625) and step 3 the finisher (x1.25) — never the other way round.");
+ }
+
+ [Test]
+ public void ContactTicks_ZeroKnob_MeansImmediate()
+ {
+ Assert.AreEqual(0u, MeleeTiming.ContactTicks(1, 0f));
+ Assert.AreEqual(0u, MeleeTiming.ContactTicks(3, 0f),
+ "0 is the IMMEDIATE sentinel at every step — the legacy same-tick resolve the mechanics tests use.");
+ }
+
+ [Test]
+ public void ContactTicks_ScalePerStep_FromTheOneKnob()
+ {
+ const float knob = 16f;
+ Assert.AreEqual(16u, MeleeTiming.ContactTicks(1, knob), "step 1 is the knob itself (x1.0)");
+ Assert.AreEqual(10u, MeleeTiming.ContactTicks(2, knob), "step 2 is the quick one (x0.625)");
+ Assert.AreEqual(20u, MeleeTiming.ContactTicks(3, knob), "step 3+ is the finisher (x1.25)");
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta
new file mode 100644
index 000000000..741893feb
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7086ea8eabfc7974c9c173c7a5e12429
\ No newline at end of file