Tuning Knobs

This commit is contained in:
2026-06-10 15:22:30 -07:00
parent da522efe7a
commit 08f16b689f
20 changed files with 11045 additions and 18 deletions
@@ -38,6 +38,8 @@ namespace ProjectM.Client
public static void Kill() => Send(DebugOp.KillPlayer); public static void Kill() => Send(DebugOp.KillPlayer);
public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by); public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by);
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat); public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => s_Pending.Clear(); static void ResetOnEnterPlayMode() => s_Pending.Clear();
@@ -17,6 +17,8 @@ namespace ProjectM.Client
bool _open = true; bool _open = true;
int _siegeSize = 5; int _siegeSize = 5;
int _grantAmount = 50; int _grantAmount = 50;
bool _tuningOpen;
Vector2 _scroll;
void OnDisable() => AimPresentation.ForceCursorVisible = false; void OnDisable() => AimPresentation.ForceCursorVisible = false;
@@ -29,8 +31,10 @@ namespace ProjectM.Client
if (!_open) if (!_open)
return; return;
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, 540), GUI.skin.box); float panelH = Mathf.Min(760f, Screen.height - 50f);
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, panelH), GUI.skin.box);
GUILayout.Label("DEV TOOLS"); GUILayout.Label("DEV TOOLS");
_scroll = GUILayout.BeginScrollView(_scroll);
GUILayout.Label("- World -"); GUILayout.Label("- World -");
_siegeSize = IntField("Siege size", _siegeSize); _siegeSize = IntField("Siege size", _siegeSize);
@@ -76,6 +80,23 @@ namespace ProjectM.Client
GUILayout.Label("(waiting for server telemetry...)"); GUILayout.Label("(waiting for server telemetry...)");
} }
GUILayout.Space(6);
_tuningOpen = GUILayout.Toggle(_tuningOpen, "- Tuning (MC-0) -");
if (_tuningOpen)
{
TuningRow("Dash dist", TuningKnob.DashDistance, 0.5f, "0.0");
TuningRow("Dash iframe t", TuningKnob.IFrameWindowTicks, 1f, "0");
TuningRow("Dash recover t", TuningKnob.RecoverTailTicks, 1f, "0");
TuningRow("Dash cd t", TuningKnob.DashCooldownTicks, 1f, "0");
TuningRow("Dash sharp", TuningKnob.DashSharpness, 25f, "0");
TuningRow("Chgr windup t", TuningKnob.ChargerWindupTicks, 1f, "0");
TuningRow("Chgr lunge spd", TuningKnob.ChargerLungeSpeed, 1f, "0.0");
TuningRow("Chgr lunge t", TuningKnob.ChargerLungeDurationTicks, 1f, "0");
TuningRow("Chgr stagger t", TuningKnob.ChargerWhiffStaggerTicks, 1f, "0");
TuningRow("Grunt windup t", TuningKnob.GruntWindupTicks, 1f, "0");
}
GUILayout.EndScrollView();
GUILayout.EndArea(); GUILayout.EndArea();
} }
@@ -87,6 +108,25 @@ namespace ProjectM.Client
GUILayout.EndHorizontal(); GUILayout.EndHorizontal();
return int.TryParse(s, out var v) ? v : value; return int.TryParse(s, out var v) ? v : value;
} }
// One live-tuning row: shows the current value (from the readout) + step buttons. A nudge fires the
// authoritative server RPC AND an optimistic local apply (so the tuner's own predicted dash uses it now).
static void TuningRow(string label, byte knob, float step, string fmt)
{
float cur = ProjectM.Simulation.TuningConfig.Get(TuningReadout.Current, knob);
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(92));
GUILayout.Label(cur.ToString(fmt), GUILayout.Width(40));
if (GUILayout.Button("-", GUILayout.Width(26))) Nudge(knob, cur - step);
if (GUILayout.Button("+", GUILayout.Width(26))) Nudge(knob, cur + step);
GUILayout.EndHorizontal();
}
static void Nudge(byte knob, float value)
{
DebugCommandSendSystem.SetTuning(knob, value); // authoritative (server applies + broadcasts; clamped)
TuningReadout.SetLocal(knob, value); // optimistic local (instant feel for the tuner; clamped)
}
} }
} }
#endif #endif
@@ -0,0 +1,82 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// MC-0 — EDITOR-ONLY client owner of the local <see cref="TuningConfig"/> singleton (the value the PREDICTED
/// DashSystem reads on this client). Each tick it (1) drains any authoritative <see cref="DebugTuningReport"/>
/// into the <see cref="TuningReadout"/> static, then (2) pushes <see cref="TuningReadout.Current"/> into the
/// client TuningConfig singleton. The overlay mutates the readout OPTIMISTICALLY via
/// <see cref="TuningReadout.SetLocal"/> (so the tuner's own dash uses a nudged value instantly — instant, then
/// EVENTUALLY CONSISTENT with the server within ~1 RTT once the SetTuning RPC lands and the next report
/// reconciles). Runs EVERY tick (no RequireForUpdate) so an overlay-only change with no report still reaches the
/// singleton. Plain client <see cref="SimulationSystemGroup"/> (mutated at most once/tick → the predicted
/// re-sim reads a per-tick-constant value); non-Burst (touches a managed static). This DELIBERATELY extends the
/// DevTelemetry pattern (which writes only a static) with a per-world singleton; the release path has no such
/// system, so DashSystem's <c>TryGetSingleton ? : Defaults()</c> fallback is the permanent release behaviour.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTuningReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
if (state.GetEntityQuery(ComponentType.ReadWrite<TuningConfig>()).IsEmpty)
{
var e = state.EntityManager.CreateEntity(typeof(TuningConfig));
state.EntityManager.SetComponentData(e, TuningConfig.Defaults());
}
}
public void OnUpdate(ref SystemState state)
{
// (1) Authoritative server snapshot wins — overwrite the readout (FULL state).
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (report, reqEntity) in
SystemAPI.Query<RefRO<DebugTuningReport>>()
.WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
{
TuningReadout.Current = TuningConfig.FromReport(report.ValueRO);
TuningReadout.Initialized = true;
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
// (2) Push the readout (authoritative, or optimistic SetLocal, or Defaults()) into the client singleton.
if (SystemAPI.TryGetSingletonEntity<TuningConfig>(out var cfgEntity))
state.EntityManager.SetComponentData(cfgEntity, TuningReadout.Current);
}
}
/// <summary>
/// MC-0 — static bridge from the ECS tuning receiver to the IMGUI <c>DebugOverlay</c> (so the overlay reads/sets
/// a plain struct, never ECS state). <see cref="Current"/> seeds to <see cref="TuningConfig.Defaults"/> on
/// play-enter (so it matches the server's seeded Defaults before the first report). <see cref="SetLocal"/> is the
/// overlay's OPTIMISTIC apply — it runs the same <see cref="TuningConfig.Apply"/> clamp so an overlay nudge can
/// never feed a 0/negative into the predicted DashSystem.
/// </summary>
public static class TuningReadout
{
public static TuningConfig Current;
/// <summary>True once an authoritative server report has been received (else <see cref="Current"/> is Defaults()).</summary>
public static bool Initialized;
/// <summary>Optimistic local apply (overlay button) — clamped via <see cref="TuningConfig.Apply"/>.</summary>
public static void SetLocal(byte knob, float value) => TuningConfig.Apply(ref Current, knob, value);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Reset()
{
Current = TuningConfig.Defaults();
Initialized = false;
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f4b5a00778a9f44f8c07543d471beb6
@@ -61,6 +61,8 @@ namespace ProjectM.Server
float dt = SystemAPI.Time.DeltaTime; float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics); bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u; uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
@@ -165,7 +167,7 @@ namespace ProjectM.Server
} }
if (ready) if (ready)
{ {
uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks); uint windupTicks = (uint)math.max(1f, tune.GruntWindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks); windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks);
} }
} }
@@ -173,10 +175,12 @@ namespace ProjectM.Server
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge. // --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>(). // Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
const float ChargerLungeSpeed = 16f; // units/s while lunging // Charger feel knobs — live-tunable via TuningConfig (MC-0), guarded at the read site. Server-only
const uint ChargerLungeDurationTicks = 18; // ~0.30 s of committed travel // (clients never simulate Chargers); the >=1-tick floor avoids a degenerate instant/no-travel lunge.
const uint ChargerWindupTicks = 30; // ~0.50 s readable telegraph (>= interp delay + reaction) float ChargerLungeSpeed = math.max(0f, tune.ChargerLungeSpeed); // units/s while lunging
const uint ChargerWhiffStaggerTicks = 36; // ~0.60 s punish window on a whiff uint ChargerLungeDurationTicks = (uint)math.max(1f, tune.ChargerLungeDurationTicks); // committed travel
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
uint chargerWhiffsThisTick = 0; uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>, SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
@@ -165,6 +165,13 @@ namespace ProjectM.Server
SystemAPI.SetComponent(cycleEntity, ts); SystemAPI.SetComponent(cycleEntity, ts);
} }
break; break;
case DebugOp.SetTuning:
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
{
TuningConfig.Apply(ref tuningCfg, (byte)cmd.ArgA, cmd.ArgB / 1000f);
SystemAPI.SetSingleton(tuningCfg);
}
break;
} }
ecb.DestroyEntity(reqEntity); ecb.DestroyEntity(reqEntity);
@@ -0,0 +1,59 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// MC-0 — EDITOR-ONLY server owner of the authoritative <see cref="TuningConfig"/> singleton. Ensures+seeds it
/// to <see cref="TuningConfig.Defaults"/> in OnCreate, then every <see cref="BroadcastPeriodTicks"/> ships the
/// FULL config to every connection as a <see cref="DebugTuningReport"/> (so a PREDICTING client's DashSystem —
/// including an MPPM thin client that has no overlay — converges on the tuned values; full state, not a delta,
/// so a late-joiner converges in one report). The singleton is mutated by <c>DebugCommandReceiveSystem</c>'s
/// SetTuning op (same plain server group). Verbatim <c>DevTelemetrySystem</c> shape. Plain server
/// <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple, editor-only).
/// Stripped from builds; the wire TYPE <see cref="DebugTuningReport"/> + <see cref="TuningConfig"/> are
/// unconditional, so in a release build no system creates the singleton and consumers fall back to Defaults().
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct TuningBroadcastSystem : ISystem
{
const uint BroadcastPeriodTicks = 15;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
if (state.GetEntityQuery(ComponentType.ReadWrite<TuningConfig>()).IsEmpty)
{
var e = state.EntityManager.CreateEntity(typeof(TuningConfig));
state.EntityManager.SetComponentData(e, TuningConfig.Defaults());
}
}
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
if (now == 0 || (now % BroadcastPeriodTicks) != 0)
return;
var report = TuningConfig.ToReport(SystemAPI.GetSingleton<TuningConfig>());
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (netId, connEnt) in SystemAPI.Query<RefRO<NetworkId>>().WithEntityAccess())
{
var req = ecb.CreateEntity();
ecb.AddComponent(req, report);
ecb.AddComponent(req, new SendRpcCommandRequest { TargetConnection = connEnt });
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58b4be546da44eb499b7cba0c7b04ea9
@@ -60,5 +60,8 @@ namespace ProjectM.Simulation
/// <summary>Set ThreatState.Heat to ArgA (inert until the Heat source ships).</summary> /// <summary>Set ThreatState.Heat to ArgA (inert until the Heat source ships).</summary>
public const byte SetHeat = 11; public const byte SetHeat = 11;
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
public const byte SetTuning = 12;
} }
} }
@@ -0,0 +1,176 @@
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-0 — live-tunable dash/Charger feel knobs (a per-world singleton). UNCONDITIONAL type (no #if) so the
/// reflection-built RpcCollection hash matches across release/dev peers — only the dev SYSTEMS that create,
/// mutate, broadcast and receive it are <c>#if UNITY_EDITOR</c>. Consumers (DashSystem, EnemyAISystem) read it
/// via <c>TryGetSingleton</c> and FALL BACK to <see cref="Defaults"/> when it is absent (release builds +
/// EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton exists. NOT a
/// <c>[GhostField]</c> (no ghost-hash change / re-bake); the server broadcasts it to clients via
/// <see cref="DebugTuningReport"/> so a PREDICTING client's DashSystem stays in sync (an MPPM thin client with no
/// overlay learns tuned values ONLY through this broadcast).
/// <para>
/// Values match the historical baked consts (DashSystem / EnemyAISystem / <see cref="Tuning.AttackWindupTicks"/>);
/// <c>TuningConfigTests</c> pins <see cref="Defaults"/> to them so the promote-to-singleton refactor is
/// behaviour-preserving. Tick knobs clamp to &gt;= 1 and value knobs to &gt;= 0 (<see cref="ClampKnob"/>) on EVERY
/// write path (<see cref="Apply"/> + the client-side optimistic SetLocal), and DashSystem additionally clamps the
/// i-frame window AT the divide site — a 0 there NaNs the kinematic body permanently (MC-0 design-review F1).
/// </para>
/// </summary>
public struct TuningConfig : IComponentData
{
public float DashDistance;
public float IFrameWindowTicks;
public float RecoverTailTicks;
public float DashCooldownTicks;
public float DashSharpness;
public float ChargerWindupTicks;
public float ChargerLungeSpeed;
public float ChargerLungeDurationTicks;
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
public static TuningConfig Defaults() => new TuningConfig
{
DashDistance = 4.0f,
IFrameWindowTicks = 12f,
RecoverTailTicks = 9f,
DashCooldownTicks = 45f,
DashSharpness = 200f,
ChargerWindupTicks = 30f,
ChargerLungeSpeed = 16f,
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
};
/// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 0. Used by every write path
/// (<see cref="Apply"/> + the client-side optimistic SetLocal) so no 0/negative ever reaches a consumer.</summary>
public static float ClampKnob(byte knob, float value)
{
switch (knob)
{
// value knobs: non-negative
case TuningKnob.DashDistance:
case TuningKnob.DashSharpness:
case TuningKnob.ChargerLungeSpeed:
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem)
default:
return math.max(1f, value);
}
}
/// <summary>Authoritatively set one knob (clamped) by its <see cref="TuningKnob"/> index. Unknown index = no-op.</summary>
public static void Apply(ref TuningConfig c, byte knob, float value)
{
value = ClampKnob(knob, value);
switch (knob)
{
case TuningKnob.DashDistance: c.DashDistance = value; break;
case TuningKnob.IFrameWindowTicks: c.IFrameWindowTicks = value; break;
case TuningKnob.RecoverTailTicks: c.RecoverTailTicks = value; break;
case TuningKnob.DashCooldownTicks: c.DashCooldownTicks = value; break;
case TuningKnob.DashSharpness: c.DashSharpness = value; break;
case TuningKnob.ChargerWindupTicks: c.ChargerWindupTicks = value; break;
case TuningKnob.ChargerLungeSpeed: c.ChargerLungeSpeed = value; break;
case TuningKnob.ChargerLungeDurationTicks: c.ChargerLungeDurationTicks = value; break;
case TuningKnob.ChargerWhiffStaggerTicks: c.ChargerWhiffStaggerTicks = value; break;
case TuningKnob.GruntWindupTicks: c.GruntWindupTicks = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
}
/// <summary>Read one knob by its <see cref="TuningKnob"/> index (overlay display). Unknown index = 0.</summary>
public static float Get(in TuningConfig c, byte knob)
{
switch (knob)
{
case TuningKnob.DashDistance: return c.DashDistance;
case TuningKnob.IFrameWindowTicks: return c.IFrameWindowTicks;
case TuningKnob.RecoverTailTicks: return c.RecoverTailTicks;
case TuningKnob.DashCooldownTicks: return c.DashCooldownTicks;
case TuningKnob.DashSharpness: return c.DashSharpness;
case TuningKnob.ChargerWindupTicks: return c.ChargerWindupTicks;
case TuningKnob.ChargerLungeSpeed: return c.ChargerLungeSpeed;
case TuningKnob.ChargerLungeDurationTicks: return c.ChargerLungeDurationTicks;
case TuningKnob.ChargerWhiffStaggerTicks: return c.ChargerWhiffStaggerTicks;
case TuningKnob.GruntWindupTicks: return c.GruntWindupTicks;
default: return 0f;
}
}
/// <summary>Project the full config onto the wire snapshot.</summary>
public static DebugTuningReport ToReport(in TuningConfig c) => new DebugTuningReport
{
DashDistance = c.DashDistance,
IFrameWindowTicks = c.IFrameWindowTicks,
RecoverTailTicks = c.RecoverTailTicks,
DashCooldownTicks = c.DashCooldownTicks,
DashSharpness = c.DashSharpness,
ChargerWindupTicks = c.ChargerWindupTicks,
ChargerLungeSpeed = c.ChargerLungeSpeed,
ChargerLungeDurationTicks = c.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = c.ChargerWhiffStaggerTicks,
GruntWindupTicks = c.GruntWindupTicks,
};
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
public static TuningConfig FromReport(in DebugTuningReport r) => new TuningConfig
{
DashDistance = r.DashDistance,
IFrameWindowTicks = r.IFrameWindowTicks,
RecoverTailTicks = r.RecoverTailTicks,
DashCooldownTicks = r.DashCooldownTicks,
DashSharpness = r.DashSharpness,
ChargerWindupTicks = r.ChargerWindupTicks,
ChargerLungeSpeed = r.ChargerLungeSpeed,
ChargerLungeDurationTicks = r.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = r.ChargerWhiffStaggerTicks,
GruntWindupTicks = r.GruntWindupTicks,
};
}
/// <summary>Byte indices for <see cref="TuningConfig"/> knobs (bytes — never an enum on a Bursted/RPC path).</summary>
public static class TuningKnob
{
public const byte DashDistance = 0;
public const byte IFrameWindowTicks = 1;
public const byte RecoverTailTicks = 2;
public const byte DashCooldownTicks = 3;
public const byte DashSharpness = 4;
public const byte ChargerWindupTicks = 5;
public const byte ChargerLungeSpeed = 6;
public const byte ChargerLungeDurationTicks = 7;
public const byte ChargerWhiffStaggerTicks = 8;
public const byte GruntWindupTicks = 9;
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 10;
}
/// <summary>
/// MC-0 — server → dev-client snapshot of the full <see cref="TuningConfig"/> (sent periodically by the
/// editor-only broadcaster). UNCONDITIONAL wire type (like <see cref="DebugTelemetryReport"/>) for RpcCollection
/// hash parity; only the send/receive SYSTEMS are <c>#if UNITY_EDITOR</c>. FULL state (not a delta) so a
/// late-joining client converges in one report.
/// </summary>
public struct DebugTuningReport : IRpcCommand
{
public float DashDistance;
public float IFrameWindowTicks;
public float RecoverTailTicks;
public float DashCooldownTicks;
public float DashSharpness;
public float ChargerWindupTicks;
public float ChargerLungeSpeed;
public float ChargerLungeDurationTicks;
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24ab752e5f2d5a8428b4580f2b2588e4
@@ -29,12 +29,9 @@ namespace ProjectM.Simulation
[BurstCompile] [BurstCompile]
public partial struct DashSystem : ISystem public partial struct DashSystem : ISystem
{ {
// Baked-first feel knobs (MC-1; promote to a live TuningConfig later). Sim runs at 60 ticks/sec. // Feel knobs are LIVE-tunable via the TuningConfig singleton (MC-0): OnUpdate reads it each tick and falls
const float DashDistance = 4.0f; // world units covered during the i-frame window // back to TuningConfig.Defaults() when absent (release builds / EditMode), so behaviour is identical to the
const uint IFrameWindowTicks = 12; // ~0.20 s of i-frames // old baked consts. DefaultSharpness (the restore target) + SimTickRate stay compile-time (not tuned).
const uint RecoverTailTicks = 9; // ~0.15 s movement-locked tail (punishes spam)
const uint DashCooldownTicks = 45; // ~0.75 s
const float DashSharpness = 200f; // GroundedMovementSharpness during the dash -> blink
const float DefaultSharpness = 15f; // CharacterComponent.GetDefault() base const float DefaultSharpness = 15f; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f; const float SimTickRate = 60f;
@@ -45,7 +42,12 @@ namespace ProjectM.Simulation
return; return;
var serverTick = netTime.ServerTick; var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate); var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tc) ? tc : TuningConfig.Defaults();
uint iFrameTicks = (uint)math.max(1f, t.IFrameWindowTicks);
uint recoverTicks = (uint)math.max(1f, t.RecoverTailTicks);
uint cooldownTicks = (uint)math.max(1f, t.DashCooldownTicks);
float dashSpeed = t.DashDistance / (iFrameTicks / SimTickRate); // iFrameTicks>=1 -> never div-by-0 (review F1)
float dashSharpness = t.DashSharpness;
foreach (var (ds, cd, control, character, input, facing) in foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>, SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
@@ -64,9 +66,9 @@ namespace ProjectM.Simulation
dir = math.normalize(dir); dir = math.normalize(dir);
ds.ValueRW.Dir = dir; ds.ValueRW.Dir = dir;
ds.ValueRW.StartTick = TickUtil.NonZero(now); ds.ValueRW.StartTick = TickUtil.NonZero(now);
ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + IFrameWindowTicks); ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + iFrameTicks);
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + IFrameWindowTicks + RecoverTailTicks); ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + iFrameTicks + recoverTicks);
cd.ValueRW.NextTick = TickUtil.NonZero(now + DashCooldownTicks); cd.ValueRW.NextTick = TickUtil.NonZero(now + cooldownTicks);
} }
// --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) --- // --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) ---
@@ -84,7 +86,7 @@ namespace ProjectM.Simulation
{ {
float2 d = ds.ValueRO.Dir; float2 d = ds.ValueRO.Dir;
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed; control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
character.ValueRW.GroundedMovementSharpness = DashSharpness; character.ValueRW.GroundedMovementSharpness = dashSharpness;
} }
else if (recoverActive) else if (recoverActive)
{ {
@@ -0,0 +1,188 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// MC-0 — EditMode tests for the live <see cref="TuningConfig"/> tuning singleton: the GOLDEN pin that
/// <see cref="TuningConfig.Defaults"/> reproduces the historical baked consts (so promoting them to a singleton
/// is behaviour-preserving — the existing DashSystemTests assert the dash half via the no-singleton fallback),
/// the <c>Apply</c>/<c>Get</c>/clamp logic (the design-review F1 divide-by-zero guard lives here + at the
/// DashSystem read site), the x1000 wire round-trip, and a world test proving DashSystem actually READS a
/// non-default singleton (not just Defaults()).
/// </summary>
public class TuningConfigTests
{
// ---- pure logic ----
[Test]
public void Defaults_Match_The_Historical_Baked_Consts()
{
var d = TuningConfig.Defaults();
Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance");
Assert.AreEqual(12f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks");
Assert.AreEqual(45f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness");
Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks");
Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed");
Assert.AreEqual(18f, d.ChargerLungeDurationTicks, 1e-6f, "ChargerLungeDurationTicks");
Assert.AreEqual(36f, d.ChargerWhiffStaggerTicks, 1e-6f, "ChargerWhiffStaggerTicks");
// GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it).
Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks");
}
[Test]
public void Apply_Sets_Only_The_Targeted_Knob()
{
for (byte knob = 0; knob < TuningKnob.Count; knob++)
{
var c = TuningConfig.Defaults();
float baseline = TuningConfig.Get(c, knob);
float target = baseline + 7f; // survives both clamps (positive)
TuningConfig.Apply(ref c, knob, target);
Assert.AreEqual(target, TuningConfig.Get(c, knob), 1e-4f, $"knob {knob} took the new value");
// every OTHER knob is untouched
var d = TuningConfig.Defaults();
for (byte other = 0; other < TuningKnob.Count; other++)
if (other != knob)
Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f,
$"knob {other} unchanged while editing {knob}");
}
}
[Test]
public void Apply_Clamps_Tick_Knobs_To_At_Least_One()
{
// IFrameWindowTicks at 0 would divide-by-zero in DashSystem — the clamp is the first line of defense.
var c = TuningConfig.Defaults();
TuningConfig.Apply(ref c, TuningKnob.IFrameWindowTicks, 0f);
Assert.AreEqual(1f, c.IFrameWindowTicks, 1e-6f, "i-frame window floors at 1 (never 0)");
TuningConfig.Apply(ref c, TuningKnob.ChargerWindupTicks, -10f);
Assert.AreEqual(1f, c.ChargerWindupTicks, 1e-6f, "tick knobs floor at 1 on negative input");
TuningConfig.Apply(ref c, TuningKnob.GruntWindupTicks, 0f);
Assert.AreEqual(1f, c.GruntWindupTicks, 1e-6f, "grunt windup floors at 1");
}
[Test]
public void Apply_Clamps_Value_Knobs_To_NonNegative()
{
var c = TuningConfig.Defaults();
TuningConfig.Apply(ref c, TuningKnob.DashDistance, -3f);
Assert.AreEqual(0f, c.DashDistance, 1e-6f, "DashDistance floors at 0");
TuningConfig.Apply(ref c, TuningKnob.DashSharpness, -1f);
Assert.AreEqual(0f, c.DashSharpness, 1e-6f, "DashSharpness floors at 0");
TuningConfig.Apply(ref c, TuningKnob.ChargerLungeSpeed, -5f);
Assert.AreEqual(0f, c.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed floors at 0");
}
[Test]
public void Apply_Ignores_An_Out_Of_Range_Knob_Index()
{
var c = TuningConfig.Defaults();
var before = TuningConfig.ToReport(c);
Assert.DoesNotThrow(() => TuningConfig.Apply(ref c, 200, 999f), "unknown knob index is a safe no-op");
var after = TuningConfig.ToReport(c);
Assert.AreEqual(before.DashDistance, after.DashDistance, 1e-6f, "no field changed on an unknown knob");
Assert.AreEqual(before.IFrameWindowTicks, after.IFrameWindowTicks, 1e-6f);
Assert.AreEqual(before.GruntWindupTicks, after.GruntWindupTicks, 1e-6f);
}
[Test]
public void Wire_FixedPoint_RoundTrips_Through_The_x1000_Channel()
{
// mirror the overlay -> RPC -> server path: argB = round(value*1000); value' = argB/1000f.
foreach (var (knob, value) in new[]
{
(TuningKnob.DashDistance, 4.5f),
(TuningKnob.DashSharpness, 175f),
(TuningKnob.IFrameWindowTicks, 14f),
(TuningKnob.ChargerLungeSpeed, 16.5f),
})
{
int argB = (int)math.round(value * 1000f);
var c = TuningConfig.Defaults();
TuningConfig.Apply(ref c, knob, argB / 1000f);
Assert.AreEqual(value, TuningConfig.Get(c, knob), 1e-3f, $"knob {knob} survives the x1000 channel");
}
}
[Test]
public void Report_Projection_Round_Trips()
{
var c = TuningConfig.Defaults();
TuningConfig.Apply(ref c, TuningKnob.DashDistance, 6f);
TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f);
var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c));
for (byte knob = 0; knob < TuningKnob.Count; knob++)
Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport");
}
// ---- consumption (world) ----
[Test]
public void DashSystem_Reads_The_Window_From_The_TuningConfig_Singleton()
{
var (world, group) = MakeWorld<DashSystem>("DashTuning", 100);
using (world)
{
var em = world.EntityManager;
// A non-default i-frame window proves DashSystem reads the singleton, not the baked Defaults() (12).
var cfg = TuningConfig.Defaults();
cfg.IFrameWindowTicks = 20f;
var cfgE = em.CreateEntity(typeof(TuningConfig));
em.SetComponentData(cfgE, cfg);
var e = MakeDasher(em, new float2(0, 1));
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
group.Update(); // tick 100
var ds = em.GetComponentData<DashState>(e);
Assert.AreEqual(120u, ds.IFrameUntilTick, "IFrameUntilTick = now + tuned 20 (singleton honored, not Defaults' 12).");
Assert.AreEqual(129u, ds.RecoverUntilTick, "RecoverUntilTick = now + 20 + default 9.");
}
}
// ---- harness (mirrors DashSystemTests) ----
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick) where T : unmanaged, ISystem
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakeDasher(EntityManager em, float2 facing)
{
var e = em.CreateEntity();
em.AddComponentData(e, new DashState());
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, CharacterComponent.GetDefault());
em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = facing });
em.AddComponent<Simulate>(e);
em.AddComponent<Dead>(e);
em.SetComponentEnabled<Dead>(e, false);
return e;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 572c0c389fda97841a1ba1cc16da4fc8
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5e70301c93ddff2438537adae6e59371
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 372b8f8077a280246b6b8d0de3e4797c
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+3 -1
View File
@@ -79,7 +79,9 @@ Green EditMode + server==client stay **necessary, not sufficient** — they were
### The thesis ### The thesis
Depth = a **dialogue**. Enemies ask distinct, readable questions (a committed lunge to dodge, a bolt to reposition from, a swarm to AoE); the player answers with tools that have skill and **commitment cost**. The keystone is **enemy commitment + a punishable whiff** paired with the **dash** — the dash is the *answer*, a committed lunge is the *question*; neither is fun alone (so they ship together). The repo is well-shaped for this: the predicted CharacterController, the `RespawnInvuln`/`KnockbackState`/`AttackWindup` windowed-tick idiom, the derive-don't-replicate `Dead` gate, the `StatModifier` fold, and a near-complete `CombatFeedbackSystem` juice scaffold are all already proven under prediction. Depth = a **dialogue**. Enemies ask distinct, readable questions (a committed lunge to dodge, a bolt to reposition from, a swarm to AoE); the player answers with tools that have skill and **commitment cost**. The keystone is **enemy commitment + a punishable whiff** paired with the **dash** — the dash is the *answer*, a committed lunge is the *question*; neither is fun alone (so they ship together). The repo is well-shaped for this: the predicted CharacterController, the `RespawnInvuln`/`KnockbackState`/`AttackWindup` windowed-tick idiom, the derive-don't-replicate `Dead` gate, the `StatModifier` fold, and a near-complete `CombatFeedbackSystem` juice scaffold are all already proven under prediction.
### MC-0 — Instrument the box (dev-overlay readback) `~0.5 d` · risk LOW ### MC-0 — Instrument the box (dev-overlay readback) `~0.5 d` · risk LOW · ✅ DONE (2026-06-10)
> **Status:** telemetry counters **and** the `TuningConfig` live-tuning singleton — 10 dash/Charger knobs nudgeable from the dev overlay mid-Play, no recompile — both landed. See [[2026-06-10_MC0_TuningConfig_LiveTuning]]. The MC-1 fun-gate's playtest→nudge→replay loop is now unblocked.
**Goal:** make every later fun-gate *measurable* before spending a friend's time. The M8 dev-tools triad (`DebugCommandRequest` + `DebugOverlay` + `DebugCommandReceiveSystem`) today only **sends** commands and never reads live values back — that gap is why the gates are unfalsifiable. **Goal:** make every later fun-gate *measurable* before spending a friend's time. The M8 dev-tools triad (`DebugCommandRequest` + `DebugOverlay` + `DebugCommandReceiveSystem`) today only **sends** commands and never reads live values back — that gap is why the gates are unfalsifiable.
- **Scope:** add a server-only `DevTelemetry` `IComponentData` (a flat struct of `uint` counters + a few `float` accumulators, **not** `[GhostField]` by default) updated at the stamp sites the later milestones already touch. Surface it to the local overlay via a handful of owner-send `[GhostField] uint`s on the predicted player (read each frame in `PresentationSystemGroup`) **or** a periodic `DebugTelemetryReport` RPC server→client (avoids any ghost-hash change). Add a read-only IMGUI readout block to `DebugOverlay` showing the live counters + derived ratios (negated-hits/dash, whiff-convert %, per-player DPS, hit-stop frames, downed/revive timers). - **Scope:** add a server-only `DevTelemetry` `IComponentData` (a flat struct of `uint` counters + a few `float` accumulators, **not** `[GhostField]` by default) updated at the stamp sites the later milestones already touch. Surface it to the local overlay via a handful of owner-send `[GhostField] uint`s on the predicted player (read each frame in `PresentationSystemGroup`) **or** a periodic `DebugTelemetryReport` RPC server→client (avoids any ghost-hash change). Add a read-only IMGUI readout block to `DebugOverlay` showing the live counters + derived ratios (negated-hits/dash, whiff-convert %, per-player DPS, hit-stop frames, downed/revive timers).
- **Build notes:** the dev-RPC wire type stays **unconditional** (no `#if` on the struct — the RpcCollection hash must match release/dev peers); `#if UNITY_EDITOR`-gate only the send/receive systems. Add a `SetTuning(op, valueX1000)` `DebugOp` so the operator nudges live singletons (below) from the overlay without leaving Play. Pure editor-only, server-authoritative plumbing — fully Claude-headless. - **Build notes:** the dev-RPC wire type stays **unconditional** (no `#if` on the struct — the RpcCollection hash must match release/dev peers); `#if UNITY_EDITOR`-gate only the send/receive systems. Add a `SetTuning(op, valueX1000)` `DebugOp` so the operator nudges live singletons (below) from the overlay without leaving Play. Pure editor-only, server-authoritative plumbing — fully Claude-headless.
@@ -0,0 +1,50 @@
---
date: 2026-06-10
type: session
tags:
- session
- combat
- mc-0
- tuning
- netcode
- dev-tools
permalink: gamevault/07-sessions/2026/2026-06-10-mc0-tuningconfig-live-tuning
---
# MC-0 completion — TuningConfig live-tuning singleton (dash/Charger knobs, no recompile)
> Closes the last MC-0 gap flagged in [[2026-06-09_MC1_Implementation]] ("the TuningConfig live-singleton still does not exist — dash/Charger knobs are baked consts"). Direction: [[Path_to_Fun]] (MC-0) · locks: [[DR-029_Path_A_Fork_Locks]]. **Purpose: make the [[2026-06-09_MC1_Build_Spec|MC-1]] fun-gate a playtest→nudge→replay loop instead of edit→recompile→replay.** All three gates passed (compile clean · 267/267 EditMode · live server==client round-trip). The MC-1 **feel** fun-gate is still the open operator item — this only removes the recompile friction from it.
## What was built
A per-world `TuningConfig` `IComponentData` singleton holding 10 live feel knobs, mirroring the proven `DevTelemetry` dev-tools pattern (singleton + scalar `DebugCommandRequest` RPC + a broadcast report + a client readout static). **Zero ghost-hash change, no re-bake; fully editor-strippable** (types unconditional for RpcCollection hash parity, all systems `#if UNITY_EDITOR`).
- **`TuningConfig` + `DebugTuningReport` + `TuningKnob`** (`Simulation/Debug/TuningConfig.cs`, UNCONDITIONAL types): 10 floats (dash: distance/iframe/recover/cooldown/sharpness; Charger: windup/lunge-speed/lunge-dur/whiff-stagger; + Grunt windup). `Defaults()` is the **single source of truth** == the historical baked consts; `GruntWindupTicks` **references `Tuning.AttackWindupTicks`** (not a duplicate literal — `Tuning.AttackWindupTicks` stays alive for `TelegraphTests`). `Apply`/`Get`/`ClampKnob` switch on a **byte** index (no enum on a Bursted/RPC path).
- **`TuningBroadcastSystem`** (Server, editor-only): ensures+seeds the server singleton to `Defaults()` in OnCreate; every 15 ticks broadcasts the FULL config to every connection (load-bearing for MPPM thin clients + late joiners — they have no overlay, so the report is their only path to tuned values).
- **`DevTuningReceiveSystem` + `TuningReadout`** (Client, editor-only): drains the authoritative report into the readout, then writes the client `TuningConfig` singleton from it each tick (so the PREDICTED `DashSystem` reads live values). The overlay mutates the readout **optimistically** via `SetLocal` (instant feel for the tuner; eventually consistent with the server within ~1 RTT).
- **`DebugOp.SetTuning = 12`** + the server handler (`TuningConfig.Apply` on the server singleton) + `DebugCommandSendSystem.SetTuning(knob, value)` (×1000 fixed-point over the int `ArgB`) + a collapsible, scroll-wrapped **Tuning section in `DebugOverlay`** (per-knob /+ rows; each nudge fires the authoritative RPC AND the optimistic local apply).
- **Consumers repointed:** `DashSystem` (predicted) + `EnemyAISystem` (server-only Charger/Grunt) read the singleton via `TryGetSingleton ? : Defaults()` — so **release builds fall back to `Defaults()` == today's consts, behaviour unchanged**. The 5 dash consts and 4 Charger consts were deleted (now in `Defaults()`); the 7 Charger read-sites kept their names (locals shadow the old const names) so only the const *declarations* changed.
## The mandatory pre-code design review (operator-forced, per the standing rule)
Operator chose "run the design review first" over "build it now" (honoring [[validate-netcode-design-before-coding]] / [[DR-029_Path_A_Fork_Locks]]'s ritual). A 3-lens adversarial Workflow (netcode/determinism · reuse/scope/Burst · edge-cases, each finding adversarially refuted vs ground-truth code) → **15 findings, 1 confirmed, 14 refuted**.
- **CONFIRMED — MAJOR (fixed): dash-speed divide-by-zero → PERMANENT NaN.** `DashSystem` computes `dashSpeed = DashDistance / (IFrameWindowTicks/60)` at a read site distinct from the window math. The spec only guarded the window, not the divisor — and the **optimistic `SetLocal` path bypasses `Apply`'s clamp**, so nudging the i-frame-window knob to 0 → 0 denominator → `MoveVelocity` NaN → the kinematic body's `LocalTransform.Position` goes NaN and **stays NaN forever** (not self-correcting; corrupts the replicated transform — bricks the player). **Fix:** clamp at the read site (`uint iFrameTicks = (uint)math.max(1f, t.IFrameWindowTicks)`) used for BOTH the window and the divisor, AND clamp in `Apply` + `SetLocal` (defense in depth). Pinned by `TuningConfigTests.Apply_Clamps_Tick_Knobs_To_At_Least_One` + the golden-defaults test.
- **Hardening folded in** (several refuted findings converged): `Apply`/`SetLocal` clamp all tick knobs ≥1 and value knobs ≥0; Charger read-sites guard with `(uint)math.max(1,…)`; **do NOT delete `Tuning.AttackWindupTicks`** (a reviewer caught `TelegraphTests.cs:73` references it → `Defaults().GruntWindupTicks` references it instead); a **golden test** pins `Defaults()` to the historical consts.
- **Architecture validated (no change):** unconditional types / no re-bake confirmed; the broadcast is genuinely load-bearing for MPPM thin clients (not redundant); the knob-change transient mis-predict is acceptable for a dev tool; within one client's rollback re-sim the singleton is per-tick-constant (mutated ≤once/tick) so per-tick determinism holds.
## Validation (all 3 gates)
- **Compile:** clean — 0 errors / 0 warnings (Bursted `DashSystem`/`EnemyAISystem` reading the singleton compiled fine; no enum-on-Burst hazard, byte knob map).
- **EditMode: 267/267 green** (was 259; +8 `TuningConfigTests`: golden `Defaults()`==consts incl. `GruntWindupTicks==Tuning.AttackWindupTicks`, `Apply` index-map, tick/value clamps, out-of-range no-op, ×1000 wire round-trip, `ToReport`/`FromReport` round-trip, and a world test proving `DashSystem` reads a **non-default** singleton — IFrameUntilTick = now+20 with a tuned window, not Defaults' 12). The pre-existing `DashSystemTests` (asserting 112/121/145/200/20 with no singleton) double as the behaviour-preserving guard on the `Defaults()` fallback path.
- **Live Play (real netcode session, server+client):** both worlds create+seed `TuningConfig` to `Defaults()` (iframe 12 / dist 4 / chWindup 30 / lungeSpd 16 / grunt 18); a `SetTuning` RPC (iframe 12→18, chargerWindup 30→40) **applied on the server AND converged on the client via the broadcast**, untargeted knobs unchanged; **zero console errors/exceptions** (only benign Server-Tick-Batching warnings from in-editor host load) — no Burst stale-binary / undeclared-component throw.
## Notes / deviations (deliberate)
- Broadcast is **every 15 ticks unconditionally** (full state, not a delta) — simplest robust convergence for late joiners / thin clients; ~16 bytes/0.25 s on an editor-only loopback is negligible.
- Charger knobs are **server-only consumed** (clients never simulate Chargers) but are broadcast anyway for uniformity + overlay display.
- A mid-dash knob change desyncs the in-flight dash's baked window-length from its live recomputed speed for ONE dash (cosmetic, self-corrects next dash) — acceptable per the review.
## Open items (operator)
- **The MC-1 fun-gate is still the open gate** — now unblocked for fast live tuning: open `DEV ▲` → "- Tuning (MC-0) -", nudge dash/Charger knobs while playing, read the live counters in "- Telemetry (MC-0) -". MC-1 is NOT "done" until the feel pass + bench (timed vs spam ≥70% fewer hits) + friend read pass; MC-1 is the project kill-switch.
- After MC-1 passes → **MC-4 (melee cleave)** is the next committed code milestone ([[Path_to_Fun]]).
- `Assets/_Recovery/0.unity` untracked artifact still pending review/delete (carried from [[2026-06-09_MC1_Implementation]]).
## Links
[[Path_to_Fun]] · [[DR-029_Path_A_Fork_Locks]] · [[DR-028_Combat_Primary_Verb_Depth_First]] · [[2026-06-09_MC1_Implementation]] · [[2026-06-09_MC1_Build_Spec]] · [[validate-netcode-design-before-coding]]