diff --git a/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs
new file mode 100644
index 000000000..0f54e962c
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs
@@ -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
+{
+ ///
+ /// 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: ProjectM.Client.CombatProbe.Run(6);, read the console.
+ ///
+ 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(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(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();
+ 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(); if (nt.ServerTick.IsValid) tick = nt.ServerTick.TickIndexForValidTick; }
+ if (firstTick == 0) firstTick = tick;
+ lastTick = tick;
+
+ var mc = em.GetComponentData(player);
+ var pend = em.GetComponentData(player);
+ float hp = em.GetComponentData(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
diff --git a/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta
new file mode 100644
index 000000000..1cbe42ab6
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c20d0a512cd41f64191bc2457e059350
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
index d8bc7d5b3..305d0ec13 100644
--- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
+++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
@@ -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
+
}
diff --git a/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs
new file mode 100644
index 000000000..9e58b4a55
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs
@@ -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
+{
+ ///
+ /// 07-20 data-driven tuning: a FEEL PROFILE is one JSON file holding a whole gestalt — sim knobs
+ /// ( names → values, applied through the authoritative SetTuning path exactly like an
+ /// overlay nudge) + client feel statics ( 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).
+ ///
+ 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 sim = new List();
+ public List feel = new List();
+ }
+
+ static string[] _cachedPaths;
+
+ /// Profile file paths (cached; after adding files).
+ public static string[] ListProfiles()
+ {
+ if (_cachedPaths == null) Rescan();
+ return _cachedPaths;
+ }
+
+ public static void Rescan()
+ {
+ _cachedPaths = Directory.Exists(ProfileDir)
+ ? Directory.GetFiles(ProfileDir, "*.json")
+ : Array.Empty();
+ Array.Sort(_cachedPaths);
+ }
+
+ /// 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.
+ 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}");
+ }
+
+ /// 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).
+ 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(File.ReadAllText(path)); }
+ catch (Exception ex) { Debug.LogError($"[FeelProfile] Failed to read {path}: {ex.Message}"); return null; }
+ }
+
+ static Dictionary KnobByName()
+ {
+ var map = new Dictionary();
+ 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
diff --git a/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta
new file mode 100644
index 000000000..4262f1148
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 696cc12527a12a74fbc5758a3e949bd5
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs
new file mode 100644
index 000000000..e66f694e7
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs
@@ -0,0 +1,102 @@
+using System.Text;
+using ProjectM.Simulation;
+using UnityEditor;
+using UnityEditor.Animations;
+using UnityEngine;
+
+namespace ProjectM.EditorTools
+{
+ ///
+ /// 07-20 data-driven tuning: derive the melee timing TRUTH from the assets themselves instead of hand-copied
+ /// constants — clip WindUp lengths / wired state speeds → the real per-step contact seconds/ticks, compared
+ /// against what MeleeTiming + the MeleeContactTicks knob actually produce; the coupling invariants the design
+ /// review flagged as un-clamped (contact < recover, contact ≤ CastFacingTicks); cadence/DPS math; and the
+ /// weapon-vs-cone reach-honesty ratio measured off the BAKED weapon mesh. Run after any clip re-speed,
+ /// wire-tool re-run, or knob-default change — drift shows up here before it shows up as jank.
+ ///
+ public static class TuningAuditTools
+ {
+ const string Controller = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
+ const string PackDir = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/";
+ const string WeaponMesh = "Assets/_Project/Art/Models/Rebased_SM_Wep_Melee.asset";
+
+ [MenuItem("ProjectM/Tuning/Audit Melee Timing + Reach")]
+ public static void AuditMeleeTimingMenu() => AuditMeleeTiming();
+
+ public static string AuditMeleeTiming()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("==== MELEE TUNING AUDIT (derived from assets vs configured) ====");
+ var t = TuningConfig.Defaults();
+ float contactKnob = t.MeleeContactTicks;
+ uint recover = (uint)Mathf.Max(1f, t.MeleeRecoverTicks);
+ uint finRecover = (uint)Mathf.Max(1f, Mathf.Round(t.MeleeRecoverTicks * Mathf.Max(1f, t.MeleeFinisherMult)));
+
+ var ac = AssetDatabase.LoadAssetAtPath(Controller);
+ if (ac == null) { Debug.LogError("[TuningAudit] Controller missing."); return "controller missing"; }
+ var sm = ac.layers[0].stateMachine;
+
+ // (state, windup-take fbx, windup clip name, combo step, lock that gates this step's chain)
+ var rows = new (string state, string fbx, string clip, byte step, uint stepRecover)[]
+ {
+ ("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_WindUp_Sword", 1, recover),
+ ("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_WindUp_Sword", 2, recover),
+ ("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_WindUp_Sword", 3, finRecover),
+ };
+ bool anyDrift = false;
+ foreach (var r in rows)
+ {
+ AnimatorState st = null;
+ foreach (var cs in sm.states) if (cs.state.name == r.state) st = cs.state;
+ if (st == null) { sb.AppendLine($" {r.state}: STATE MISSING"); continue; }
+ AnimationClip windup = null;
+ foreach (var o in AssetDatabase.LoadAllAssetsAtPath(PackDir + r.fbx + ".fbx"))
+ if (o is AnimationClip c && c.name == r.clip) windup = c;
+ if (windup == null) { sb.AppendLine($" {r.state}: windup take '{r.clip}' MISSING"); continue; }
+
+ float speed = Mathf.Max(0.01f, st.speed);
+ float derivedSec = windup.length / speed; // the blade lands when the WindUp take ends
+ uint derivedTicks = (uint)Mathf.Round(derivedSec * 60f);
+ uint configured = MeleeTiming.ContactTicks(r.step, contactKnob);
+ bool drift = derivedTicks != configured;
+ anyDrift |= drift;
+ sb.AppendLine($" {r.state}: clip windup {windup.length:F3}s @ speed {speed:F2} -> TRUE contact {derivedSec:F3}s = {derivedTicks}t | configured {configured}t {(drift ? " << DRIFT" : " OK")}");
+ if (configured >= r.stepRecover) sb.AppendLine($" !! contact {configured}t >= step lock {r.stepRecover}t (flush-at-overwrite will fire it EARLY every chain)");
+ if (configured > PlayerAimSystem.CastFacingTicks) sb.AppendLine($" !! contact {configured}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (aim-steer + anim pulse end BEFORE the blade lands)");
+ }
+ if (anyDrift) sb.AppendLine(" -> DRIFT fix: retune MeleeContactTicks (base = Swing1's true ticks) and/or MeleeTiming's step fractions.");
+
+ // Cadence / DPS at the configured knobs (chain = light, light, finisher).
+ float dmg = t.MeleeDamage;
+ float chainSec = (2f * recover + finRecover) / 60f;
+ float chainDmg = 2f * dmg + dmg * Mathf.Max(1f, t.MeleeFinisherMult);
+ sb.AppendLine($" Cadence: light lock {recover}t ({recover / 60f:F2}s), finisher lock {finRecover}t ({finRecover / 60f:F2}s); full chain {chainSec:F2}s");
+ sb.AppendLine($" DPS: per-light {dmg / (recover / 60f):F1} | full-chain {chainDmg / chainSec:F1} (dmg {dmg}, finisher x{t.MeleeFinisherMult})");
+ sb.AppendLine($" Buffer: {t.MeleeBufferTicks}t window, unlock slack {MeleeTiming.BatchSlackTicks}t (batch-proof)");
+
+ // Reach honesty: the BAKED weapon mesh's max planar radius (rest pose, feet-at-0 space) vs the cone.
+ var mesh = AssetDatabase.LoadAssetAtPath(WeaponMesh);
+ if (mesh != null)
+ {
+ float maxR = 0f;
+ var verts = mesh.vertices;
+ for (int i = 0; i < verts.Length; i++)
+ {
+ float rr = new Vector2(verts[i].x, verts[i].z).magnitude;
+ if (rr > maxR) maxR = rr;
+ }
+ float swingReach = maxR + 0.45f; // rest radius + typical arm extension at full swing (approx)
+ float range = t.MeleeRange;
+ float finRange = range * Mathf.Max(0.01f, t.MeleeFinisherRangeMult);
+ sb.AppendLine($" Reach: weapon rest radius {maxR:F2}m (~{swingReach:F2}m swept) vs cone {range:F2}m (x{range / swingReach:F2}) | finisher {finRange:F2}m (x{finRange / swingReach:F2})");
+ sb.AppendLine($" honesty budget: keep the ratio <= ~1.25x (guidelines G2; >1.4x reads as the old 'arc lies' complaint)");
+ }
+ else sb.AppendLine(" Reach: weapon mesh not found (run Attach Melee Weapon first)");
+
+ sb.AppendLine(" NOTE: SpecialSlam (cone socket) still damages AT FIRE, ~0.35s before its visual contact — the");
+ sb.AppendLine(" same dishonesty melee just fixed; it rides the SOCKET pipeline (guidelines G6 follow-up).");
+ Debug.Log(sb.ToString());
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta
new file mode 100644
index 000000000..5be4b9038
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f9457f271f6d69e4c92aa4a273af46fc
\ No newline at end of file
diff --git a/Assets/_Project/Tuning.meta b/Assets/_Project/Tuning.meta
new file mode 100644
index 000000000..8cba56243
--- /dev/null
+++ b/Assets/_Project/Tuning.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: dc29876a7efaad54da85c8c668fd3215
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Tuning/Profiles.meta b/Assets/_Project/Tuning/Profiles.meta
new file mode 100644
index 000000000..617eb0be0
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c9422a862aa42ce49be71d2cab58f0b8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Tuning/Profiles/baseline-0720.json b/Assets/_Project/Tuning/Profiles/baseline-0720.json
new file mode 100644
index 000000000..00e8fbd7b
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/baseline-0720.json
@@ -0,0 +1,20 @@
+{
+ "name": "baseline-0720",
+ "notes": "The shipped 07-20 defaults (forks 1-6). Apply to RESET after experiments. DPS ~60 held: dmg = recover ticks.",
+ "sim": [
+ { "k": "MeleeDamage", "v": "30" },
+ { "k": "MeleeRange", "v": "2.2" },
+ { "k": "MeleeRecoverTicks", "v": "30" },
+ { "k": "MeleeChainGraceTicks", "v": "24" },
+ { "k": "MeleeFinisherMult", "v": "1.5" },
+ { "k": "MeleeFinisherRangeMult", "v": "1.25" },
+ { "k": "MeleeBufferTicks", "v": "8" },
+ { "k": "MeleeContactTicks", "v": "16" }
+ ],
+ "feel": [
+ { "k": "MeleeArcIntensity", "v": "1" },
+ { "k": "MeleeArcBubbles", "v": "6" },
+ { "k": "FinisherHoldFrames", "v": "5" },
+ { "k": "CombatIdleHoldSec", "v": "4" }
+ ]
+}
diff --git a/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta b/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta
new file mode 100644
index 000000000..d43969c83
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: d5f21d9955bf61d4ca0e4543712e8b46
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Tuning/Profiles/heavy-committed.json b/Assets/_Project/Tuning/Profiles/heavy-committed.json
new file mode 100644
index 000000000..37a23ca8f
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/heavy-committed.json
@@ -0,0 +1,17 @@
+{
+ "name": "heavy-committed",
+ "notes": "Slower, weightier: MonHun-lean. Longer lock + contact, bigger hit + hold, dimmer arc (the weapon is the read). DPS ~60 held.",
+ "sim": [
+ { "k": "MeleeDamage", "v": "36" },
+ { "k": "MeleeRecoverTicks", "v": "36" },
+ { "k": "MeleeChainGraceTicks", "v": "28" },
+ { "k": "MeleeContactTicks", "v": "20" },
+ { "k": "MeleeBufferTicks", "v": "10" },
+ { "k": "MeleeSwingMoveScale", "v": "0.25" }
+ ],
+ "feel": [
+ { "k": "MeleeArcIntensity", "v": "0.8" },
+ { "k": "FinisherHoldFrames", "v": "7" },
+ { "k": "MeleeConnectFovKick", "v": "1.1" }
+ ]
+}
diff --git a/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta b/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta
new file mode 100644
index 000000000..66a1423e5
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2ed6c17ceb8dc044f885d858b99e9a21
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Tuning/Profiles/snappier.json b/Assets/_Project/Tuning/Profiles/snappier.json
new file mode 100644
index 000000000..5ebcfdddc
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/snappier.json
@@ -0,0 +1,17 @@
+{
+ "name": "snappier",
+ "notes": "Faster, Hades-lean: shorter lock + contact, lighter hits, freer movement mid-swing. DPS ~60 held. Contact 12 keeps step-3 (15t) under CastFacingTicks 26.",
+ "sim": [
+ { "k": "MeleeDamage", "v": "24" },
+ { "k": "MeleeRecoverTicks", "v": "24" },
+ { "k": "MeleeChainGraceTicks", "v": "20" },
+ { "k": "MeleeContactTicks", "v": "12" },
+ { "k": "MeleeBufferTicks", "v": "8" },
+ { "k": "MeleeSwingMoveScale", "v": "0.45" }
+ ],
+ "feel": [
+ { "k": "MeleeArcIntensity", "v": "1.1" },
+ { "k": "FinisherHoldFrames", "v": "4" },
+ { "k": "MeleeConnectFovKick", "v": "0.7" }
+ ]
+}
diff --git a/Assets/_Project/Tuning/Profiles/snappier.json.meta b/Assets/_Project/Tuning/Profiles/snappier.json.meta
new file mode 100644
index 000000000..c568ee06f
--- /dev/null
+++ b/Assets/_Project/Tuning/Profiles/snappier.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: b58b377cf031b0b489bd1e1234a1f3ec
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant: