Data-driven tuning bench: feel profiles (JSON gestalts + overlay A/B), asset-derived timing audit, server-measured combat probe

FeelProfileService: partial-apply profiles ride the authoritative SetTuning
path + FeelConfig reflection; Save-current captures hand-dialed state; 3
starter profiles (baseline-0720 / heavy-committed / snappier, DPS-held).
TuningAuditTools: contact truth derived from clip WindUp takes / state
speeds vs MeleeTiming (drift detector), coupling invariants, cadence/DPS,
reach-honesty ratio off the baked weapon mesh (now x0.93/x1.16, was >2x).
CombatProbe: dummy + injected mash chain, per-swing server-measured
start/contact/damage + cadence/DPS report. Verified live: snappier profile
apply -> measured 24/24/36 gaps, +12/+8/+15 contacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:28:33 -07:00
parent 2b742f0179
commit 730bad3d74
15 changed files with 520 additions and 0 deletions
@@ -0,0 +1,153 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.Text;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// 07-20 data-driven tuning: a MEASUREMENT probe for melee feel. In Play, spawns a huge-HP dummy in front of
/// the local player, holds the (injected) Attack for a scripted chain, and samples the SERVER truth every
/// editor frame: swing-start ticks, scheduled contact ticks, actual damage ticks + amounts. The report is
/// MEASURED cadence / contact latency / DPS / buffer usage — objective numbers to tune against instead of
/// eyeballs, valid under real tick-batching. Editor-only diagnostics (cross-world reads); drive from the
/// DebugOverlay button or execute_code: <c>ProjectM.Client.CombatProbe.Run(6);</c>, read the console.
/// </summary>
public static class CombatProbe
{
public static bool Running { get; private set; }
struct SwingRec { public uint Start; public uint Scheduled; public uint DamageTickObserved; public float Damage; }
public static void Run(int swings = 6, float aimX = 1f, float aimZ = 0f)
{
if (!Application.isPlaying) { Debug.LogWarning("[CombatProbe] Play mode only."); return; }
if (Running) { Debug.LogWarning("[CombatProbe] Already running."); return; }
World server = null;
foreach (var w in World.All) if (w.Name == "ServerWorld") server = w;
if (server == null) { Debug.LogWarning("[CombatProbe] No ServerWorld."); return; }
var sem = server.EntityManager;
// The local player's server twin (the entity that carries the scheduled-cleave slot).
var pq = sem.CreateEntityQuery(typeof(MeleeCleavePending), typeof(MeleeCombo), typeof(LocalTransform));
var players = pq.ToEntityArray(Allocator.Temp);
if (players.Length == 0) { players.Dispose(); Debug.LogWarning("[CombatProbe] No server player."); return; }
var player = players[0];
players.Dispose();
var playerPos = sem.GetComponentData<LocalTransform>(player).Position;
// Dummy: any enemy prefab, parked inside the cone, HP no swing chain can finish.
var prefQ = sem.CreateEntityQuery(new EntityQueryDesc
{
All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) },
Options = EntityQueryOptions.IncludePrefab,
});
var prefabs = prefQ.ToEntityArray(Allocator.Temp);
if (prefabs.Length == 0) { prefabs.Dispose(); Debug.LogWarning("[CombatProbe] No enemy prefab."); return; }
var dummy = sem.Instantiate(prefabs[0]);
prefabs.Dispose();
var baked = sem.GetComponentData<LocalTransform>(dummy);
var aim = math.normalizesafe(new float2(aimX, aimZ), new float2(1f, 0f));
sem.SetComponentData(dummy, baked.WithPosition(playerPos + new float3(aim.x, 0f, aim.y) * 1.6f));
sem.SetComponentData(dummy, new Health { Current = 100000f, Max = 100000f });
Running = true;
int frame = 0;
var recs = new List<SwingRec>();
uint lastSwing = 0; float lastHp = 100000f; int buffered = 0; uint firstTick = 0, lastTick = 0;
UnityEditor.EditorApplication.CallbackFunction cb = null;
cb = () =>
{
try
{
frame++;
World sw = null;
foreach (var w2 in World.All) if (w2.Name == "ServerWorld") sw = w2;
if (sw == null) { Finish("server world gone"); return; }
var em = sw.EntityManager;
if (!em.Exists(player) || !em.Exists(dummy)) { Finish("probe entity despawned"); return; }
uint tick = 0;
var ntq = em.CreateEntityQuery(typeof(NetworkTime));
if (!ntq.IsEmpty) { var nt = ntq.GetSingleton<NetworkTime>(); if (nt.ServerTick.IsValid) tick = nt.ServerTick.TickIndexForValidTick; }
if (firstTick == 0) firstTick = tick;
lastTick = tick;
var mc = em.GetComponentData<MeleeCombo>(player);
var pend = em.GetComponentData<MeleeCleavePending>(player);
float hp = em.GetComponentData<Health>(dummy).Current;
if (mc.SwingStartTick != 0 && mc.SwingStartTick != lastSwing)
{
recs.Add(new SwingRec { Start = mc.SwingStartTick, Scheduled = pend.ResolveTick });
lastSwing = mc.SwingStartTick;
}
else if (recs.Count > 0 && pend.ResolveTick != 0)
{
var r0 = recs[recs.Count - 1];
if (r0.Scheduled == 0) { r0.Scheduled = pend.ResolveTick; recs[recs.Count - 1] = r0; }
}
if (hp < lastHp && recs.Count > 0)
{
var r1 = recs[recs.Count - 1];
if (r1.DamageTickObserved == 0) { r1.DamageTickObserved = tick; r1.Damage = lastHp - hp; recs[recs.Count - 1] = r1; }
}
if (mc.BufferedAttackTick != 0) buffered++; // frames a buffer was armed (usage signal)
lastHp = hp;
// Drive: hold the attack (re-issued) until the target swing count is reached — a mashing player.
if (recs.Count < swings) { DebugInputInjectionSystem.SetAim(aim.x, aim.y); DebugInputInjectionSystem.Attack(3); }
bool lastResolved = recs.Count == 0 || recs[recs.Count - 1].DamageTickObserved != 0; // wait for the FINAL contact (a whiff falls through to the timeout)
if ((recs.Count >= swings && lastResolved) || frame > 900) Finish(null);
}
catch (System.Exception ex) { Finish("exception: " + ex.Message); }
};
UnityEditor.EditorApplication.update += cb;
Debug.Log($"[CombatProbe] Running: {swings} swings vs a 100k-HP dummy at +{1.6f:F1}m (aim {aim.x:F1},{aim.y:F1}).");
void Finish(string abort)
{
UnityEditor.EditorApplication.update -= cb;
DebugInputInjectionSystem.Stop();
Running = false;
World sw2 = null;
foreach (var w3 in World.All) if (w3.Name == "ServerWorld") sw2 = w3;
if (sw2 != null && sw2.EntityManager.Exists(dummy)) sw2.EntityManager.DestroyEntity(dummy);
var sb = new StringBuilder();
sb.AppendLine("==== COMBAT PROBE REPORT (server-measured) ====");
if (abort != null) sb.AppendLine(" ABORTED: " + abort);
float totalDmg = 0f;
for (int i = 0; i < recs.Count; i++)
{
var r = recs[i];
long contact = r.Scheduled != 0 ? (long)r.Scheduled - r.Start : -1;
long observed = r.DamageTickObserved != 0 ? (long)r.DamageTickObserved - r.Start : -1;
long gap = i > 0 ? (long)r.Start - recs[i - 1].Start : 0;
totalDmg += r.Damage;
sb.AppendLine($" swing {i + 1}: start {r.Start}"
+ (i > 0 ? $" (gap {gap}t = {gap / 60f:F2}s)" : "")
+ $" | scheduled contact +{contact}t | damage {r.Damage:F0} observed +{observed}t (frame-sampled)");
}
if (recs.Count > 1)
{
float span = (recs[recs.Count - 1].Start - recs[0].Start) / 60f;
sb.AppendLine($" MEASURED cadence: {span / (recs.Count - 1):F2}s/swing over {recs.Count} swings");
}
float dur = (lastTick - firstTick) / 60f;
if (dur > 0.5f) sb.AppendLine($" MEASURED DPS vs dummy: {totalDmg / dur:F1} over {dur:F1}s (total {totalDmg:F0})");
sb.AppendLine($" buffer armed on {buffered} sampled frames (mashing exercises the unlock-edge path)");
Debug.Log(sb.ToString());
}
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c20d0a512cd41f64191bc2457e059350
@@ -117,6 +117,20 @@ namespace ProjectM.Client
TuningRow("Cast turn deg", TuningKnob.CastTurnRateDeg, 60f, "0");
TuningRow("Move sharp", TuningKnob.MoveSharpness, 0.5f, "0.0");
// 07-20 data-driven tuning: whole-gestalt FEEL PROFILES (sim knobs + FeelConfig in one JSON) --
// A/B a feel with one click instead of dialing 30 scalars; Save captures the hand-dialed state.
GUILayout.Space(6);
GUILayout.Label("- Feel Profiles -");
foreach (var prof in FeelProfileService.ListProfiles())
if (GUILayout.Button(System.IO.Path.GetFileNameWithoutExtension(prof)))
FeelProfileService.Apply(prof);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Save current")) FeelProfileService.SaveCurrent();
if (GUILayout.Button("Rescan")) FeelProfileService.Rescan();
GUILayout.EndHorizontal();
if (GUILayout.Button(CombatProbe.Running ? "Probe running..." : "Combat probe (6 swings)") && !CombatProbe.Running)
CombatProbe.Run(6); // measured cadence/contact/DPS -> console
}
@@ -0,0 +1,154 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using ProjectM.Simulation;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// 07-20 data-driven tuning: a FEEL PROFILE is one JSON file holding a whole gestalt — sim knobs
/// (<see cref="TuningKnob"/> names → values, applied through the authoritative SetTuning path exactly like an
/// overlay nudge) + client feel statics (<see cref="FeelConfig"/> field names → values, set via reflection).
/// Profiles are PARTIAL: only the keys a profile lists are touched, so a profile can be a 4-line experiment.
/// A/B whole feels with one overlay click instead of twiddling 30 scalars; "save current" captures whatever
/// was hand-dialed. EDITOR-ONLY (same family as DebugOverlay / DebugInputInjectionSystem); profiles live in
/// Assets/_Project/Tuning/Profiles/*.json. When a profile wins, fold it into TuningConfig.Defaults() /
/// FeelConfig.ResetDefaults() (code stays the single shipped truth; profiles are experiments).
/// </summary>
public static class FeelProfileService
{
public const string ProfileDir = "Assets/_Project/Tuning/Profiles";
[Serializable] public class Entry { public string k; public string v; }
[Serializable]
public class ProfileData
{
public string name;
public string notes;
public List<Entry> sim = new List<Entry>();
public List<Entry> feel = new List<Entry>();
}
static string[] _cachedPaths;
/// <summary>Profile file paths (cached; <see cref="Rescan"/> after adding files).</summary>
public static string[] ListProfiles()
{
if (_cachedPaths == null) Rescan();
return _cachedPaths;
}
public static void Rescan()
{
_cachedPaths = Directory.Exists(ProfileDir)
? Directory.GetFiles(ProfileDir, "*.json")
: Array.Empty<string>();
Array.Sort(_cachedPaths);
}
/// <summary>Apply a profile: sim knobs ride the authoritative SetTuning RPC + the optimistic local copy
/// (identical to an overlay nudge; server clamps); feel fields are set by name. Play mode only for sim.</summary>
public static void Apply(string path)
{
var data = Load(path);
if (data == null) return;
int simApplied = 0, feelApplied = 0;
var knobByName = KnobByName();
foreach (var e in data.sim)
{
if (!knobByName.TryGetValue(e.k, out byte knob))
{ Debug.LogWarning($"[FeelProfile] Unknown sim knob '{e.k}' — skipped."); continue; }
if (!float.TryParse(e.v, NumberStyles.Float, CultureInfo.InvariantCulture, out float val))
{ Debug.LogWarning($"[FeelProfile] Bad value for {e.k}: '{e.v}'"); continue; }
if (Application.isPlaying)
{
DebugCommandSendSystem.SetTuning(knob, val); // authoritative (server applies + broadcasts; clamped)
TuningReadout.SetLocal(knob, val); // optimistic local (instant feel; clamped)
}
simApplied++;
}
foreach (var e in data.feel)
{
var f = typeof(FeelConfig).GetField(e.k, BindingFlags.Public | BindingFlags.Static);
if (f == null) { Debug.LogWarning($"[FeelProfile] Unknown FeelConfig field '{e.k}' — skipped."); continue; }
try { f.SetValue(null, ParseFeel(f.FieldType, e.v)); feelApplied++; }
catch (Exception ex) { Debug.LogWarning($"[FeelProfile] {e.k}: {ex.Message}"); }
}
string simNote = Application.isPlaying ? simApplied.ToString() : simApplied + " (LISTED ONLY — sim knobs need Play mode)";
Debug.Log($"[FeelProfile] Applied '{data.name}' ({Path.GetFileName(path)}): sim {simNote}, feel {feelApplied}. {data.notes}");
}
/// <summary>Snapshot the CURRENT tuning (live knob readout + every FeelConfig field) as a new profile.
/// Outside Play the feel statics are re-stamped to defaults first (they are zero after a domain reload).</summary>
public static string SaveCurrent(string profileName = null)
{
if (!Application.isPlaying) FeelConfig.ResetDefaults();
var data = new ProfileData
{
name = string.IsNullOrEmpty(profileName) ? "captured-" + DateTime.Now.ToString("MMdd-HHmm") : profileName,
notes = "Captured from the live session " + DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
};
foreach (var kv in KnobByName())
data.sim.Add(new Entry { k = kv.Key, v = TuningConfig.Get(TuningReadout.Current, kv.Value).ToString(CultureInfo.InvariantCulture) });
foreach (var f in typeof(FeelConfig).GetFields(BindingFlags.Public | BindingFlags.Static))
data.feel.Add(new Entry { k = f.Name, v = FormatFeel(f.GetValue(null)) });
Directory.CreateDirectory(ProfileDir);
string path = Path.Combine(ProfileDir, data.name + ".json");
File.WriteAllText(path, JsonUtility.ToJson(data, prettyPrint: true));
UnityEditor.AssetDatabase.Refresh();
Rescan();
Debug.Log($"[FeelProfile] Saved current tuning to {path} ({data.sim.Count} sim, {data.feel.Count} feel).");
return path;
}
static ProfileData Load(string path)
{
try { return JsonUtility.FromJson<ProfileData>(File.ReadAllText(path)); }
catch (Exception ex) { Debug.LogError($"[FeelProfile] Failed to read {path}: {ex.Message}"); return null; }
}
static Dictionary<string, byte> KnobByName()
{
var map = new Dictionary<string, byte>();
foreach (var f in typeof(TuningKnob).GetFields(BindingFlags.Public | BindingFlags.Static))
if (f.IsLiteral && f.FieldType == typeof(byte) && f.Name != "Count")
map[f.Name] = (byte)f.GetRawConstantValue();
return map;
}
static object ParseFeel(Type t, string v)
{
if (t == typeof(float)) return float.Parse(v, CultureInfo.InvariantCulture);
if (t == typeof(int)) return int.Parse(v, CultureInfo.InvariantCulture);
if (t == typeof(bool)) return bool.Parse(v);
if (t == typeof(Color))
{
var p = v.Split(',');
return new Color(
float.Parse(p[0], CultureInfo.InvariantCulture), float.Parse(p[1], CultureInfo.InvariantCulture),
float.Parse(p[2], CultureInfo.InvariantCulture), p.Length > 3 ? float.Parse(p[3], CultureInfo.InvariantCulture) : 1f);
}
throw new NotSupportedException($"unsupported field type {t.Name}");
}
static string FormatFeel(object val)
{
switch (val)
{
case float f: return f.ToString(CultureInfo.InvariantCulture);
case int i: return i.ToString(CultureInfo.InvariantCulture);
case bool b: return b.ToString();
case Color c: return string.Join(",",
c.r.ToString(CultureInfo.InvariantCulture), c.g.ToString(CultureInfo.InvariantCulture),
c.b.ToString(CultureInfo.InvariantCulture), c.a.ToString(CultureInfo.InvariantCulture));
default: return val?.ToString() ?? "";
}
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 696cc12527a12a74fbc5758a3e949bd5