Compare commits

..

8 Commits

Author SHA1 Message Date
kronic 4e795a38e9 Docs: 07-21 heavy feel lock session log
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:13:50 -07:00
kronic ce0faafb72 HEAVY FEEL LOCKED: the A/B winner folded into defaults, clips retimed to zero contact drift
recover 36 / dmg 36 (DPS 60 held) / contact 20 (20/12/25 per step) /
grace 28 / buffer 10 / move-commit 0.25; arc 0.8, finisher hold 7,
connect kick 1.1. Clip state speeds retimed 1.0/0.85/0.88 so the VISUAL
blade contact matches the damage tick exactly (audit: zero drift; probe:
gaps 36/36/54, contacts +20/+12/+25 measured). Step-3 contact sits 1
tick under CastFacingTicks 26 (documented coupling). baseline profile =
the locked defaults; heavy-committed kept as the record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:13:49 -07:00
kronic 7f032571df Weapon grip anchor: gizmo-authored WYSIWYG grip + operator's hand-tuned grip baked
WeaponGripAnchor under Hand_R in Player.prefab carries the actual axe
mesh as an EditorOnly preview (baker skips it; play-verified single axe)
so grip manipulation is the real model live in the prefab stage;
AttachMeleeWeapon reads the anchor (consts = seed/fallback). Includes
the operator's first gizmo-tuned grip (rebaked mesh matches expected
radius exactly). PlayerRigTools also carries the heavy-lock clip
re-speeds (1.0/0.85/0.88) consumed by the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:13:48 -07:00
kronic 2710d680b4 Sandbox target dummy: planted EnemyTag ghost + DevSandbox respawn spawner
GymSub bakes no WaveDirector (why sandbox enemies never spawned); the
sandbox's combat target is now a TargetDummyTag'd enemy ghost all five
EnemyAISystem passes exclude (planted; knockback stamps inert), spawned
at player+3m with 400 HP by an editor-only scene-gated server system,
dying through the normal Dying path and respawning 1.5s after the corpse.
EnemyTag-reuse audited: sandbox-only by design (cleared-checks would
count it elsewhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:13:47 -07:00
kronic cd634a9df5 Docs: 07-20 weapon grip anchor session log (gizmo-authored WYSIWYG grip flow)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:58:14 -07:00
kronic 760c77c083 Docs: 07-20 sandbox target dummy session log (planted dummy + GymSub no-director diagnosis)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:40 -07:00
kronic c64cda6e44 Docs: 07-20 tuning bench session log (profiles / audit / probe workflow)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:34 -07:00
kronic 730bad3d74 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>
2026-07-20 21:28:33 -07:00
32 changed files with 1234 additions and 123 deletions
@@ -633,7 +633,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Swing3
m_Speed: 1.1
m_Speed: 0.88
m_CycleOffset: 0
m_Transitions:
- {fileID: 7246856984932168808}
@@ -779,7 +779,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Swing2
m_Speed: 1
m_Speed: 0.85
m_CycleOffset: 0
m_Transitions:
- {fileID: -7565464033697258994}
@@ -1050,7 +1050,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Swing1
m_Speed: 1.25
m_Speed: 1
m_CycleOffset: 0
m_Transitions:
- {fileID: -1792799526728587842}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,137 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_Weapon_Preview
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 2800000, guid: 48dd9a7736c11b5448a74711310e2ee6, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 2800000, guid: 48dd9a7736c11b5448a74711310e2ee6, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &3355021883474362010
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b1208b4fab8c6c84c9655fcc3da69efe
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+185 -94
View File
@@ -543,6 +543,96 @@ Transform:
- {fileID: 8387096127486878932}
m_Father: {fileID: 3285138630030188913}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1276864559111187745
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7059114398337531242}
- component: {fileID: 3316843276031498534}
- component: {fileID: 195853059780659648}
m_Layer: 0
m_Name: WeaponGripAnchor
m_TagString: EditorOnly
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &7059114398337531242
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1276864559111187745}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: -0.7071068, w: 0.7071068}
m_LocalPosition: {x: 0, y: 0.06, z: 0.02}
m_LocalScale: {x: 1.25, y: 1.25, z: 1.25}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7714500739160558408}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3316843276031498534
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1276864559111187745}
m_Mesh: {fileID: 4300000, guid: 779990e366a12f94ebf06711efef57d3, type: 3}
--- !u!23 &195853059780659648
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1276864559111187745}
m_Enabled: 1
m_CastShadows: 0
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: b1208b4fab8c6c84c9655fcc3da69efe, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!1 &1325769919043544559
GameObject:
m_ObjectHideFlags: 0
@@ -833,7 +923,7 @@ Transform:
- {fileID: 100450397525093210}
- {fileID: 795319999662639885}
- {fileID: 1063840860590240591}
- {fileID: 136525282400229282}
- {fileID: 1295007706386937831}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &8290370249712404227
@@ -2239,6 +2329,99 @@ Transform:
- {fileID: 6528362243562002222}
m_Father: {fileID: 2241067459902915878}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &4881940712018230872
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1295007706386937831}
- component: {fileID: 1300809461631886519}
m_Layer: 0
m_Name: SM_Wep_Melee
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1295007706386937831
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4881940712018230872}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 194292233036670670}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!137 &1300809461631886519
SkinnedMeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4881940712018230872}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 3
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 53abed4a039a2944498dc0d43258baf5, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
serializedVersion: 2
m_Quality: 0
m_UpdateWhenOffscreen: 0
m_SkinnedMotionVectors: 1
m_Mesh: {fileID: 4300000, guid: 0d76db2ce815146419fd52c745104909, type: 2}
m_Bones:
- {fileID: 7714500739160558408}
m_BlendShapeWeights: []
m_RootBone: {fileID: 7714500739160558408}
m_AABB:
m_Center: {x: 0.41444796, y: 0.059999853, z: 0.02000001}
m_Extent: {x: 0.6914556, y: 0.07196632, z: 0.31731963}
m_DirtyAABB: 0
--- !u!1 &5369485156592443817
GameObject:
m_ObjectHideFlags: 0
@@ -2949,99 +3132,6 @@ MeshRenderer:
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!1 &7369922033552706299
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 136525282400229282}
- component: {fileID: 4540681531604194498}
m_Layer: 0
m_Name: SM_Wep_Melee
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &136525282400229282
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7369922033552706299}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 194292233036670670}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!137 &4540681531604194498
SkinnedMeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7369922033552706299}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 3
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 53abed4a039a2944498dc0d43258baf5, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
serializedVersion: 2
m_Quality: 0
m_UpdateWhenOffscreen: 0
m_SkinnedMotionVectors: 1
m_Mesh: {fileID: 4300000, guid: 0d76db2ce815146419fd52c745104909, type: 2}
m_Bones:
- {fileID: 7714500739160558408}
m_BlendShapeWeights: []
m_RootBone: {fileID: 7714500739160558408}
m_AABB:
m_Center: {x: 0.41444784, y: 0.060000006, z: 0.02000001}
m_Extent: {x: 0.6914554, y: 0.071966305, z: 0.31731963}
m_DirtyAABB: 0
--- !u!1 &7432474647424217847
GameObject:
m_ObjectHideFlags: 0
@@ -3488,6 +3578,7 @@ Transform:
- {fileID: 133776596909499235}
- {fileID: 8397794599406007036}
- {fileID: 7135072282735691904}
- {fileID: 7059114398337531242}
m_Father: {fileID: 5022670406498921928}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &8130402508941367653
@@ -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
@@ -267,7 +267,7 @@ namespace ProjectM.Client
// Combat feel pass (2026-06)
HitFlashBurstCount = 16;
MeleeConnectFovKick = 0.8f;
MeleeConnectFovKick = 1.1f; // 07-21 HEAVY LOCK: meatier bite
MeleeConnectVolume = 0.55f;
KillFlashBurstCount = 20;
FootstepVolume = 0.30f; // 07-15: heavier, more present underwater thud
@@ -287,9 +287,9 @@ namespace ProjectM.Client
ShoulderLampIntensity = 8f; // 07-16e: the suit lamp actually lights (3 got lost in the murk ambient)
ShoulderLampRange = 12f;
CombatIdleHoldSec = 4f; // 07-18: Menacing01 combat-idle hold after the last swing
MeleeArcIntensity = 1f; // 07-19: arc colors are pre-toned dim; this scales from there
MeleeArcIntensity = 0.8f; // 07-21 HEAVY LOCK: the weapon is the read, the arc recedes further
MeleeArcBubbles = 6;
FinisherHoldFrames = 5; // 07-20 G5: the finisher's heavy beat (~83ms), capped well under the 8-frame ladder ceiling
FinisherHoldFrames = 7; // 07-21 HEAVY LOCK: a fatter finisher beat (~117ms, still under the 8-frame ladder cap)
RumbleEnabled = true;
@@ -674,9 +674,9 @@ namespace ProjectM.EditorTools
// window. Hit-start seconds measured off the pack takes (= the WindUp take length).
var swaps = new (string state, string fbxSub, string clip, float speed)[]
{
("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_Sword", 1.25f), // hit @0.33s -> ~0.27s (07-19 retime: 26-tick window)
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 1.0f), // hit @0.17s natural speed
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 1.1f), // finisher: hit @0.33s, heaviest of the chain
("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_Sword", 1.0f), // 07-21 heavy lock: TRUE contact 0.333s = 20t (matches MeleeContactTicks 20)
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 0.85f), // TRUE contact 0.196s = 12t (knob step2 = round(20*0.625) = 12)
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 0.88f), // finisher: TRUE contact 0.417s = 25t (knob step3 = 25; 1 tick under CastFacingTicks)
("SpecialSlam", "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword", "A_Attack_HeavyCombo01A_Slam_Sword", 1.5f), // custom take (skips ~2/3 of the windup); hit ~0.35s
("Death", "Death/A_Death_B_01_Sword", "A_Death_B_01_Sword", 1.0f), // backward collapse, holds last frame
};
@@ -784,7 +784,22 @@ namespace ProjectM.EditorTools
// Rigid rest matrices (GraftSmr conventions: scale-stripped rest, feet-at-0 prefab space).
var hm = hand.localToWorldMatrix;
var handRigid = Matrix4x4.TRS((Vector3)hm.GetColumn(3), hm.rotation, Vector3.one);
var grip = Matrix4x4.TRS(WeaponGripPos, Quaternion.Euler(WeaponGripEuler), Vector3.one * WeaponScale); // uniform scale: normals keep their directions
// 07-20 designer flow: the WeaponGripAnchor child of Hand_R (EditorOnly, carries a live preview of
// the weapon mesh) is the AUTHORING source when present -- move/rotate/scale it with normal gizmos
// in the prefab stage, then re-run this tool to bake. The consts are the fallback/seed only.
Vector3 gripPos = WeaponGripPos;
Quaternion gripRot = Quaternion.Euler(WeaponGripEuler);
float gripScale = WeaponScale;
var gripAnchor = hand.Find("WeaponGripAnchor");
if (gripAnchor != null)
{
gripPos = gripAnchor.localPosition;
gripRot = gripAnchor.localRotation;
gripScale = gripAnchor.localScale.x; // uniform: gizmo-scaled non-uniformly -> x wins
if (Mathf.Abs(gripAnchor.localScale.y - gripScale) > 1e-3f || Mathf.Abs(gripAnchor.localScale.z - gripScale) > 1e-3f)
Debug.LogWarning("[PlayerRigTools] WeaponGripAnchor scale is non-uniform; using X for the rigid bake.");
}
var grip = Matrix4x4.TRS(gripPos, gripRot, Vector3.one * gripScale); // uniform scale: normals keep their directions
var toWorld = handRigid * grip;
var verts = srcMesh.vertices;
@@ -838,8 +853,79 @@ namespace ProjectM.EditorTools
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v, grip pos {WeaponGripPos} euler {WeaponGripEuler}).");
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v). Grip source: WeaponGripAnchor if present (gizmo-authored), else the consts. Re-run after moving the anchor.");
}
/// <summary>07-20 designer grip flow: create (or refresh the preview on) the WeaponGripAnchor under
/// Hand_R in Player.prefab. The anchor CARRIES the actual weapon mesh as an edit-time preview (plain
/// MeshRenderer + a URP/Lit material with the pack atlas), so what you grab with the move/rotate/scale
/// gizmos in the prefab stage IS the axe at true size, live. Tagged <b>EditorOnly</b> -- the Entities
/// baker skips it, so it can never leak an un-animated duplicate into the game; the baked rigid-skinned
/// SMR stays the only runtime weapon. Until you re-run Attach Melee Weapon you'll see BOTH (preview at
/// the new grip, baked axe at the old) -- a free before/after ghost; when they coincide, you're baked.
/// Seeds from the consts ONLY on first creation; re-runs never stomp a hand-tuned anchor.</summary>
[MenuItem("ProjectM/Animation/Player - Create Weapon Grip Anchor (LANTERN)")]
public static void CreateWeaponGripAnchor()
{
const string weaponFbx = "Assets/Synty/PolygonDungeonRealms/Models/SM_Wep_Axe_Large_01.fbx";
const string atlasPath = "Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png";
const string previewMatPath = "Assets/_Project/Materials/M_Weapon_Preview.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
var src = AssetDatabase.LoadAssetAtPath<GameObject>(weaponFbx);
var srcMf = src != null ? src.GetComponentInChildren<MeshFilter>(true) : null;
if (srcMf == null || srcMf.sharedMesh == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx/mesh missing: {weaponFbx}"); return; }
// Plain URP/Lit preview material (the runtime deformation shader won't render on a static MeshRenderer).
var mat = AssetDatabase.LoadAssetAtPath<Material>(previewMatPath);
if (mat == null)
{
var lit = Shader.Find("Universal Render Pipeline/Lit");
if (lit == null) { Debug.LogError("[PlayerRigTools] URP/Lit shader not found."); return; }
mat = new Material(lit) { name = "M_Weapon_Preview" };
var atlas = AssetDatabase.LoadAssetAtPath<Texture>(atlasPath);
if (atlas != null && mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", atlas);
AssetDatabase.CreateAsset(mat, previewMatPath);
}
var root = PrefabUtility.LoadPrefabContents(output);
try
{
Transform hand = null;
foreach (var t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "Hand_R") { hand = t; break; }
if (hand == null) { Debug.LogError("[PlayerRigTools] Hand_R not found on Player.prefab."); return; }
var anchor = hand.Find("WeaponGripAnchor");
bool fresh = anchor == null;
if (fresh)
{
var go = new GameObject("WeaponGripAnchor");
anchor = go.transform;
anchor.SetParent(hand, false);
// Seed exactly where the current bake sits, so the preview and the baked axe start coincident.
anchor.localPosition = WeaponGripPos;
anchor.localRotation = Quaternion.Euler(WeaponGripEuler);
anchor.localScale = Vector3.one * WeaponScale;
}
anchor.gameObject.tag = "EditorOnly"; // the Entities baker SKIPS EditorOnly -- preview never reaches runtime
var mf = anchor.GetComponent<MeshFilter>();
if (mf == null) mf = anchor.gameObject.AddComponent<MeshFilter>();
mf.sharedMesh = srcMf.sharedMesh;
var mr = anchor.GetComponent<MeshRenderer>();
if (mr == null) mr = anchor.gameObject.AddComponent<MeshRenderer>();
mr.sharedMaterial = mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] WeaponGripAnchor ready under Hand_R (EditorOnly, live axe preview). "
+ "Open Player.prefab, search 'WeaponGripAnchor', move/rotate/scale it with normal gizmos, then run "
+ "'Player - Attach Melee Weapon' to bake. Preview + baked axe overlap = you're in sync.");
}
}
}
@@ -0,0 +1,102 @@
using System.Text;
using ProjectM.Simulation;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace ProjectM.EditorTools
{
/// <summary>
/// 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 &lt; 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.
/// </summary>
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<AnimatorController>(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<Mesh>(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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9457f271f6d69e4c92aa4a273af46fc
@@ -120,14 +120,14 @@ namespace ProjectM.Server
if (sweep)
{
foreach (var depenXform in SystemAPI.Query<RefRW<LocalTransform>>()
.WithAll<EnemyTag>().WithNone<Dying, BossState>())
.WithAll<EnemyTag>().WithNone<Dying, BossState>().WithNone<TargetDummyTag>())
depenXform.ValueRW.Position = EnemyMoveUtil.Depenetrate(in physics, depenXform.ValueRO.Position, SweepRadius, envFilter);
}
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>())
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>().WithNone<TargetDummyTag>())
{
float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region;
@@ -398,7 +398,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, knockback, windup, spitter, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>())
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>().WithNone<TargetDummyTag>())
{
float3 pos = xform.ValueRO.Position;
byte sRegion = region.ValueRO.Region;
@@ -503,7 +503,7 @@ namespace ProjectM.Server
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
foreach (var (lunge, isLunging) in
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
.WithAll<EnemyTag>().WithPresent<IsLunging>().WithNone<Dying>())
.WithAll<EnemyTag>().WithPresent<IsLunging>().WithNone<Dying>().WithNone<TargetDummyTag>())
{
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
}
@@ -591,7 +591,7 @@ namespace ProjectM.Server
float nudgeStep = UnstickNudgeSpeed * dt;
foreach (var (nxform, nstats, nav, nregion, nent) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyNavState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>().WithEntityAccess())
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>().WithNone<TargetDummyTag>().WithEntityAccess())
{
float3 npos = nxform.ValueRO.Position;
byte nRegion = nregion.ValueRO.Region;
@@ -0,0 +1,85 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// 07-20 sandbox combat target: keeps ONE planted <see cref="TargetDummyTag"/> dummy alive in the DevSandbox
/// (scene-name gate — the established dev-script convention; GymSub bakes no WaveDirector, so this is the
/// sandbox's enemy source by design). The dummy is a real enemy ghost instantiated from a baked prefab
/// (grunt-shaped preferred), parked at the first player's position + a fixed offset, HP fat enough to eat a
/// few chains; it dies through the NORMAL death path (Dying corpse window → destroy → kill pop on the client)
/// and respawns at the same anchor shortly after — kill feedback stays testable on repeat. Editor-only.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class TargetDummySpawnSystem : SystemBase
{
const float RespawnDelaySec = 1.5f;
const float DummyHealth = 400f;
static readonly float3 AnchorOffset = new float3(3f, 0f, 1.5f);
float _respawnTimer;
float3 _anchor;
bool _anchored;
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "DevSandbox")
return;
// A dummy exists (alive OR in its Dying corpse window) -> nothing to do; the corpse window
// conveniently delays the respawn timer until the body is gone.
var dummyQ = SystemAPI.QueryBuilder().WithAll<TargetDummyTag>().Build();
if (!dummyQ.IsEmpty) { _respawnTimer = 0f; return; }
// Anchor once on the first player seen (spawn point + offset = a consistent training spot).
if (!_anchored)
{
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag>())
{
_anchor = xf.ValueRO.Position + AnchorOffset;
_anchored = true;
break;
}
if (!_anchored) return; // no player yet (world still connecting)
}
_respawnTimer += SystemAPI.Time.DeltaTime; // wall-frame is fine: dev spawner in the plain server group
if (_respawnTimer < RespawnDelaySec) return;
_respawnTimer = 0f;
// Grunt-shaped prefab preferred (no lunge/spit machinery on a training dummy); fall back to any enemy.
var prefQ = EntityManager.CreateEntityQuery(new EntityQueryDesc
{
All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) },
None = new ComponentType[] { typeof(LungeState), typeof(SpitterState) },
Options = EntityQueryOptions.IncludePrefab,
});
var prefabs = prefQ.ToEntityArray(Allocator.Temp);
if (prefabs.Length == 0)
{
prefabs.Dispose();
var anyQ = EntityManager.CreateEntityQuery(new EntityQueryDesc
{
All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) },
Options = EntityQueryOptions.IncludePrefab,
});
prefabs = anyQ.ToEntityArray(Allocator.Temp);
if (prefabs.Length == 0) { prefabs.Dispose(); return; } // no enemy ghosts in this world
}
var prefab = prefabs[0];
prefabs.Dispose();
var inst = EntityManager.Instantiate(prefab);
var baked = EntityManager.GetComponentData<LocalTransform>(prefab);
EntityManager.SetComponentData(inst, baked.WithPosition(_anchor)); // WithPosition: never FromPosition (Scale reset)
EntityManager.SetComponentData(inst, new Health { Current = DummyHealth, Max = DummyHealth });
EntityManager.AddComponent<TargetDummyTag>(inst); // server-only: plants it (every AI pass excludes the tag)
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 036a5a62e96d4ac49b556c748135b478
@@ -0,0 +1,14 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>07-20 sandbox combat testing: marks a server-spawned enemy ghost as a PLANTED target dummy —
/// every EnemyAISystem pass excludes it (no move, no attack, no knockback integration, no depenetration
/// nudge), so it stands at its anchor and just takes hits. Server-only (runtime-added at spawn, never
/// replicated); the client sees a normal interpolated enemy, so health bars, hit flash, damage, kill pop
/// and the death path all exercise the REAL pipeline. Spawned + respawned by TargetDummySpawnSystem in the
/// DevSandbox (GymSub bakes no WaveDirector — the sandbox's combat target is this dummy, by design).
/// ★ EnemyTag-reuse audit (CLAUDE.md): cleave/projectile damage MUST see it (the point); AI passes exclude
/// it; wave/room cleared-checks would count it — acceptable ONLY because the spawner is sandbox-gated.</summary>
public struct TargetDummyTag : IComponentData { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 013cf2990a041494fa876653dff1d059
@@ -84,18 +84,18 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
MeleeDamage = 30f, // 07-19 heavy-weapon retime: bigger hit, slower cadence (DPS ~held: 18/0.27s -> 30/0.50s)
MeleeDamage = 36f, // 07-21 HEAVY LOCK (operator A/B): DPS ~held at 60 (dmg = recover ticks)
MeleeRange = 2.2f, // 07-20 G2.2 reach honesty: axe tip ~1.8m + a 0.4m player-favoring margin (was 2.6)
MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc)
MeleeRecoverTicks = 30f, // heavy-swing lock/recovery (~0.50s; 07-19 weighty-melee retime)
MeleeChainGraceTicks = 24f, // window after the lock to chain the next hit (~0.4s)
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
MeleeRecoverTicks = 36f, // 07-21 HEAVY LOCK: 0.60s light lock (finisher x1.5 = 54t)
MeleeChainGraceTicks = 28f, // 07-21 HEAVY LOCK: ~0.47s chain window after the lock
MeleeSwingMoveScale = 0.25f, // 07-21 HEAVY LOCK: deeper movement commit while swinging
MeleeKnockbackSpeed = 6f,
MeleeFinisherMult = 1.5f, // finisher (last hit) scales dmg/range/recover/knockback (07-19: 1.8 -> 1.5, recover was ballooning at the heavy cadence)
MeleeComboLength = 3f, // light, light, finisher
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
MeleeBufferTicks = 8f, // 07-20 G7: attack press remembered in the last ~0.13s of a lock (0 = off)
MeleeContactTicks = 16f, // 07-20 G2.1: blade lands ~0.27s after swing start (per-step via MeleeTiming; 0 = legacy immediate)
MeleeBufferTicks = 10f, // 07-21 HEAVY LOCK: wider buffer fits the slower cadence (0 = off)
MeleeContactTicks = 20f, // 07-21 HEAVY LOCK: blade lands 20/12/25t per step -- clip speeds retuned to MATCH (wire tool 1.0/0.85/0.88; audit must show zero drift). Step-3 (25t) sits 1 tick under CastFacingTicks 26 -- do NOT raise without raising the cast window.
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dc29876a7efaad54da85c8c668fd3215
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c9422a862aa42ce49be71d2cab58f0b8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
{
"name": "baseline-heavy-0721",
"notes": "THE SHIPPED DEFAULTS (heavy feel locked 07-21 from the operator A/B). Apply to RESET after experiments. DPS 60 held (dmg = recover ticks); clip speeds 1.0/0.85/0.88 match contact 20/12/25.",
"sim": [
{ "k": "MeleeDamage", "v": "36" },
{ "k": "MeleeRange", "v": "2.2" },
{ "k": "MeleeRecoverTicks", "v": "36" },
{ "k": "MeleeChainGraceTicks", "v": "28" },
{ "k": "MeleeFinisherMult", "v": "1.5" },
{ "k": "MeleeFinisherRangeMult", "v": "1.25" },
{ "k": "MeleeBufferTicks", "v": "10" },
{ "k": "MeleeContactTicks", "v": "20" },
{ "k": "MeleeSwingMoveScale", "v": "0.25" }
],
"feel": [
{ "k": "MeleeArcIntensity", "v": "0.8" },
{ "k": "MeleeArcBubbles", "v": "6" },
{ "k": "FinisherHoldFrames", "v": "7" },
{ "k": "MeleeConnectFovKick", "v": "1.1" },
{ "k": "CombatIdleHoldSec", "v": "4" }
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d5f21d9955bf61d4ca0e4543712e8b46
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
{
"name": "heavy-committed",
"notes": "LOCKED into code defaults 2026-07-21 (kept as the record of the winning A/B candidate). 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"
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2ed6c17ceb8dc044f885d858b99e9a21
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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" }
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b58b377cf031b0b489bd1e1234a1f3ec
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
# 2026-07-20 (D) — Sandbox target dummy (planted combat-test target)
**Operator:** enemies don't work in the DevSandbox — replace with a target dummy for combat testing.
**Diagnosis (recorded, not fixed per operator's "anyways"):** `GymSub.unity` bakes **no WaveDirector** — only the
Game arena's `Gameplay.unity` has one — so nothing ever spawns enemies in the sandbox (and Force Next Wave has no
director to force).
## What shipped
- **`TargetDummyTag`** (Simulation, server-only, never replicated): marks a real enemy ghost as a PLANTED dummy.
Every `EnemyAISystem` pass (grunt move/attack, lunge, spitter, depenetration nudge, IsLunging enable — 5
queries) excludes it via `WithNone<TargetDummyTag>`; since those passes are ALSO where `KnockbackState`
integrates into movement, the dummy is planted for free (knockback stamps land but never move it).
- **`TargetDummySpawnSystem`** (Server, editor-only, plain `SimulationSystemGroup`, `DevSandbox` scene-name gate —
the established dev-script convention): keeps exactly ONE dummy alive at the first player's spawn + (3, 0, 1.5),
400 HP (≈4 full chains), instantiated from a grunt-shaped baked enemy prefab (`WithPosition`, never
`FromPosition`). It dies through the NORMAL path (`Dying` corpse window → destroy → kill pop) and respawns
1.5 s after the corpse clears — the corpse window delays the timer for free. Client sees a normal interpolated
enemy: health bar, hit flash, damage numbers, death feedback all exercise the real pipeline.
- **★ EnemyTag-reuse audit** (the CLAUDE.md re-audit rule): damage paths MUST see the dummy (the point);
AI excluded; markers/telemetry count it (cosmetic); wave/room cleared-checks WOULD count it — acceptable only
because the spawner is sandbox-gated where no director runs. If a dummy ever ships outside the sandbox,
re-audit cleared-checks first.
Composes with the tuning bench: the sandbox now always has a target for eyes-on swings; `CombatProbe` still
spawns its own tracked 100k-HP dummy for measured runs (both can coexist — the probe reads its own entity).
**Validation:** console clean; suite deferred mid-session (operator entered Play while it queued — their live
session doubles as the visual validation); dummy presence verified live server-side.
@@ -0,0 +1,41 @@
# 2026-07-20 (C) — Data-driven tuning bench (profiles + audit + probe)
**Operator:** "the knobs are a lot & not very intuitive — is there any way for you to do data-driven tuning?"
The answer: raw scalars are bad for both of us — the operator can't intuit ticks/radians, and I can't feel the
game. What I CAN do precisely is **derive and measure**. Three tools:
## 1. Feel profiles — whole gestalts as data
`Assets/_Project/Tuning/Profiles/*.json` — one file = sim knobs (TuningKnob names) + FeelConfig fields, PARTIAL
(only listed keys apply). `FeelProfileService` (Client/Debug, editor-only) applies via the existing authoritative
SetTuning path + FeelConfig reflection; **"Save current"** captures a hand-dialed state back to a profile.
DebugOverlay grew a Profiles section (one-click A/B) + the probe button. Starter set: `baseline-0720` (the shipped
defaults / reset), `heavy-committed` (MonHun-lean), `snappier` (Hades-lean) — all DPS-held (~60) via dmg = recover.
**Rule: code defaults stay the single shipped truth; profiles are experiments — a winner gets folded into
`TuningConfig.Defaults()` / `FeelConfig.ResetDefaults()` and the profile archived.**
## 2. TuningAuditTools — the derived truth (edit-time, no eyes needed)
`ProjectM/Tuning/Audit Melee Timing + Reach`: derives per-step contact from the ACTUAL clip WindUp takes ÷ wired
state speeds and compares against `MeleeTiming` + the knob (drift detector — kills the hand-copied-constant class
of bug); checks the un-clamped coupling invariants the design review flagged (contact < lock, ≤ CastFacingTicks);
computes cadence/DPS; measures the reach-honesty ratio off the BAKED weapon mesh. First run: **zero drift**
(16/10/20 == clip truth), DPS 60.0 flat, and **reach ratio ×0.93 base / ×1.16 finisher** — the "swings don't line
up" complaint is now a measured number inside the ≤1.25 budget (was >2×).
## 3. CombatProbe — measured play numbers
`CombatProbe.Run(n)` (Client/Debug, editor-only; overlay button): spawns a 100k-HP dummy in the cone, mash-drives
a real injected attack chain, samples the SERVER per frame → per-swing start/scheduled-contact/damage-tick/amount,
measured cadence + DPS + buffer usage. Demo run at defaults: gaps 30/30/45 exactly, contacts +16/+10/+20 exactly,
damage 36/36/54 (class seed × finisher). Then **the full loop**: applied `snappier` → measured 24/24/36 gaps and
+12/+8/+15 contacts immediately. Tuning = author JSON → apply → measure → compare. Probe fix during the demo: wait
for the FINAL swing's contact before finishing (first run cut off swing 6).
## The workflow now
- **Me (precise):** edit/author a profile → `Apply``CombatProbe` → read measured numbers vs targets → iterate.
- **Operator (intuitive):** tap profile buttons in the overlay to A/B whole feels → nudge individual rows if
desired → **Save current** → I fold the winner into code defaults.
Validation: console clean; audit + probe both demonstrated live; profile apply verified against measured sim
output. No netcode surface (profiles ride the existing SetTuning RPC; probe is read-only diagnostics).
**Next:** operator A/Bs the three starter profiles in DevSandbox (DEV overlay → Feel Profiles); tell me which
direction wins (or Save-current a custom) and I fold it into defaults + build the next profile iteration.
@@ -0,0 +1,25 @@
# 2026-07-20 (E) — Weapon grip anchor: gizmo-authored, WYSIWYG
**Operator:** why is the grip code-consts, and can the manipulation show the ACTUAL weapon at true rotation/size?
**Why it was consts:** DOTS + Rukhanka has no runtime bone Transforms — attachments are rigid-skinned SMRs whose
grip is baked INTO the vertex data (the proven suit-kit path), so *something* must feed the bake; consts were the
feel-pass shortcut. The fix is a designer-grade INPUT, not a different runtime model.
**What shipped (PlayerRigTools):**
- **`Player - Create Weapon Grip Anchor (LANTERN)`** — creates `WeaponGripAnchor` under the `Hand_R` bone in
`Player.prefab`, carrying the ACTUAL axe mesh as an edit-time preview (plain `MeshRenderer` + `M_Weapon_Preview`
URP/Lit + the pack atlas — the runtime deformation shader won't render on a static MR). What you grab with
move/rotate/scale gizmos in the prefab stage IS the axe, live, at true size. Seeded from the consts on first
creation only — re-runs never stomp a hand-tuned anchor.
- **`AttachMeleeWeapon` reads the anchor** (localPosition/localRotation/localScale.x) when present; consts are the
fallback/seed. Non-uniform scale warns and takes X.
- **Runtime safety:** the anchor is tagged **`EditorOnly`** — the Entities baker skips it, so the preview can never
leak an un-animated duplicate. Play-verified: exactly one axe on the rig. Until rebake you see BOTH the preview
(new grip) and the baked axe (old grip) in the prefab stage — a free before/after ghost; coincident = in sync.
**The designer loop:** open `Player.prefab` → search `WeaponGripAnchor` → move/rotate/scale with normal gizmos →
`ProjectM/Animation/Player - Attach Melee Weapon` → the baked axe snaps to the preview, visible immediately in the
prefab stage (no Play mode, no recompile). This is the in-Unity twin of the Blender nudge-and-bake workflow, and
the pattern for future rigid attachments (weapon variants, kit pieces): gizmo anchors for spatial authoring,
profiles for numbers, tools as the bake button.
@@ -0,0 +1,42 @@
# 2026-07-21 (F) — HEAVY FEEL LOCKED (the A/B winner folded into code defaults)
**Operator:** "lets lock the heavy feel, commit everything." The `heavy-committed` profile wins the A/B; per the
bench rule the winner folds into code defaults and the profile stays as the record.
## The locked numbers (TuningConfig.Defaults / FeelConfig.ResetDefaults)
| Knob | Was | LOCKED | Note |
|---|---|---|---|
| MeleeRecoverTicks | 30 | **36** | 0.60s light lock; finisher x1.5 = 54t (0.90s) |
| MeleeDamage | 30 | **36** | DPS 60 held (dmg = recover) |
| MeleeContactTicks | 16 | **20** | blade lands 20/12/25t per step |
| MeleeChainGraceTicks | 24 | **28** | |
| MeleeBufferTicks | 8 | **10** | |
| MeleeSwingMoveScale | 0.35 | **0.25** | deeper commit |
| MeleeArcIntensity | 1.0 | **0.8** | the weapon is the read |
| FinisherHoldFrames | 5 | **7** | ~117ms beat |
| MeleeConnectFovKick | 0.8 | **1.1** | |
**Honesty correction while locking:** the profile as played had damage at 20/12/25t while the clips' VISUAL
contacts sat at 16/10/20t (45t drift, the audit flagged it). The lock keeps the approved DAMAGE timing and
retimes the clip state speeds to match — wire-tool swaps now **1.0 / 0.85 / 0.88** → audit shows **ZERO drift**
(derived == configured at every step). Coupling note baked into the knob comment: step-3 contact (25t) sits ONE
tick under CastFacingTicks (26) — never raise the contact knob without raising the cast window.
## Verified
- Audit: zero drift, DPS 60.0 per-light AND full-chain, buffer 10t + batch slack 4t.
- Probe (server-measured, in play): gaps exactly 36/36/54/36/36, contacts +20/+12/+25 exactly, damage 42/42/63
(base 36 x class seed, finisher x1.5).
- 414/414 EditMode green (mechanics tests pin their own config — default drift can't break them, by design).
- **The grip-anchor flow got its first real operator use**: the anchor moved from the seed (0, .06, .02) to the
hand-tuned (0.115, .06, .061) and the baked mesh matches it exactly (expected-radius recompute == baked 1.53m).
Reach with the tuned grip: swept ~1.98m vs 2.2m cone = **x1.11** base (in budget); finisher x1.39 — slightly
over the 1.25 guideline, watch it in play (trim MeleeFinisherRangeMult toward ~1.15 if the finisher reads long).
Profiles: `baseline-0720.json` now carries the LOCKED defaults (display name `baseline-heavy-0721`, the reset
button); `heavy-committed.json` marked as the locked record; `snappier.json` stays as the contrast experiment.
**Next:** hit-react pass (the last missing impact layer: pack React/Stagger clips + a damage-edge anim param +
the Rukhanka-compatible victim-flinch that replaces the cut vibrate) · finisher-reach eyes-on (x1.39) ·
G6 zone-socket telegraph + the SpecialSlam socket's damage-at-contact (same honesty fix melee got).